A way to view raw tool call data #226

Closed
opened 2026-11-06 10:58:29 +01:00 by omp-deck · 0 comments
Collaborator

Problem

Every tool call in the chat transcript is drawn by a per-tool renderer that
shapes the payload for readability. That shaping is lossy: extractResultText
(apps/web/src/components/tools/shared.tsx) keeps only type: "text" content
blocks and drops result.details entirely whenever any text exists, and
summarizeArgs truncates header values to 60–80 characters. When a renderer
looks wrong, shows nothing, or throws, there is no way to see what the tool
actually received and returned.

The data is already fully client-side — no server or protocol work is needed.
ws.ts forwards session_event frames verbatim, in-process.ts snapshot()
returns the SDK message array unmodified, and the reducer stores args,
result, partialResult, isError and status on ToolCallStream
(apps/web/src/lib/types.ts:146-156). Nothing between the agent and the
renderer truncates or redacts.

Wanted: a per-card toggle that swaps the nice renderer for the raw arguments
and raw result, readable enough to actually scan, and copyable as exact JSON.

Plan

1. New pure formatter formatRawJson

Create apps/web/src/lib/raw-json.ts exporting:

export function formatRawJson(value: unknown): string

No equivalent exists — JSON.stringify(v, null, 2) is used directly in
Generic.tsx:11, ToolPermissionDialog.tsx:82, and canvas/StepInspector.tsx,
and none of them render embedded newlines.

It emits pretty-printed JSON with two deviations from JSON.stringify(v, null, 2):

a) Multi-line strings render their line breaks. A string value containing
\n is emitted with real line breaks. Continuation lines are left-padded with
spaces to the column at which that value's own opening " sits, so the block
hangs under the value. Example — input
{"content":[{"type":"text","text":"total 12\ndrwxr-xr-x 3 lucas\n"}],"isError":false}
produces exactly:

{
  "content": [
    {
      "type": "text",
      "text": "total 12
               drwxr-xr-x 3 lucas
               "
    }
  ],
  "isError": false
}

("text" starts at column 6; its value's opening quote is at column 15; every
continuation line gets 15 spaces.) For a string in an array position the same
rule applies against that element's own opening quote. Only \n is converted;
\t, \" and \\ stay escaped so payload text can never be mistaken for
surrounding JSON punctuation. A \r\n in the payload emits the \r escaped
and breaks on the \n.

Consequence to accept, not work around: the output is deliberately not valid
JSON. Copying is handled separately in step 3.

b) Image data is elided. When serializing an object with type === "image"
and a string data property (the ImageBlock shape, lib/types.ts:32-36;
produced by generate_image, browser, and read on raster files), the data
value is replaced by the literal placeholder string

‹image data elided: 123456 chars›

with the real character count substituted. mimeType and all sibling keys are
untouched. Only type: "image" objects are elided; long bash or read text
is rendered in full — that is the point of a raw view. CodeBlock already
degrades on oversized input (MAX_HIGHLIGHT_BYTES = 256 * 1024, then a banner
plus unhighlighted <pre>), and max-h-72 overflow-auto caps the height.

Other cases: undefined returns the empty string; null returns "null";
non-finite numbers serialize as null (matching JSON.stringify). Circular
references cannot occur — every value originated as JSON over the WebSocket —
so no cycle guard is added.

2. Raw view in ToolCallCard

All changes in apps/web/src/components/tools/ToolCallCard.tsx.

Header restructure. The header is currently one full-width <button>
(lines ~89-120). A nested <button> is invalid HTML, so wrap it:

  • An outer <div className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 hover:bg-paper-3/60">.
  • The existing <button> keeps every child it has today (chevron, status dot,
    name, summary, eval badge, the ml-auto status/duration group) and becomes
    className="flex min-w-0 flex-1 items-center gap-2 text-left font-mono text-xs";
    the hover:bg-paper-3/60 and padding move to the wrapper so the hover
    highlight still covers the whole row.
  • A sibling raw toggle button after it:
<button
  type="button"
  aria-pressed={raw}
  title={raw ? "Show rendered view" : "Show raw tool call data"}
  aria-label={raw ? "Show rendered view" : "Show raw tool call data"}
  onClick={() => { setRaw(!raw); if (!open) setToolCardOpen(toolCallId, true); }}
  className={cn(
    "shrink-0 rounded p-1 text-ink-3 hover:bg-paper-3 hover:text-ink",
    raw && "text-accent",
  )}
>
  <Braces className="h-3 w-3" />
</button>

Import Braces from lucide-react alongside the existing ChevronRight,
Loader2. The button is always rendered, including on collapsed cards, so one
press goes from collapsed to raw.

State. const [raw, setRaw] = useState(false); inside ToolCallCard —
per card, ephemeral, no store surface. Import useState from react.

Body. Replace the body block (currently lines ~113-119) with:

{open ? (
  <div className="ml-3 mt-1 border-l border-line pl-3">
    {raw ? (
      <RawToolView args={args} stream={stream} />
    ) : (
      <ToolRenderBoundary resetKey={effectiveProps.stream ?? effectiveProps.args}>
        {renderTool(effectiveName, effectiveProps)}
      </ToolRenderBoundary>
    )}
  </div>
) : null}

RawToolView is passed the outer args and stream destructured from
props, never effectiveArgs / effectiveProps.stream. For write →
xd://<tool> calls, resolveXdevWrite (lines 148-191) synthesizes an inner
name, inner args parsed from args.content, and a rebuilt result with
partialResult dropped; the raw view must show the call that actually
happened, not that reconstruction. For every other tool the two are identical.
Deliberately placed outside ToolRenderBoundary, so raw still works when a
renderer throws.

3. RawToolView component

New file apps/web/src/components/tools/RawToolView.tsx:

export function RawToolView({
  args,
  stream,
}: {
  args: Record<string, unknown>;
  stream?: ToolCallStream;
}) {

Renders a <div className="space-y-1.5"> with two labelled sections, using
SectionLabel from ./shared (<div className="meta mt-2 mb-1">) rather than
the <details> idiom — raw is already an explicit mode, so a second click to
reveal would be redundant:

  1. <SectionLabel>args</SectionLabel> then
    <CodeBlock code={formatRawJson(args)} copyText={() => JSON.stringify(args, null, 2)} language="json" />.
    args is always an object at this boundary; an empty one renders as {}.

  2. Result section. Let const result = stream?.result; const partial = stream?.partialResult;

    • If result !== undefined: label result, body formatRawJson(result).
    • Else if partial !== undefined: label partial, body formatRawJson(partial).
    • Else: label result, and instead of a CodeBlock render
      <div className="meta text-ink-4">{stream ? "no result yet" : "no stream data"}</div>.
      stream is undefined for a tool call block whose id never reached the
      toolCalls map; result is absent while a call is still running.

    The result CodeBlock gets copyText={() => JSON.stringify(result ?? partial, null, 2)}.

Both blocks use language="json". CodeBlock calls
hljs.highlight(text, { language, ignoreIllegals: true }), so the
intentionally-invalid JSON cannot throw; it may highlight oddly past a line
break (unverified — check visually during implementation). If the highlighting
is visibly broken on multi-line payloads, drop language from these two blocks
so CodeBlock falls through to hljs.highlightAuto; do not change the
formatter output to accommodate the highlighter.

No max-h-* override is passed — the CodeBlock default max-h-72 applies.

4. copyText passthrough on CodeBlock

In apps/web/src/lib/code.tsx, add to the Props interface (lines 41-47):

/** Clipboard content, when it must differ from the displayed text (e.g. a
 *  reading-oriented render whose literal bytes are not the real payload). */
copyText?: string | (() => string);

Destructure it in CodeBlock and pass text={copyText ?? text} to both
CopyButton call sites — the oversized/binary branch and the normal branch.
CopyButton already accepts string | (() => string) (lib/CopyButton.tsx),
so no change there. All existing CodeBlock callers omit the prop and keep
copying their displayed text.

This makes the clipboard carry complete, valid, unelided JSON — including full
base64 image data — while the screen shows the readable, elided render.

Verification

Run from the repo root.

  1. cd apps/web && bunx tsc -b --noEmit — compiles clean.

  2. New test file apps/web/src/lib/raw-json.test.ts, matching the existing
    bun:test convention of lib/git-format.test.ts (import { describe, expect, test } from "bun:test").
    apps/web has no test script and no DOM harness, so run it with
    cd apps/web && bun test src/lib/raw-json.test.ts. Cases:

    • Single-line strings and nested objects/arrays match
      JSON.stringify(v, null, 2) byte for byte.
    • The worked {"content":[{"type":"text","text":"total 12\ndrwxr-xr-x 3 lucas\n"}],"isError":false}
      example from step 1a produces the exact expected string, including the
      15-space continuation indent and the lone trailing-quote line.
    • A multi-line string as a bare array element indents to that element's own
      opening quote, not to a key's.
    • \t, \" and \\ inside a rendered multi-line string remain escaped.
    • { type: "image", data: "A".repeat(500), mimeType: "image/png" } renders
      "data": "‹image data elided: 500 chars›" and leaves mimeType intact.
    • An object with type: "text" and a long data string is not elided.
    • formatRawJson(undefined) === "" and formatRawJson(null) === "null".
  3. Manual, in a live session (just dev or the existing dev scripts; open the
    web UI on a session with history):

    • Expand a bash tool call, press the braces button: body swaps to
      args + result, and the shell output inside result reads as real
      indented lines, not one \n-laden line. Press again: the normal renderer
      returns.
    • Press the braces button on a collapsed card: the card opens directly
      into the raw view.
    • On a forgejo_* or git_* call, confirm result.details is visible in
      raw — it is dropped by extractResultText in the rendered view, so this
      is the concrete new capability.
    • On a write → xd://deck_task_list call, confirm raw shows
      path: "xd://deck_task_list" and the content JSON string, i.e. the outer
      call, while the rendered view still shows the inner deck_task_list card.
    • On a browser screenshot or generate_image call, confirm the raw result
      shows the elision placeholder and renders instantly.
    • Hover the raw result block, press Copy, paste into an editor: the result
      is valid parseable JSON with \n escapes restored and full base64 present.

Assumptions & contingencies

  • Assumed the omp SDK persists full tool args and results in its session JSONL,
    so raw is equally complete for a reloaded session as for a live one
    (unverified — the SDK owns that file format and was not inspected). If a
    reloaded session shows a thinner result than the live one, that is an SDK
    storage limit, not a defect in this feature; leave it and report it.
  • Assumed the raw toggle resetting when a card unmounts is acceptable. If
    transcript remounting proves to lose the toggle often enough to annoy, move
    the flag into the toolView store slice as rawPerCard: Record<string, boolean>
    with a setToolCardRaw(id, raw) action mirroring setToolCardOpen
    (lib/store.ts:104-111), and read it the same way open is read.
## Problem Every tool call in the chat transcript is drawn by a per-tool renderer that shapes the payload for readability. That shaping is lossy: `extractResultText` (`apps/web/src/components/tools/shared.tsx`) keeps only `type: "text"` content blocks and drops `result.details` entirely whenever any text exists, and `summarizeArgs` truncates header values to 60–80 characters. When a renderer looks wrong, shows nothing, or throws, there is no way to see what the tool actually received and returned. The data is already fully client-side — no server or protocol work is needed. `ws.ts` forwards `session_event` frames verbatim, `in-process.ts snapshot()` returns the SDK message array unmodified, and the reducer stores `args`, `result`, `partialResult`, `isError` and `status` on `ToolCallStream` (`apps/web/src/lib/types.ts:146-156`). Nothing between the agent and the renderer truncates or redacts. Wanted: a per-card toggle that swaps the nice renderer for the raw arguments and raw result, readable enough to actually scan, and copyable as exact JSON. ## Plan ### 1. New pure formatter `formatRawJson` Create `apps/web/src/lib/raw-json.ts` exporting: ```ts export function formatRawJson(value: unknown): string ``` No equivalent exists — `JSON.stringify(v, null, 2)` is used directly in `Generic.tsx:11`, `ToolPermissionDialog.tsx:82`, and `canvas/StepInspector.tsx`, and none of them render embedded newlines. It emits pretty-printed JSON with two deviations from `JSON.stringify(v, null, 2)`: **a) Multi-line strings render their line breaks.** A string value containing `\n` is emitted with real line breaks. Continuation lines are left-padded with spaces to the column at which that value's own opening `"` sits, so the block hangs under the value. Example — input `{"content":[{"type":"text","text":"total 12\ndrwxr-xr-x 3 lucas\n"}],"isError":false}` produces exactly: ``` { "content": [ { "type": "text", "text": "total 12 drwxr-xr-x 3 lucas " } ], "isError": false } ``` (`"text"` starts at column 6; its value's opening quote is at column 15; every continuation line gets 15 spaces.) For a string in an array position the same rule applies against that element's own opening quote. Only `\n` is converted; `\t`, `\"` and `\\` stay escaped so payload text can never be mistaken for surrounding JSON punctuation. A `\r\n` in the payload emits the `\r` escaped and breaks on the `\n`. Consequence to accept, not work around: the output is deliberately not valid JSON. Copying is handled separately in step 3. **b) Image data is elided.** When serializing an object with `type === "image"` and a string `data` property (the `ImageBlock` shape, `lib/types.ts:32-36`; produced by `generate_image`, `browser`, and `read` on raster files), the `data` value is replaced by the literal placeholder string ``` ‹image data elided: 123456 chars› ``` with the real character count substituted. `mimeType` and all sibling keys are untouched. Only `type: "image"` objects are elided; long `bash` or `read` text is rendered in full — that is the point of a raw view. `CodeBlock` already degrades on oversized input (`MAX_HIGHLIGHT_BYTES = 256 * 1024`, then a banner plus unhighlighted `<pre>`), and `max-h-72 overflow-auto` caps the height. Other cases: `undefined` returns the empty string; `null` returns `"null"`; non-finite numbers serialize as `null` (matching `JSON.stringify`). Circular references cannot occur — every value originated as JSON over the WebSocket — so no cycle guard is added. ### 2. Raw view in `ToolCallCard` All changes in `apps/web/src/components/tools/ToolCallCard.tsx`. **Header restructure.** The header is currently one full-width `<button>` (lines ~89-120). A nested `<button>` is invalid HTML, so wrap it: - An outer `<div className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 hover:bg-paper-3/60">`. - The existing `<button>` keeps every child it has today (chevron, status dot, name, summary, eval badge, the `ml-auto` status/duration group) and becomes `className="flex min-w-0 flex-1 items-center gap-2 text-left font-mono text-xs"`; the `hover:bg-paper-3/60` and padding move to the wrapper so the hover highlight still covers the whole row. - A sibling raw toggle button after it: ```tsx <button type="button" aria-pressed={raw} title={raw ? "Show rendered view" : "Show raw tool call data"} aria-label={raw ? "Show rendered view" : "Show raw tool call data"} onClick={() => { setRaw(!raw); if (!open) setToolCardOpen(toolCallId, true); }} className={cn( "shrink-0 rounded p-1 text-ink-3 hover:bg-paper-3 hover:text-ink", raw && "text-accent", )} > <Braces className="h-3 w-3" /> </button> ``` Import `Braces` from `lucide-react` alongside the existing `ChevronRight`, `Loader2`. The button is always rendered, including on collapsed cards, so one press goes from collapsed to raw. **State.** `const [raw, setRaw] = useState(false);` inside `ToolCallCard` — per card, ephemeral, no store surface. Import `useState` from `react`. **Body.** Replace the body block (currently lines ~113-119) with: ```tsx {open ? ( <div className="ml-3 mt-1 border-l border-line pl-3"> {raw ? ( <RawToolView args={args} stream={stream} /> ) : ( <ToolRenderBoundary resetKey={effectiveProps.stream ?? effectiveProps.args}> {renderTool(effectiveName, effectiveProps)} </ToolRenderBoundary> )} </div> ) : null} ``` `RawToolView` is passed the **outer** `args` and `stream` destructured from `props`, never `effectiveArgs` / `effectiveProps.stream`. For `write` → `xd://<tool>` calls, `resolveXdevWrite` (lines 148-191) synthesizes an inner name, inner args parsed from `args.content`, and a rebuilt `result` with `partialResult` dropped; the raw view must show the call that actually happened, not that reconstruction. For every other tool the two are identical. Deliberately placed outside `ToolRenderBoundary`, so raw still works when a renderer throws. ### 3. `RawToolView` component New file `apps/web/src/components/tools/RawToolView.tsx`: ```tsx export function RawToolView({ args, stream, }: { args: Record<string, unknown>; stream?: ToolCallStream; }) { ``` Renders a `<div className="space-y-1.5">` with two labelled sections, using `SectionLabel` from `./shared` (`<div className="meta mt-2 mb-1">`) rather than the `<details>` idiom — raw is already an explicit mode, so a second click to reveal would be redundant: 1. `<SectionLabel>args</SectionLabel>` then `<CodeBlock code={formatRawJson(args)} copyText={() => JSON.stringify(args, null, 2)} language="json" />`. `args` is always an object at this boundary; an empty one renders as `{}`. 2. Result section. Let `const result = stream?.result; const partial = stream?.partialResult;` - If `result !== undefined`: label `result`, body `formatRawJson(result)`. - Else if `partial !== undefined`: label `partial`, body `formatRawJson(partial)`. - Else: label `result`, and instead of a `CodeBlock` render `<div className="meta text-ink-4">{stream ? "no result yet" : "no stream data"}</div>`. `stream` is `undefined` for a tool call block whose id never reached the `toolCalls` map; `result` is absent while a call is still running. The result `CodeBlock` gets `copyText={() => JSON.stringify(result ?? partial, null, 2)}`. Both blocks use `language="json"`. `CodeBlock` calls `hljs.highlight(text, { language, ignoreIllegals: true })`, so the intentionally-invalid JSON cannot throw; it may highlight oddly past a line break *(unverified — check visually during implementation)*. If the highlighting is visibly broken on multi-line payloads, drop `language` from these two blocks so `CodeBlock` falls through to `hljs.highlightAuto`; do not change the formatter output to accommodate the highlighter. No `max-h-*` override is passed — the `CodeBlock` default `max-h-72` applies. ### 4. `copyText` passthrough on `CodeBlock` In `apps/web/src/lib/code.tsx`, add to the `Props` interface (lines 41-47): ```ts /** Clipboard content, when it must differ from the displayed text (e.g. a * reading-oriented render whose literal bytes are not the real payload). */ copyText?: string | (() => string); ``` Destructure it in `CodeBlock` and pass `text={copyText ?? text}` to **both** `CopyButton` call sites — the oversized/binary branch and the normal branch. `CopyButton` already accepts `string | (() => string)` (`lib/CopyButton.tsx`), so no change there. All existing `CodeBlock` callers omit the prop and keep copying their displayed text. This makes the clipboard carry complete, valid, unelided JSON — including full base64 image data — while the screen shows the readable, elided render. ## Verification Run from the repo root. 1. `cd apps/web && bunx tsc -b --noEmit` — compiles clean. 2. New test file `apps/web/src/lib/raw-json.test.ts`, matching the existing `bun:test` convention of `lib/git-format.test.ts` (`import { describe, expect, test } from "bun:test"`). `apps/web` has no `test` script and no DOM harness, so run it with `cd apps/web && bun test src/lib/raw-json.test.ts`. Cases: - Single-line strings and nested objects/arrays match `JSON.stringify(v, null, 2)` byte for byte. - The worked `{"content":[{"type":"text","text":"total 12\ndrwxr-xr-x 3 lucas\n"}],"isError":false}` example from step 1a produces the exact expected string, including the 15-space continuation indent and the lone trailing-quote line. - A multi-line string as a bare array element indents to that element's own opening quote, not to a key's. - `\t`, `\"` and `\\` inside a rendered multi-line string remain escaped. - `{ type: "image", data: "A".repeat(500), mimeType: "image/png" }` renders `"data": "‹image data elided: 500 chars›"` and leaves `mimeType` intact. - An object with `type: "text"` and a long `data` string is **not** elided. - `formatRawJson(undefined) === ""` and `formatRawJson(null) === "null"`. 3. Manual, in a live session (`just dev` or the existing dev scripts; open the web UI on a session with history): - Expand a `bash` tool call, press the braces button: body swaps to `args` + `result`, and the shell output inside `result` reads as real indented lines, not one `\n`-laden line. Press again: the normal renderer returns. - Press the braces button on a **collapsed** card: the card opens directly into the raw view. - On a `forgejo_*` or `git_*` call, confirm `result.details` is visible in raw — it is dropped by `extractResultText` in the rendered view, so this is the concrete new capability. - On a `write` → `xd://deck_task_list` call, confirm raw shows `path: "xd://deck_task_list"` and the `content` JSON string, i.e. the outer call, while the rendered view still shows the inner `deck_task_list` card. - On a `browser` screenshot or `generate_image` call, confirm the raw result shows the elision placeholder and renders instantly. - Hover the raw result block, press Copy, paste into an editor: the result is valid parseable JSON with `\n` escapes restored and full base64 present. ## Assumptions & contingencies - Assumed the omp SDK persists full tool args and results in its session JSONL, so raw is equally complete for a reloaded session as for a live one *(unverified — the SDK owns that file format and was not inspected)*. If a reloaded session shows a thinner `result` than the live one, that is an SDK storage limit, not a defect in this feature; leave it and report it. - Assumed the raw toggle resetting when a card unmounts is acceptable. If transcript remounting proves to lose the toggle often enough to annoy, move the flag into the `toolView` store slice as `rawPerCard: Record<string, boolean>` with a `setToolCardRaw(id, raw)` action mirroring `setToolCardOpen` (`lib/store.ts:104-111`), and read it the same way `open` is read.
lucas closed this issue 2026-11-06 11:55:13 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#226
No description provided.