diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index ea8815a..fcc3be0 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -171,19 +171,107 @@ describe("buildTranscriptRow: tool calls", () => { expect(head.getAttribute("aria-expanded")).toBe("false"); }); - it("shows raw command, input, and output as separately copyable details", () => { + it("renders command fields and keeps the exact JSON available", () => { const el = buildTranscriptRow( row("tool_call", { title: "Bash(test)", - rawInput: { command: "printf ok", timeout_ms: 1000 }, - rawOutput: { stdout: "ok", exit_code: 0 }, + rawInput: { command: "printf ok", cwd: "/workspace", timeout_ms: 1000 }, + rawOutput: { formatted_output: "ok", exit_code: 0 }, }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); - const labels = [...el.querySelectorAll(".st-tool-detail-label")].map((node) => node.textContent); - expect(labels).toEqual(["Command", "Input", "Output"]); expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok"); - expect(el.querySelectorAll(".st-tool-detail pre[role=button]")).toHaveLength(3); + expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0"); + const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement; + expect(raw.open).toBe(false); + expect(raw.textContent).toContain('"timeout_ms": 1000'); + expect(raw.textContent).toContain('"exit_code": 0'); + }); + + it("renders MCP arguments and results without hiding the source payload", () => { + const el = buildTranscriptRow( + row("tool_call", { + title: "mcp.github.search", + rawInput: { server: "github", tool: "search", arguments: { query: "ACP" } }, + rawOutput: { result: { count: 2 }, error: null }, + }), + ); + (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); + const labels = [ + ...el.querySelectorAll( + ".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail", + ), + ].map((node) => node.querySelector(".st-tool-detail-label")?.textContent); + expect(labels).toEqual(["Arguments", "Result"]); + expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"'); + }); + + it("renders a recognized web search query", () => { + const el = buildTranscriptRow( + row("tool_call", { + title: "Search the web", + rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } }, + }), + ); + (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); + expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query"); + expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol"); + }); + + it("falls back to raw JSON for an unknown provider shape", () => { + const el = buildTranscriptRow( + row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }), + ); + (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); + expect( + el.querySelectorAll( + ".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail", + ), + ).toHaveLength(0); + expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"'); + expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"'); + }); + + it("renders standardized content before provider-specific and raw data", () => { + const el = buildTranscriptRow( + row("tool_call", { + title: "Bash(test)", + content: [ + { type: "content", content: { type: "text", text: "typed result" } }, + { type: "content", content: { type: "resource_link", name: "report", uri: "file:///tmp/report" } }, + { type: "terminal", terminalId: "term-1" }, + ], + rawInput: { command: "test" }, + rawOutput: { formatted_output: "typed result", exit_code: 0 }, + }), + ); + (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); + const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!; + expect([...inner.children].map((node) => node.className)).toEqual([ + "st-tool-text st-copyable", + "st-tool-resource", + "st-tool-terminal", + "st-tool-detail", + "st-tool-fact", + "st-tool-raw", + ]); + expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report"); + expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1"); + }); + + it("renders safe typed image and audio blocks as media controls", () => { + const el = buildTranscriptRow( + row("tool_call", { + title: "Media", + content: [ + { type: "content", content: { type: "image", mimeType: "image/png", data: "aW1hZ2U=" } }, + { type: "content", content: { type: "audio", mimeType: "audio/mpeg", data: "YXVkaW8=" } }, + ], + }), + ); + (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); + expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U="); + expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8="); }); }); diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index 771d074..33dad16 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -246,16 +246,18 @@ function printable(value: unknown): string | null { } } -function toolInput(tool: AcpToolCallLike): { command: string | null; input: string | null } { - const raw = tool.rawInput; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return { command: null, input: printable(raw) }; +function jsonPrintable(value: unknown): string | null { + if (value === undefined) return null; + try { + return JSON.stringify(value, null, 2) ?? null; + } catch { + return printable(value); } - const record = raw as Record; - const command = typeof record.command === "string" ? record.command : null; - if (!command) return { command: null, input: printable(raw) }; - const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== "command")); - return { command, input: Object.keys(rest).length > 0 ? printable(rest) : null }; +} + +function record(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; } function appendToolDetail(container: HTMLElement, label: string, text: string): void { @@ -269,6 +271,200 @@ function appendToolDetail(container: HTMLElement, label: string, text: string): container.appendChild(detail); } +function appendToolValue(container: HTMLElement, label: string, value: unknown, duplicates?: Set): void { + const text = printable(value); + if (text !== null && text !== "" && !duplicates?.has(text)) appendToolDetail(container, label, text); +} + +function appendExitCode(container: HTMLElement, value: unknown): void { + if (typeof value !== "number") return; + const line = el("div", "st-tool-fact"); + const label = el("span", "st-tool-fact-label"); + label.textContent = "Exit code"; + const code = el("code", value === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"); + code.textContent = String(value); + line.append(label, code); + container.appendChild(line); +} + +function appendCommandPayload( + container: HTMLElement, + input: Record, + output: unknown, + contentText: Set, +): boolean { + if (typeof input.command !== "string") return false; + appendToolDetail(container, "Command", input.command); + appendToolValue(container, "Working directory", input.cwd); + + const result = record(output); + if (result) { + appendToolValue(container, "Output", result.formatted_output ?? result.stdout ?? result.output, contentText); + appendExitCode(container, result.exit_code); + } + return true; +} + +function appendMcpPayload( + container: HTMLElement, + input: Record, + output: unknown, + contentText: Set, +): boolean { + if (typeof input.server !== "string" || typeof input.tool !== "string" || !("arguments" in input)) return false; + appendToolValue(container, "Arguments", input.arguments); + const result = record(output); + if (result) { + appendToolValue(container, "Result", result.result, contentText); + appendToolValue(container, "Error", result.error); + } + return true; +} + +function appendSearchPayload( + container: HTMLElement, + input: Record, + tool: AcpToolCallLike, +): boolean { + if (typeof input.query !== "string" || (tool.kind !== "search" && input.type !== "webSearch")) return false; + appendToolDetail(container, "Query", input.query); + appendToolValue(container, "Action", input.action); + return true; +} + +function appendFunctionPayload( + container: HTMLElement, + input: Record, + output: unknown, + contentText: Set, +): boolean { + if (!("arguments" in input)) return false; + appendToolValue(container, "Arguments", input.arguments); + const result = record(output); + if (result) appendToolValue(container, "Result", result.output, contentText); + return true; +} + +function appendKnownToolPayload( + container: HTMLElement, + tool: AcpToolCallLike, + content: AcpToolCallContent[], +): void { + const input = record(tool.rawInput); + if (!input) return; + const contentText = new Set( + content + .filter((block): block is Extract => block.type === "content") + .filter((block) => block.content.type === "text") + .map((block) => (block.content as Extract).text), + ); + if (appendCommandPayload(container, input, tool.rawOutput, contentText)) return; + if (appendMcpPayload(container, input, tool.rawOutput, contentText)) return; + if (appendSearchPayload(container, input, tool)) return; + appendFunctionPayload(container, input, tool.rawOutput, contentText); +} + +function safeMediaSource( + mimeType: string | undefined, + data: string | undefined, + family: "image" | "audio", +): string | null { + if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null; + return `data:${mimeType};base64,${data}`; +} + +function appendContentBlock(container: HTMLElement, block: AcpContentBlock): void { + if (block.type === "text") { + if (!block.text) return; + const text = el("div", "st-tool-text"); + text.textContent = block.text; + makeCopyable(text, block.text, "output"); + container.appendChild(text); + return; + } + if (block.type === "image") { + const source = safeMediaSource(block.mimeType, block.data, "image"); + if (source) { + const image = document.createElement("img"); + image.className = "st-tool-image"; + image.src = source; + image.alt = "Tool output"; + image.loading = "lazy"; + container.appendChild(image); + return; + } + appendToolValue(container, "Image", block.uri ?? block.mimeType ?? "Unavailable"); + return; + } + if (block.type === "audio") { + const source = safeMediaSource(block.mimeType, block.data, "audio"); + if (source) { + const audio = document.createElement("audio"); + audio.className = "st-tool-audio"; + audio.controls = true; + audio.src = source; + container.appendChild(audio); + return; + } + appendToolValue(container, "Audio", block.mimeType ?? "Unavailable"); + return; + } + if (block.type === "resource_link") { + const resource = el("div", "st-tool-resource"); + const name = el("span", "st-tool-resource-name"); + name.textContent = block.title ?? block.name ?? "Resource"; + resource.appendChild(name); + if (block.uri) { + const uri = el("code", "st-tool-resource-uri"); + uri.textContent = block.uri; + makeCopyable(uri, block.uri, "resource URI"); + resource.appendChild(uri); + } + if (block.description) { + const description = el("span", "st-tool-resource-description"); + description.textContent = block.description; + resource.appendChild(description); + } + container.appendChild(resource); + return; + } + const resource = block.resource; + if (typeof resource?.text === "string") { + appendToolDetail(container, resource.uri ? `Resource · ${resource.uri}` : "Resource", resource.text); + } else { + appendToolValue(container, "Resource", resource); + } +} + +function appendStandardContent(container: HTMLElement, content: AcpToolCallContent[]): void { + for (const block of content) { + if (block.type === "content") appendContentBlock(container, block.content); + if (block.type === "diff") container.appendChild(buildDiffBlock(block)); + if (block.type === "terminal") { + const note = el("div", "st-tool-terminal"); + note.textContent = block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"; + container.appendChild(note); + } + } +} + +function appendRawPayload(container: HTMLElement, tool: AcpToolCallLike): void { + const input = jsonPrintable(tool.rawInput); + const output = jsonPrintable(tool.rawOutput); + if (input === null && output === null) return; + + const disclosure = document.createElement("details"); + disclosure.className = "st-tool-raw"; + const summary = document.createElement("summary"); + summary.textContent = "Raw JSON"; + disclosure.appendChild(summary); + const body = el("div", "st-tool-raw-body"); + if (input !== null) appendToolDetail(body, "Input", input); + if (output !== null) appendToolDetail(body, "Output", output); + disclosure.appendChild(body); + container.appendChild(disclosure); +} + function setDisclosure(element: HTMLElement, expanded: boolean, animate = false): void { element.hidden = false; if (animate && expanded && !element.classList.contains("is-expanded")) { @@ -354,30 +550,9 @@ function buildToolRow( const buildOut = () => { if (outBuilt) return; outBuilt = true; - const { command, input } = toolInput(tool); - if (command) appendToolDetail(outInner, "Command", command); - if (input !== null) appendToolDetail(outInner, "Input", input); - const rawOutput = printable(tool.rawOutput); - if (rawOutput !== null) appendToolDetail(outInner, "Output", rawOutput); - const texts = content - .filter((c): c is Extract => c.type === "content") - .map((c) => contentBlockText(c.content)) - .filter(Boolean); - const contentText = texts.join("\n"); - if (contentText && contentText !== rawOutput) { - const textPanel = el("div", "st-tool-text"); - textPanel.textContent = contentText; - makeCopyable(textPanel, contentText, "output"); - outInner.appendChild(textPanel); - } - for (const block of content) { - if (block.type === "diff") outInner.appendChild(buildDiffBlock(block)); - if (block.type === "terminal") { - const note = el("div", "st-tool-text"); - note.textContent = "[embedded terminal]"; - outInner.appendChild(note); - } - } + appendStandardContent(outInner, content); + appendKnownToolPayload(outInner, tool, content); + appendRawPayload(outInner, tool); }; card.appendChild(out); diff --git a/web/src/styles.css b/web/src/styles.css index e07179f..185e45d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2725,6 +2725,109 @@ input.leaf-rename:focus { overflow-x: auto; } +.st-tool-fact { + display: flex; + align-items: baseline; + gap: var(--sp-2); + color: var(--faint); + font-size: var(--fs-11); +} + +.st-tool-fact code { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.st-tool-exit-ok { + color: var(--live); +} + +.st-tool-exit-failed { + color: var(--danger); +} + +.st-tool-image { + display: block; + width: auto; + max-width: min(100%, 720px); + max-height: 480px; + border: 1px solid var(--border); + border-radius: var(--r-1); + object-fit: contain; +} + +.st-tool-audio { + display: block; + width: min(100%, 480px); + min-height: var(--control-h); +} + +.st-tool-resource { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + gap: var(--sp-1) var(--sp-2); + min-width: 0; + padding: var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--r-1); + background: var(--bg); + font-size: var(--fs-12); +} + +.st-tool-resource-name { + color: var(--ink); + font-weight: 500; +} + +.st-tool-resource-uri { + min-width: 0; + overflow-wrap: anywhere; + color: var(--muted); +} + +.st-tool-resource-description { + grid-column: 1 / -1; + color: var(--muted); +} + +.st-tool-terminal { + color: var(--muted); + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.st-tool-raw { + color: var(--faint); + font-size: var(--fs-11); +} + +.st-tool-raw summary { + width: fit-content; + padding-block: var(--sp-1); + cursor: pointer; + user-select: none; + transition: color var(--dur-fast) var(--ease-out); +} + +.st-tool-raw summary:hover, +.st-tool-raw summary:focus-visible { + color: var(--ink); +} + +.st-tool-raw summary:focus-visible { + border-radius: var(--r-1); + outline: 2px solid var(--accent-ring); + outline-offset: 2px; +} + +.st-tool-raw-body { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding-top: var(--sp-1); +} + .st-tool-group { min-width: 0; border: 1px solid var(--border); diff --git a/web/src/types.ts b/web/src/types.ts index 5238b4e..2a10938 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -485,14 +485,28 @@ export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "d // ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ---- -/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a - * generic placeholder rather than being dropped silently. */ +export interface AcpEmbeddedResource { + uri?: string; + mimeType?: string; + text?: string; + blob?: string; +} + +/** `ContentBlock`, including the fields needed to render typed tool output. */ export type AcpContentBlock = | { type: "text"; text: string } - | { type: "image"; mimeType?: string } - | { type: "audio"; mimeType?: string } - | { type: "resource_link"; uri?: string; name?: string } - | { type: "resource"; resource?: unknown }; + | { type: "image"; data?: string; mimeType?: string; uri?: string | null } + | { type: "audio"; data?: string; mimeType?: string } + | { + type: "resource_link"; + uri?: string; + name?: string; + title?: string | null; + description?: string | null; + mimeType?: string | null; + size?: number | null; + } + | { type: "resource"; resource?: AcpEmbeddedResource }; /** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */ export interface AcpContentChunk {