diff --git a/.yoi/tickets/00001KX7179VS/item.md b/.yoi/tickets/00001KX7179VS/item.md index b1bef54c..e04316bd 100644 --- a/.yoi/tickets/00001KX7179VS/item.md +++ b/.yoi/tickets/00001KX7179VS/item.md @@ -2,7 +2,7 @@ title: 'Improve workspace console tool call rendering' state: 'closed' created_at: '2026-07-10T22:08:58Z' -updated_at: '2026-07-10T22:17:10Z' +updated_at: '2026-07-10T22:28:24Z' assignee: null queued_by: 'yoi ticket' queued_at: '2026-07-10T22:09:21Z' diff --git a/.yoi/tickets/00001KX7179VS/thread.md b/.yoi/tickets/00001KX7179VS/thread.md index 51e7e14d..3c7e5e3c 100644 --- a/.yoi/tickets/00001KX7179VS/thread.md +++ b/.yoi/tickets/00001KX7179VS/thread.md @@ -87,4 +87,19 @@ Ticket を closed にしました。 Implemented frontend Workspace Console tool call grouping. Tool call start/arg/done/result events now project into a single `Call · ` block keyed by call id, with streaming state updates and folded result output. Added generic and TUI-aligned specialized summaries for core tools, including Bash command/output rendering. Validated with frontend check/test, diff check, and Nix package build. +--- + + + +## Implementation report + +Follow-up correction: Read rendering now matches the TUI special case more closely. Consecutive Read calls are aggregated into a single `Call · Read` block, and file contents / tool output are not rendered in the console body. The aggregate lists paths and state only, preserving summaries in detail. + +Validation: +- cd web/workspace && deno task check +- cd web/workspace && deno task test +- git diff --check +- nix build .#yoi --no-link + + --- diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts index 22bb27f5..f7c6549c 100644 --- a/web/workspace/src/lib/workspace-console/model.test.ts +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -227,8 +227,80 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo assert(toolLines[0].streaming, "streaming tool call should remain streaming"); assert( toolLines[0].body.includes("/tmp/a.md") && - toolLines[0].body.includes("streaming args"), - "Read call should render the current argument stream and state", + toolLines[0].body.includes("Read — reading"), + "Read call should render aggregate progress and path without content", + ); +}); + +Deno.test("projectConsole aggregates Read calls without showing file content", () => { + const projection = projectConsole([ + { + cursor: "50", + event: { + event: "tool_call_done", + data: { + id: "read-1", + name: "Read", + arguments: '{"file_path":"/tmp/a.md"}', + }, + } satisfies Event, + }, + { + cursor: "51", + event: { + event: "tool_result", + data: { + id: "read-1", + summary: "Read 2 lines", + output: "secret file content\nsecond line\n", + is_error: false, + }, + } satisfies Event, + }, + { + cursor: "52", + event: { + event: "tool_call_done", + data: { + id: "read-2", + name: "Read", + arguments: '{"file_path":"/tmp/b.md"}', + }, + } satisfies Event, + }, + { + cursor: "53", + event: { + event: "tool_result", + data: { + id: "read-2", + summary: "Read 1 line", + output: "another content\n", + is_error: false, + }, + } satisfies Event, + }, + ]); + + 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", + ); + assert( + toolLines[0].body.includes("/tmp/a.md"), + "first path should be listed", + ); + assert( + toolLines[0].body.includes("/tmp/b.md"), + "second path should be listed", + ); + assert( + !toolLines[0].body.includes("secret file content") && + !toolLines[0].body.includes("another content"), + "Read aggregate should not display file contents", ); }); diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts index 9cdfb0bc..a46828b6 100644 --- a/web/workspace/src/lib/workspace-console/model.ts +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -90,12 +90,16 @@ export type ConsoleEventInput = { cursor: string; event: ProtocolEvent }; export function projectConsole( events: ConsoleEventInput[] = [], ): ConsoleProjection { - return events.reduce(applyProtocolEvent, { + const projection = events.reduce(applyProtocolEvent, { lines: [], status: null, usage: null, lastCursor: null, }); + return { + ...projection, + lines: aggregateReadToolLines(projection.lines), + }; } export function applyProtocolEvent( @@ -473,13 +477,76 @@ function renderToolCall(toolCall: ToolCallView): string { } } +function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] { + const result: ConsoleLine[] = []; + let index = 0; + while (index < lines.length) { + const item = lines[index]; + if (item.toolCall?.name !== "Read") { + result.push(item); + index += 1; + continue; + } + + const group: ConsoleLine[] = []; + while (index < lines.length && lines[index].toolCall?.name === "Read") { + group.push(lines[index]); + index += 1; + } + result.push(readAggregateLine(group)); + } + return result; +} + +function readAggregateLine(group: ConsoleLine[]): ConsoleLine { + const calls = group.map((line) => line.toolCall!).filter(Boolean); + const count = calls.length; + const inProgress = calls.some((call) => + !["done", "error"].includes(call.state) + ); + const hasError = calls.some((call) => call.state === "error"); + 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)` + : undefined, + ]); + return { + id: `tool-read-aggregate-${calls.map((call) => call.id).join("-")}`, + kind: "tool", + title: "Call · Read", + body, + detail: calls.map(readDetail).join("\n\n"), + cursor: group.at(-1)?.cursor, + source: "event", + streaming: inProgress, + error: hasError, + }; +} + +function readPath(toolCall: ToolCallView): string { + const args = parsedArgs(toolCall); + return stringField(args, "file_path") ?? "?"; +} + +function readDetail(toolCall: ToolCallView): string { + return compactLines([ + `id: ${toolCall.id}`, + `state: ${stateSuffix(toolCall.state)}`, + `path: ${readPath(toolCall)}`, + toolCall.summary ? `summary: ${toolCall.summary}` : undefined, + ]); +} + function renderReadTool(toolCall: ToolCallView): string { const args = parsedArgs(toolCall); const path = stringField(args, "file_path") ?? "?"; - return compactLines([ - `Read — ${path} (${stateSuffix(toolCall.state)})`, - resultText(toolCall), - ]); + return `Read — ${path} (${stateSuffix(toolCall.state)})`; } function renderWriteTool(toolCall: ToolCallView): string { @@ -655,6 +722,10 @@ function inFlightToolState( } } +function plural(count: number): string { + return count === 1 ? "" : "s"; +} + function stateSuffix(state: ToolCallState): string { switch (state) { case "pending":