workspace: aggregate console read calls

This commit is contained in:
Keisuke Hirata 2026-07-11 07:28:31 +09:00
parent dbc223e9e0
commit 89ba205cac
No known key found for this signature in database
4 changed files with 166 additions and 8 deletions

View File

@ -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'

View File

@ -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 · <tool>` 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.
---
<!-- event: implementation_report author: hare at: 2026-07-10T22:28:24Z -->
## 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
---

View File

@ -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",
);
});

View File

@ -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":