diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock
index a68fd449..25ae28a6 100644
--- a/web/workspace/deno.lock
+++ b/web/workspace/deno.lock
@@ -9,9 +9,9 @@
"npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
"npm:@lezer/highlight@1.2.3": "1.2.3",
- "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
- "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
- "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
+ "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
+ "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
+ "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
"npm:clsx@2.1.1": "2.1.1",
"npm:cookie@0.6.0": "0.6.0",
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
@@ -23,7 +23,8 @@
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
"npm:svelte@5.45.6": "5.45.6",
"npm:typescript@5.9.3": "5.9.3",
- "npm:vite@7.2.7": "7.2.7"
+ "npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
+ "npm:yaml@2.9.0": "2.9.0"
},
"jsr": {
"@std/assert@1.0.19": {
@@ -433,13 +434,13 @@
"acorn"
]
},
- "@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
+ "@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
"dependencies": [
"@sveltejs/kit"
]
},
- "@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
+ "@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
"dependencies": [
"@standard-schema/spec",
@@ -465,7 +466,7 @@
],
"bin": true
},
- "@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_vite@7.2.7": {
+ "@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
@@ -474,7 +475,7 @@
"vite"
]
},
- "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7": {
+ "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte-inspector",
@@ -966,7 +967,7 @@
"vfile-message"
]
},
- "vite@7.2.7": {
+ "vite@7.2.7_yaml@2.9.0": {
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dependencies": [
"esbuild",
@@ -974,14 +975,18 @@
"picomatch",
"postcss",
"rollup",
- "tinyglobby"
+ "tinyglobby",
+ "yaml"
],
"optionalDependencies": [
"fsevents"
],
+ "optionalPeers": [
+ "yaml"
+ ],
"bin": true
},
- "vitefu@1.1.2_vite@7.2.7": {
+ "vitefu@1.1.2_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
"dependencies": [
"vite"
@@ -993,6 +998,10 @@
"w3c-keyname@2.2.8": {
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
+ "yaml@2.9.0": {
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "bin": true
+ },
"zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
},
@@ -1024,7 +1033,8 @@
"packageJson": {
"dependencies": [
"npm:@fontsource/ibm-plex-mono@5.3.0",
- "npm:gen-interface-jp@0.8.0"
+ "npm:gen-interface-jp@0.8.0",
+ "npm:yaml@2.9.0"
]
}
}
diff --git a/web/workspace/package.json b/web/workspace/package.json
index 63ae3ddf..21d7a9c3 100644
--- a/web/workspace/package.json
+++ b/web/workspace/package.json
@@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
- "gen-interface-jp": "0.8.0"
+ "gen-interface-jp": "0.8.0",
+ "yaml": "2.9.0"
}
}
diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte
index c2e3a640..a53e3fa3 100644
--- a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte
+++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte
@@ -8,6 +8,7 @@
};
let { item }: Props = $props();
+ let detailOpen = $state(false);
let nowMs = $state(Date.now());
$effect(() => {
@@ -55,22 +56,20 @@
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
}
- function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
- const [firstLine = '', ...rest] = line.body.split('\n');
- const [label, suffix = ''] = firstLine.split(' — ', 2);
- return {
- label,
- suffix,
- rest: rest.join('\n')
- };
+ function toolLabel(line: ConsoleLine): string {
+ return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
+ }
+
+ function toolStatus(line: ConsoleLine): string {
+ return line.toolStatus ?? line.toolCall?.state ?? '';
}
function shouldRenderMarkdown(line: ConsoleLine): boolean {
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
}
- function bodyTextAfterToolSummary(line: ConsoleLine): string {
- return toolSummary(line).rest;
+ function toolBodyText(line: ConsoleLine): string {
+ return detailOpen ? (line.expandedBody ?? line.body) : line.body;
}
@@ -107,20 +106,27 @@
{:else if item.kind === 'tool'}
- {toolSummary(item).label}
- —
- {toolSummary(item).suffix}
+ {toolLabel(item)}
+ {toolStatus(item)}
+ {#if item.detail}
+
+ {/if}
{/if}
{#if item.compaction}
{:else if item.kind === 'tool'}
- {#if bodyTextAfterToolSummary(item)}
+ {#if toolBodyText(item)}
{#if isBashTool(item)}
-
+
{:else}
- {bodyTextAfterToolSummary(item)}
+ {toolBodyText(item)}
{/if}
{/if}
@@ -147,11 +153,10 @@
{/each}
{/if}
- {#if item.detail}
-
- detail
+ {#if item.detail && detailOpen}
+
{/if}
@@ -306,48 +311,46 @@
.tool-summary {
display: flex;
align-items: baseline;
- gap: 0;
+ gap: 0.5rem;
color: var(--text-muted);
font-size: 0.88rem;
font-weight: 750;
}
.tool-label {
- flex: 0 0 auto;
- color: var(--tui-cyan);
- white-space: nowrap;
- }
-
- .tool-separator {
- flex: 0 0 auto;
- white-space: nowrap;
- }
-
- .tool-suffix {
flex: 1 1 auto;
min-width: 0;
- overflow-wrap: anywhere;
+ overflow: hidden;
+ color: var(--tui-cyan);
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
- .tool-separator,
- .tool-suffix {
+ .tool-status {
+ flex: 0 0 auto;
color: var(--tui-dark-gray);
+ font-size: 0.72rem;
+ white-space: nowrap;
}
- .tool-state-error .tool-suffix {
+ .tool-state-error .tool-status {
color: var(--tui-red);
}
- .tool-state-running .tool-suffix,
- .tool-state-streaming_args .tool-suffix,
- .tool-state-pending .tool-suffix {
+ .tool-state-running .tool-status,
+ .tool-state-streaming_args .tool-status,
+ .tool-state-pending .tool-status {
color: var(--tui-yellow);
}
- .tool-state-done .tool-suffix {
+ .tool-state-done .tool-status {
color: var(--tui-dark-gray);
}
+ .console-line.error-line .tool-status {
+ color: var(--tui-red);
+ }
+
.message-heading {
display: flex;
align-items: center;
@@ -412,13 +415,47 @@
color: var(--code);
}
+ .tool-detail-button {
+ margin-inline-start: auto;
+ border: 1px solid var(--line);
+ border-radius: 0.35rem;
+ padding: 0.08rem 0.35rem;
+ background: var(--bg-raised);
+ color: var(--text-muted);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.68rem;
+ font-weight: 750;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+ }
+
+ .console-line:hover .tool-detail-button,
+ .tool-detail-button:focus-visible,
+ .tool-detail-button[aria-expanded='true'] {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
.message-detail {
+ margin-top: 0.35rem;
+ border-left: 2px solid var(--line);
+ padding-left: 0.6rem;
color: var(--text-muted);
font-size: 0.84rem;
}
- .message-detail summary {
- cursor: pointer;
- font-weight: 800;
+ .message-detail p {
+ margin: 0;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ }
+
+ @media (hover: none) {
+ .tool-detail-button {
+ opacity: 1;
+ pointer-events: auto;
+ }
}
diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts
index 98fb085a..39732416 100644
--- a/web/workspace/src/lib/workspace/console/model.test.ts
+++ b/web/workspace/src/lib/workspace/console/model.test.ts
@@ -313,26 +313,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
!toolLines[0].streaming,
"completed tool call should not remain streaming",
);
- assert(
- toolLines[0].body.includes("$ pwd"),
- "Bash command should be summarized",
- );
+ assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
+ assertEquals(toolLines[0].toolStatus, "done");
assert(
toolLines[0].body.includes("/repo"),
"tool result should be folded into the Call block",
);
assert(
toolLines[0].body.includes("line9"),
- "Bash result preview should include the ninth output line",
+ "Bash preview should include the ninth output line",
);
assert(
!toolLines[0].body.includes("line10") &&
- !toolLines[0].body.includes("line12"),
- "Bash result preview should be capped at ten display lines",
+ toolLines[0].body.includes("… +3 more lines"),
+ "Bash preview should retain its line cap",
);
assert(
- toolLines[0].body.includes("… +3 more lines"),
- "Bash result preview should show omitted output count",
+ toolLines[0].expandedBody?.includes("line12") === true &&
+ !toolLines[0].expandedBody?.includes("more lines"),
+ "Bash detail should show every returned output line",
);
assert(
toolLines[0].detail?.includes("id: call-1"),
@@ -421,7 +420,8 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
- assert(line.body.includes("Bash — failed (exit 7)"), line.body);
+ assertEquals(line.toolCallLabel, "Bash($ long-command)");
+ assertEquals(line.toolStatus, "failed (exit 7)");
assert(!line.body.includes("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("ready\n"), line.body);
@@ -463,7 +463,8 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
- assert(line.body.includes("Bash — running…"), line.body);
+ assertEquals(line.toolCallLabel, "Bash($ slow)");
+ assertEquals(line.toolStatus, "running…");
assert(!line.body.includes("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
@@ -474,7 +475,7 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
assertEquals(line.streaming, true);
});
-Deno.test("projectConsole caps default tool request and result previews", () => {
+Deno.test("projectConsole caps default preview but keeps complete detail body", () => {
const projection = projectConsole([
{
eventId: "70",
@@ -508,19 +509,100 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · CustomTool");
- assertEquals(line.body.split("\n").length, 7);
- assert(line.body.includes("CustomTool — done"), "tool state should be shown");
+ assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
+ assertEquals(line.toolStatus, "done");
+ assertEquals(line.body.split("\n").length, 3);
assert(
- line.body.includes('"first": "one"'),
- "request preview should be shown",
+ line.body.includes("out1") && line.body.includes("… +3 more lines"),
+ "normal display should retain the capped response preview",
+ );
+ assert(!line.body.includes("first"), "request arguments should stay in the Call signature and detail");
+ assert(
+ line.detail?.includes("arguments:\nfirst: one") === true &&
+ line.detail?.includes("fourth: four") === true,
+ "detail metadata should render complete request arguments as YAML",
+ );
+ assert(
+ line.expandedBody?.includes("out5") === true &&
+ !line.expandedBody?.includes("more lines"),
+ "detail body should contain the complete result",
);
- assert(line.body.includes("out1"), "result preview should be shown");
- assert(!line.body.includes("third"), "request preview should be capped");
- assert(!line.body.includes("out3"), "result preview should be capped");
- assert(line.body.includes("… +"), "overflow marker should be shown");
});
-Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
+Deno.test("projectConsole renders JSON tool responses as YAML", () => {
+ const projection = projectConsole([
+ {
+ eventId: "json-call",
+ event: {
+ event: "tool_call_done",
+ data: {
+ id: "json-tool",
+ name: "CustomTool",
+ arguments: "{}",
+ },
+ } satisfies Event,
+ },
+ {
+ eventId: "json-result",
+ event: {
+ event: "tool_result",
+ data: {
+ id: "json-tool",
+ summary: "json completed",
+ output: JSON.stringify({
+ status: "ok",
+ items: [{ id: 1 }, { id: 2 }],
+ }),
+ is_error: false,
+ },
+ } satisfies Event,
+ },
+ {
+ eventId: "invalid-json-call",
+ event: {
+ event: "tool_call_done",
+ data: {
+ id: "invalid-json-tool",
+ name: "CustomTool",
+ arguments: "{}",
+ },
+ } satisfies Event,
+ },
+ {
+ eventId: "invalid-json-result",
+ event: {
+ event: "tool_result",
+ data: {
+ id: "invalid-json-tool",
+ summary: "invalid json",
+ output: '{"status": broken}',
+ is_error: false,
+ },
+ } satisfies Event,
+ },
+ ]);
+
+ const toolLines = projection.lines.filter((line) => line.kind === "tool");
+ const jsonLine = toolLines.find((line) => line.id.includes("json-tool"));
+ const invalidLine = toolLines.find((line) => line.id.includes("invalid-json-tool"));
+ assert(jsonLine, "JSON tool line should be projected");
+ assert(invalidLine, "invalid JSON tool line should be projected");
+ assert(
+ jsonLine.expandedBody?.includes("status: ok") === true &&
+ jsonLine.expandedBody?.includes(" - id: 2") === true,
+ "detail body should serialize parsed JSON as YAML",
+ );
+ assert(
+ jsonLine.body.includes("more lines"),
+ "normal preview should cap the pretty-printed JSON",
+ );
+ assert(
+ invalidLine.expandedBody?.includes('{"status": broken}') === true,
+ "invalid JSON-looking output should remain unchanged",
+ );
+});
+
+Deno.test("projectConsole caps Grep preview but keeps complete detail body", () => {
const projection = projectConsole([
{
eventId: "72",
@@ -549,17 +631,19 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
- assert(
- line.body.includes("Grep — 6 matches"),
- "Grep summary should be shown",
- );
- assert(line.body.includes("query: needle"), "Grep query should be shown");
+ assertEquals(line.toolCallLabel, "Grep(needle)");
+ assertEquals(line.toolStatus, "done");
assert(line.body.includes("hit1"), "first result should be shown");
assert(line.body.includes("hit5"), "fifth result should be shown");
- assert(!line.body.includes("hit6"), "sixth result should be capped");
+ assert(!line.body.includes("hit6"), "normal preview should retain its result cap");
assert(
line.body.includes("… +1 more results"),
- "overflow marker should be shown",
+ "preview should show the omitted result count",
+ );
+ assert(
+ line.expandedBody?.includes("hit6") === true &&
+ !line.expandedBody?.includes("more results"),
+ "detail body should show every Grep result",
);
});
@@ -594,17 +678,15 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => {
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
- assert(
- line.body.includes("Grep — Failed"),
- "error suffix should stay short",
- );
+ assertEquals(line.toolCallLabel, "Grep(needle)");
+ assertEquals(line.toolStatus, "error");
assert(
line.body.includes(message),
"error detail should remain visible in the body",
);
assert(
- !line.body.includes(`Grep — ${message}`),
- "error detail should not be repeated in the suffix",
+ !line.toolCallLabel?.includes(message),
+ "error detail should not be repeated in the Call signature",
);
});
@@ -879,9 +961,10 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read");
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
+ assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
+ assertEquals(toolLines[0].toolStatus, "reading…");
assert(
- toolLines[0].body.includes("/tmp/a.md") &&
- toolLines[0].body.includes("Read — reading"),
+ toolLines[0].body.includes("/tmp/a.md"),
"Read call should render aggregate progress and path without content",
);
});
@@ -1018,10 +1101,8 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
const toolLines = projection.lines.filter((line) => line.kind === "tool");
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read");
- assert(
- toolLines[0].body.includes("Read — 2 files read"),
- "aggregate count should be shown",
- );
+ assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
+ assertEquals(toolLines[0].toolStatus, "done");
assert(
toolLines[0].body.includes("/tmp/a.md"),
"first path should be listed",
@@ -1073,7 +1154,9 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () =>
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Edit");
- assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
+ assertEquals(line.toolCallLabel, "Edit(/tmp/a.md)");
+ assertEquals(line.toolStatus, "done");
+ assertEquals(line.body, "ok");
assertEquals(line.diff?.map((row) => row.kind), [
"context",
"remove",
@@ -1242,13 +1325,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
assertEquals(projection.status, "running");
assertEquals(
projection.lines.map((line) =>
- `${line.kind}:${line.body}:${line.streaming}`
+ `${line.kind}:${line.toolCallLabel ? `${line.toolCallLabel}\n${line.body}` : line.body}:${line.streaming}`
),
[
"user:seed user:false",
"user:new user:false",
"assistant:assistant reply:false",
- "tool:Read — 1 file read\n /tmp/a.md:false",
+ "tool:Read(1 file)\n /tmp/a.md:false",
"status:Compacting…:true",
"in_flight:partial:true",
],
@@ -1476,25 +1559,26 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
},
]);
- const bodies = projection.lines.filter((line) => line.kind === "tool").map((
- line,
- ) => line.body);
- assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
+ const toolLines = projection.lines.filter((line) => line.kind === "tool");
+ const bodies = toolLines.map((line) => line.body);
+ assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
+ assertEquals(bodies[0], " src/main.rs");
assert(
projection.lines[0].detail?.includes("from src/main.rs"),
"Read summary detail path should be relative",
);
assert(
- bodies.some((body) =>
- body.includes("Write — out.txt") && body.includes("Wrote out.txt")
+ toolLines.some((line) =>
+ line.toolCallLabel === "Write(out.txt)" && line.body.includes("Wrote out.txt")
),
- "Write header and known result path should be relative",
+ "Write signature and known result path should be relative",
);
assert(
- bodies.some((body) =>
- body.includes("Edit — src/main.rs") && body.includes("Edited src/main.rs")
+ toolLines.some((line) =>
+ line.toolCallLabel === "Edit(src/main.rs)" &&
+ line.body.includes("Edited src/main.rs")
),
- "Edit header and known result path should be relative",
+ "Edit signature and known result path should be relative",
);
assert(
bodies.some((body) =>
diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts
index 67a0f29e..d9e56953 100644
--- a/web/workspace/src/lib/workspace/console/model.ts
+++ b/web/workspace/src/lib/workspace/console/model.ts
@@ -11,6 +11,7 @@ import type {
InternalWorkerSnapshot,
Segment,
} from "$lib/generated/protocol";
+import { stringify as stringifyYaml } from "yaml";
import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyRunActivityEvent,
@@ -86,6 +87,9 @@ export type ConsoleLine = {
kind: ConsoleLineKind;
title: string;
body: string;
+ expandedBody?: string;
+ toolCallLabel?: string;
+ toolStatus?: string;
detail?: string;
compaction?: ConsoleCompaction;
diff?: ConsoleDiffLine[];
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
title: item.title.startsWith("Call · Tool result")
? item.title
: `Call · ${toolCall.name}`,
- body: renderToolCall(toolCall),
+ body: renderToolResponse(toolCall),
+ expandedBody: renderToolResponse(toolCall, true),
+ toolCallLabel: toolCallSignature(toolCall),
+ toolStatus: toolCallStatus(toolCall),
detail: toolCallDetail(toolCall),
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
@@ -1384,7 +1391,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
};
}
-function renderToolCall(toolCall: ToolCallView): string {
+function renderToolResponse(toolCall: ToolCallView, expanded = false): string {
switch (toolCall.name) {
case "Read":
return renderReadTool(toolCall);
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
case "Glob":
return renderSearchTool(toolCall);
case "Grep":
- return renderGrepTool(toolCall);
+ return renderGrepTool(toolCall, expanded);
case "Bash":
- return renderBashTool(toolCall);
+ return renderBashTool(toolCall, expanded);
default:
- return renderDefaultTool(toolCall);
+ return renderDefaultTool(toolCall, expanded);
}
}
+function toolCallSignature(toolCall: ToolCallView): string {
+ const args = parsedArgs(toolCall);
+ switch (toolCall.name) {
+ case "Read":
+ return `Read(${readPath(toolCall)})`;
+ case "Write":
+ case "Edit": {
+ const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
+ return `${toolCall.name}(${path})`;
+ }
+ case "Glob":
+ return `Glob(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
+ case "Grep":
+ return `Grep(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
+ case "Bash": {
+ const command = stringField(args, "command");
+ return `Bash(${command ? `$ ${singleLine(command)}` : genericCallArguments(toolCall)})`;
+ }
+ default:
+ return `${toolCall.name}(${genericCallArguments(toolCall)})`;
+ }
+}
+
+function genericCallArguments(toolCall: ToolCallView): string {
+ const raw = toolCall.arguments ?? toolCall.argsStream;
+ if (!raw.trim()) return "";
+ const parsed = parseJson(raw);
+ if (parsed === undefined) return singleLine(raw);
+ const serialized = JSON.stringify(parsed) ?? "null";
+ return isRecord(parsed) ? serialized.slice(1, -1) : serialized;
+}
+
+function singleLine(value: string): string {
+ return value.replace(/\s+/g, " ").trim();
+}
+
+function toolCallStatus(toolCall: ToolCallView): string {
+ return toolCall.name === "Bash" ? commandStateSuffix(toolCall) : stateSuffix(toolCall.state);
+}
+
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
const result: ConsoleLine[] = [];
let index = 0;
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
const paths = calls.map(readPath);
const visiblePaths = inProgress ? paths.slice(-3) : paths;
const body = compactLines([
- inProgress
- ? `Read — reading (${count} file${plural(count)}…)`
- : `Read — ${count} file${plural(count)} read`,
visiblePaths.map((path) => ` ${path}`).join("\n"),
inProgress && paths.length > visiblePaths.length
? ` … (${paths.length - visiblePaths.length} earlier)`
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
kind: "tool",
title: "Call · Read",
body,
+ toolCallLabel: `Read(${count} file${plural(count)})`,
+ toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
detail: calls.map(readDetail).join("\n\n"),
eventId: group.at(-1)?.eventId,
source: "event",
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
]);
}
-function renderReadTool(toolCall: ToolCallView): string {
- return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
+function renderReadTool(_toolCall: ToolCallView): string {
+ return "";
}
function renderWriteTool(toolCall: ToolCallView): string {
- const args = parsedArgs(toolCall);
- const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
- const content = stringField(args, "content");
- return compactLines([
- `Write — ${path} (${stateSuffix(toolCall.state)})`,
- cappedSection(content, 5),
- knownToolResultText(toolCall),
- ]);
+ return knownToolResultText(toolCall) ?? "";
}
function renderEditTool(toolCall: ToolCallView): string {
- const args = parsedArgs(toolCall);
- const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
- const diff = editDiff(toolCall) ?? [];
- const removes = diff.filter((line) => line.kind === "remove").length;
- const adds = diff.filter((line) => line.kind === "add").length;
- return compactLines([
- `Edit — ${path} (${stateSuffix(toolCall.state)})`,
- diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
- knownToolResultText(toolCall),
- ]);
+ return knownToolResultText(toolCall) ?? "";
}
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
}
function renderSearchTool(toolCall: ToolCallView): string {
- const summary = toolCall.summary?.trim();
- return compactLines([
- `${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
- knownToolResultText(toolCall),
- ]);
+ return knownToolResultText(toolCall) ?? "";
}
-function renderGrepTool(toolCall: ToolCallView): string {
- const summary = toolCall.summary?.trim();
- return compactLines([
- `Grep — ${toolHeaderSuffix(toolCall, summary)}`,
- grepQueryText(toolCall),
- cappedResultSection(knownToolResultText(toolCall), 5),
- ]);
+function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
+ const result = knownToolResultText(toolCall);
+ return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
}
-function toolHeaderSuffix(
- toolCall: ToolCallView,
- summary?: string,
-): string {
- if (toolCall.state === "error") {
- return "Failed";
+function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
+ if (["done", "error"].includes(toolCall.state)) {
+ const result = resultText(toolCall);
+ return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
}
- return summary ? firstLine(summary) : stateSuffix(toolCall.state);
-}
-
-function grepQueryText(toolCall: ToolCallView): string | undefined {
- const args = parsedArgs(toolCall);
- const pattern = stringField(args, "pattern");
- if (pattern) {
- return `query: ${pattern}`;
- }
- const renderedArgs = argsText(toolCall);
- return renderedArgs ? `query:\n${renderedArgs}` : undefined;
-}
-
-function renderBashTool(toolCall: ToolCallView): string {
- const args = parsedArgs(toolCall);
- const command = stringField(args, "command");
- return compactLines([
- `Bash — ${commandStateSuffix(toolCall)}`,
- command ? `$ ${command}` : argsText(toolCall),
- ["done", "error"].includes(toolCall.state)
- ? cappedDisplaySection(resultText(toolCall), 10)
- : renderLiveCommandOutput(toolCall.command),
- ]);
+ return renderLiveCommandOutput(toolCall.command) ?? "";
}
function commandStateSuffix(toolCall: ToolCallView): string {
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
]);
}
-function renderDefaultTool(toolCall: ToolCallView): string {
- return compactLines([
- `${toolCall.name} — ${stateSuffix(toolCall.state)}`,
- cappedDisplaySection(argsText(toolCall), 3),
- cappedDisplaySection(resultText(toolCall), 3),
- ]);
+function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
+ const result = resultText(toolCall);
+ return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
}
function toolCallDetail(toolCall: ToolCallView): string {
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
}
function resultText(toolCall: ToolCallView): string | undefined {
- if (toolCall.output) {
- return toolCall.output;
+ const text = toolCall.output || toolCall.summary;
+ return text ? formatJsonResponseAsYaml(text) : undefined;
+}
+
+function formatJsonResponseAsYaml(text: string): string {
+ const trimmed = text.trim();
+ if (
+ !(
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
+ )
+ ) {
+ return text;
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ if (parsed === null || typeof parsed !== "object") {
+ return text;
+ }
+ return stringifyYaml(parsed).trimEnd();
+ } catch {
+ return text;
}
- return toolCall.summary;
}
function knownToolResultText(toolCall: ToolCallView): string | undefined {
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
return "";
}
const parsed = parseJson(raw);
- return parsed === undefined ? raw : jsonPreview(parsed);
+ return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
}
function parsedArgs(
@@ -1830,21 +1845,6 @@ function compactLines(lines: Array): string {
return lines.filter((line): line is string => Boolean(line)).join("\n");
}
-function cappedSection(
- value: string | undefined,
- cap: number,
-): string | undefined {
- if (!value) {
- return undefined;
- }
- const lines = value.split(/\r?\n/);
- const shown = lines.slice(0, cap);
- if (lines.length > cap) {
- shown.push(`… +${lines.length - cap} more lines`);
- }
- return shown.join("\n");
-}
-
function cappedDisplaySection(
value: string | undefined,
maxLines: number,
diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
index 2cb0fffe..c1d2a31f 100644
--- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
+++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
@@ -402,7 +402,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes("{#if isBashTool(item)}") &&
consoleLine.includes(
- "",
+ "",
) &&
consoleLine.includes(
".console-line.tool-bash .console-plain-text",
@@ -419,6 +419,30 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
);
});
+Deno.test("Worker Console expands uncapped tool body from the hover detail action", async () => {
+ const consoleLine = await Deno.readTextFile(
+ new URL("./ConsoleLineItem.svelte", import.meta.url),
+ );
+
+ assert(
+ consoleLine.includes(
+ "return detailOpen ? (line.expandedBody ?? line.body) : line.body",
+ ) &&
+ consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
+ consoleLine.includes('class={`tool-status') &&
+ consoleLine.includes('class="tool-detail-button"') &&
+ consoleLine.includes("aria-expanded={detailOpen}") &&
+ consoleLine.includes("detailOpen = !detailOpen") &&
+ consoleLine.includes("item.detail && detailOpen") &&
+ consoleLine.includes('role="region"') &&
+ consoleLine.includes(".console-line:hover .tool-detail-button") &&
+ consoleLine.includes(".tool-detail-button:focus-visible") &&
+ consoleLine.includes("@media (hover: none)") &&
+ !consoleLine.includes(''),
+ "Normal tool display should keep its preview while detail reveals the uncapped body and existing metadata",
+ );
+});
+
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),