workspace: aggregate console read calls
This commit is contained in:
parent
dbc223e9e0
commit
89ba205cac
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user