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'
|
title: 'Improve workspace console tool call rendering'
|
||||||
state: 'closed'
|
state: 'closed'
|
||||||
created_at: '2026-07-10T22:08:58Z'
|
created_at: '2026-07-10T22:08:58Z'
|
||||||
updated_at: '2026-07-10T22:17:10Z'
|
updated_at: '2026-07-10T22:28:24Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'yoi ticket'
|
queued_by: 'yoi ticket'
|
||||||
queued_at: '2026-07-10T22:09:21Z'
|
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.
|
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].streaming, "streaming tool call should remain streaming");
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/tmp/a.md") &&
|
toolLines[0].body.includes("/tmp/a.md") &&
|
||||||
toolLines[0].body.includes("streaming args"),
|
toolLines[0].body.includes("Read — reading"),
|
||||||
"Read call should render the current argument stream and state",
|
"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(
|
export function projectConsole(
|
||||||
events: ConsoleEventInput[] = [],
|
events: ConsoleEventInput[] = [],
|
||||||
): ConsoleProjection {
|
): ConsoleProjection {
|
||||||
return events.reduce(applyProtocolEvent, {
|
const projection = events.reduce(applyProtocolEvent, {
|
||||||
lines: [],
|
lines: [],
|
||||||
status: null,
|
status: null,
|
||||||
usage: null,
|
usage: null,
|
||||||
lastCursor: null,
|
lastCursor: null,
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
...projection,
|
||||||
|
lines: aggregateReadToolLines(projection.lines),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyProtocolEvent(
|
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 {
|
function renderReadTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
const args = parsedArgs(toolCall);
|
||||||
const path = stringField(args, "file_path") ?? "?";
|
const path = stringField(args, "file_path") ?? "?";
|
||||||
return compactLines([
|
return `Read — ${path} (${stateSuffix(toolCall.state)})`;
|
||||||
`Read — ${path} (${stateSuffix(toolCall.state)})`,
|
|
||||||
resultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWriteTool(toolCall: ToolCallView): string {
|
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 {
|
function stateSuffix(state: ToolCallState): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "pending":
|
case "pending":
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user