workspace: group console tool calls
This commit is contained in:
parent
a5417320eb
commit
2a6930a946
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Improve workspace console tool call rendering'
|
title: 'Improve workspace console tool call rendering'
|
||||||
state: 'inprogress'
|
state: 'done'
|
||||||
created_at: '2026-07-10T22:08:58Z'
|
created_at: '2026-07-10T22:08:58Z'
|
||||||
updated_at: '2026-07-10T22:09:21Z'
|
updated_at: '2026-07-10T22:16:58Z'
|
||||||
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'
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,34 @@ Ticket を `yoi ticket` が queued にしました。
|
||||||
State changed to `inprogress`.
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-10T22:16:58Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented Workspace Console tool-call grouping.
|
||||||
|
|
||||||
|
- Tool lifecycle events now update one `Call · <tool>` console line keyed by call id.
|
||||||
|
- Args streaming, call completion, and tool result output are folded into that line instead of producing separate `tool call` / `tool call done` / `tool result` rows.
|
||||||
|
- Added generic tool rendering plus TUI-aligned special summaries for Read, Write, Edit, Glob, and Grep; Bash renders the command and output in the same Call block.
|
||||||
|
- In-flight tool snapshots now render as Call blocks as well.
|
||||||
|
- Added model tests for completed Bash call aggregation and streaming Read call updates.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:16:58Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -105,11 +105,15 @@ Deno.test("projectConsole projects visible protocol rows", () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
projection.lines.some((line) => line.source === "event" && line.kind === "user"),
|
projection.lines.some((line) =>
|
||||||
|
line.source === "event" && line.kind === "user"
|
||||||
|
),
|
||||||
"user protocol row expected",
|
"user protocol row expected",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
projection.lines.some((line) => line.source === "event" && line.kind === "assistant"),
|
projection.lines.some((line) =>
|
||||||
|
line.source === "event" && line.kind === "assistant"
|
||||||
|
),
|
||||||
"assistant protocol row expected",
|
"assistant protocol row expected",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -134,6 +138,100 @@ Deno.test("projectConsole projects visible protocol rows", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole groups tool call lifecycle into one Call block", () => {
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
cursor: "40",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_start",
|
||||||
|
data: { id: "call-1", name: "Bash" },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "41",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_args_delta",
|
||||||
|
data: { id: "call-1", json: '{"command":"pw' },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "42",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_args_delta",
|
||||||
|
data: { id: "call-1", json: 'd"}' },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "43",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: { id: "call-1", name: "Bash", arguments: '{"command":"pwd"}' },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "44",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "call-1",
|
||||||
|
summary: "command completed",
|
||||||
|
output: "/repo\n",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
assertEquals(toolLines.length, 1);
|
||||||
|
assertEquals(toolLines[0].title, "Call · Bash");
|
||||||
|
assert(
|
||||||
|
!toolLines[0].streaming,
|
||||||
|
"completed tool call should not remain streaming",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
toolLines[0].body.includes("$ pwd"),
|
||||||
|
"Bash command should be summarized",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
toolLines[0].body.includes("/repo"),
|
||||||
|
"tool result should be folded into the Call block",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
toolLines[0].detail?.includes("id: call-1"),
|
||||||
|
"call id should remain in detail",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
cursor: "45",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_start",
|
||||||
|
data: { id: "call-2", name: "Read" },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "46",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_args_delta",
|
||||||
|
data: { id: "call-2", json: '{"file_path":"/tmp/a.md"}' },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
assertEquals(toolLines.length, 1);
|
||||||
|
assertEquals(toolLines[0].title, "Call · Read");
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
|
Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
|
|
@ -163,7 +261,10 @@ Deno.test("projectConsole keeps protocol lifecycle events out of the console sur
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cursor: "32",
|
cursor: "32",
|
||||||
event: { event: "turn_end", data: { turn: 0, result: "finished" } } satisfies Event,
|
event: {
|
||||||
|
event: "turn_end",
|
||||||
|
data: { turn: 0, result: "finished" },
|
||||||
|
} satisfies Event,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cursor: "33",
|
cursor: "33",
|
||||||
|
|
@ -213,7 +314,9 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol
|
||||||
|
|
||||||
assertEquals(projection.status, "running");
|
assertEquals(projection.status, "running");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`),
|
projection.lines.map((line) =>
|
||||||
|
`${line.kind}:${line.body}:${line.streaming}`
|
||||||
|
),
|
||||||
["in_flight:partial:true"],
|
["in_flight:partial:true"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
Event as ProtocolEvent,
|
Event as ProtocolEvent,
|
||||||
InFlightBlock,
|
InFlightBlock,
|
||||||
|
InFlightToolCallState,
|
||||||
Segment,
|
Segment,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
import { workspaceRoute } from "$lib/workspace-api/http";
|
import { workspaceRoute } from "$lib/workspace-api/http";
|
||||||
|
|
@ -16,6 +17,24 @@ export type ConsoleLineKind =
|
||||||
| "in_flight"
|
| "in_flight"
|
||||||
| "system";
|
| "system";
|
||||||
|
|
||||||
|
type ToolCallState =
|
||||||
|
| "pending"
|
||||||
|
| "streaming_args"
|
||||||
|
| "running"
|
||||||
|
| "done"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
type ToolCallView = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
argsStream: string;
|
||||||
|
arguments?: string;
|
||||||
|
state: ToolCallState;
|
||||||
|
summary?: string;
|
||||||
|
output?: string | null;
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConsoleLine = {
|
export type ConsoleLine = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: ConsoleLineKind;
|
kind: ConsoleLineKind;
|
||||||
|
|
@ -26,6 +45,7 @@ export type ConsoleLine = {
|
||||||
source: "event";
|
source: "event";
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
|
toolCall?: ToolCallView;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConsoleProjection = {
|
export type ConsoleProjection = {
|
||||||
|
|
@ -40,7 +60,10 @@ export type WorkerTarget = {
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function workerConsoleHref(target: WorkerTarget, workspaceId: string): string {
|
export function workerConsoleHref(
|
||||||
|
target: WorkerTarget,
|
||||||
|
workspaceId: string,
|
||||||
|
): string {
|
||||||
return workspaceRoute(
|
return workspaceRoute(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
`/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
|
`/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
|
||||||
|
|
@ -56,12 +79,17 @@ export function workerConsolePath(
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
workerId: string,
|
workerId: string,
|
||||||
): string {
|
): string {
|
||||||
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId);
|
return workerConsoleHref(
|
||||||
|
{ runtime_id: runtimeId, worker_id: workerId },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConsoleEventInput = { cursor: string; event: ProtocolEvent };
|
export type ConsoleEventInput = { cursor: string; event: ProtocolEvent };
|
||||||
|
|
||||||
export function projectConsole(events: ConsoleEventInput[] = []): ConsoleProjection {
|
export function projectConsole(
|
||||||
|
events: ConsoleEventInput[] = [],
|
||||||
|
): ConsoleProjection {
|
||||||
return events.reduce(applyProtocolEvent, {
|
return events.reduce(applyProtocolEvent, {
|
||||||
lines: [],
|
lines: [],
|
||||||
status: null,
|
status: null,
|
||||||
|
|
@ -138,43 +166,28 @@ export function applyProtocolEvent(
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "tool_call_start":
|
case "tool_call_start":
|
||||||
next.lines.push(
|
upsertToolCall(next, envelope.cursor, event.data.id, {
|
||||||
line(
|
name: event.data.name,
|
||||||
envelope.cursor,
|
state: "pending",
|
||||||
"tool",
|
});
|
||||||
`tool call · ${event.data.name}`,
|
|
||||||
`id: ${event.data.id}`,
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "tool_call_args_delta":
|
case "tool_call_args_delta":
|
||||||
appendToolArgs(next, envelope.cursor, event.data.id, event.data.json);
|
appendToolArgs(next, envelope.cursor, event.data.id, event.data.json);
|
||||||
break;
|
break;
|
||||||
case "tool_call_done":
|
case "tool_call_done":
|
||||||
next.lines.push(
|
upsertToolCall(next, envelope.cursor, event.data.id, {
|
||||||
line(
|
name: event.data.name,
|
||||||
envelope.cursor,
|
arguments: event.data.arguments,
|
||||||
"tool",
|
argsStream: event.data.arguments,
|
||||||
`tool call done · ${event.data.name}`,
|
state: "running",
|
||||||
event.data.arguments,
|
});
|
||||||
`id: ${event.data.id}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
next.lines.push(
|
attachToolResult(next, envelope.cursor, event.data.id, {
|
||||||
line(
|
summary: event.data.summary,
|
||||||
envelope.cursor,
|
output: event.data.output,
|
||||||
"tool",
|
isError: event.data.is_error,
|
||||||
event.data.is_error ? "tool result error" : "tool result",
|
});
|
||||||
event.data.output ?? event.data.summary,
|
|
||||||
`id: ${event.data.id} · ${event.data.summary}`,
|
|
||||||
false,
|
|
||||||
event.data.is_error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "usage":
|
case "usage":
|
||||||
next.usage = usageText(event.data);
|
next.usage = usageText(event.data);
|
||||||
|
|
@ -326,34 +339,265 @@ function finalizeStreaming(
|
||||||
projection.lines.push(line(cursor, kind, title, body));
|
projection.lines.push(line(cursor, kind, title, body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertToolCall(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
cursor: string,
|
||||||
|
id: string,
|
||||||
|
update: Partial<Omit<ToolCallView, "id">>,
|
||||||
|
): ConsoleLine {
|
||||||
|
let existing = findToolCallLine(projection, id);
|
||||||
|
if (!existing) {
|
||||||
|
existing = toolLine(cursor, {
|
||||||
|
id,
|
||||||
|
name: update.name ?? "Tool",
|
||||||
|
argsStream: update.argsStream ?? "",
|
||||||
|
arguments: update.arguments,
|
||||||
|
state: update.state ?? "pending",
|
||||||
|
summary: update.summary,
|
||||||
|
output: update.output,
|
||||||
|
isError: update.isError,
|
||||||
|
});
|
||||||
|
projection.lines.push(existing);
|
||||||
|
} else {
|
||||||
|
existing.cursor = cursor;
|
||||||
|
existing.toolCall = {
|
||||||
|
...existing.toolCall!,
|
||||||
|
...update,
|
||||||
|
id,
|
||||||
|
name: update.name ?? existing.toolCall!.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
refreshToolLine(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
function appendToolArgs(
|
function appendToolArgs(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
cursor: string,
|
cursor: string,
|
||||||
id: string,
|
id: string,
|
||||||
delta: string,
|
delta: string,
|
||||||
): void {
|
): void {
|
||||||
const existing = [...projection.lines]
|
const existing = upsertToolCall(projection, cursor, id, {
|
||||||
.reverse()
|
state: "streaming_args",
|
||||||
.find((item) =>
|
});
|
||||||
item.kind === "tool" && item.streaming && item.body.includes(`id: ${id}`)
|
existing.toolCall!.argsStream += delta;
|
||||||
);
|
refreshToolLine(existing);
|
||||||
if (existing) {
|
}
|
||||||
existing.body += delta;
|
|
||||||
existing.cursor = cursor;
|
function attachToolResult(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
cursor: string,
|
||||||
|
id: string,
|
||||||
|
result: Pick<ToolCallView, "summary" | "output" | "isError">,
|
||||||
|
): void {
|
||||||
|
const existing = findToolCallLine(projection, id);
|
||||||
|
if (!existing) {
|
||||||
|
const fallback = toolLine(cursor, {
|
||||||
|
id,
|
||||||
|
name: "Tool",
|
||||||
|
argsStream: "",
|
||||||
|
state: result.isError ? "error" : "done",
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
fallback.title = result.isError
|
||||||
|
? "Call · Tool result error"
|
||||||
|
: "Call · Tool result";
|
||||||
|
refreshToolLine(fallback);
|
||||||
|
projection.lines.push(fallback);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
projection.lines.push(
|
existing.cursor = cursor;
|
||||||
line(
|
existing.toolCall = {
|
||||||
cursor,
|
...existing.toolCall!,
|
||||||
"tool",
|
...result,
|
||||||
"tool call args",
|
state: result.isError ? "error" : "done",
|
||||||
`id: ${id}\n${delta}`,
|
};
|
||||||
undefined,
|
refreshToolLine(existing);
|
||||||
true,
|
}
|
||||||
),
|
|
||||||
|
function findToolCallLine(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
id: string,
|
||||||
|
): ConsoleLine | undefined {
|
||||||
|
return [...projection.lines].reverse().find((item) =>
|
||||||
|
item.toolCall?.id === id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolLine(cursor: string, toolCall: ToolCallView): ConsoleLine {
|
||||||
|
const item: ConsoleLine = {
|
||||||
|
id: `tool-call-${toolCall.id}`,
|
||||||
|
kind: "tool",
|
||||||
|
title: `Call · ${toolCall.name}`,
|
||||||
|
body: "",
|
||||||
|
detail: undefined,
|
||||||
|
cursor,
|
||||||
|
source: "event",
|
||||||
|
streaming: true,
|
||||||
|
error: false,
|
||||||
|
toolCall,
|
||||||
|
};
|
||||||
|
refreshToolLine(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshToolLine(item: ConsoleLine): void {
|
||||||
|
const toolCall = item.toolCall;
|
||||||
|
if (!toolCall) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
item.title = item.title.startsWith("Call · Tool result")
|
||||||
|
? item.title
|
||||||
|
: `Call · ${toolCall.name}`;
|
||||||
|
item.body = renderToolCall(toolCall);
|
||||||
|
item.detail = toolCallDetail(toolCall);
|
||||||
|
item.streaming = !["done", "error"].includes(toolCall.state);
|
||||||
|
item.error = toolCall.state === "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolCall(toolCall: ToolCallView): string {
|
||||||
|
switch (toolCall.name) {
|
||||||
|
case "Read":
|
||||||
|
return renderReadTool(toolCall);
|
||||||
|
case "Write":
|
||||||
|
return renderWriteTool(toolCall);
|
||||||
|
case "Edit":
|
||||||
|
return renderEditTool(toolCall);
|
||||||
|
case "Glob":
|
||||||
|
case "Grep":
|
||||||
|
return renderSearchTool(toolCall);
|
||||||
|
case "Bash":
|
||||||
|
return renderBashTool(toolCall);
|
||||||
|
default:
|
||||||
|
return renderDefaultTool(toolCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReadTool(toolCall: ToolCallView): string {
|
||||||
|
const args = parsedArgs(toolCall);
|
||||||
|
const path = stringField(args, "file_path") ?? "?";
|
||||||
|
return compactLines([
|
||||||
|
`Read — ${path} (${stateSuffix(toolCall.state)})`,
|
||||||
|
resultText(toolCall),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWriteTool(toolCall: ToolCallView): string {
|
||||||
|
const args = parsedArgs(toolCall);
|
||||||
|
const path = stringField(args, "file_path") ?? "?";
|
||||||
|
const content = stringField(args, "content");
|
||||||
|
return compactLines([
|
||||||
|
`Write — ${path} (${stateSuffix(toolCall.state)})`,
|
||||||
|
cappedSection(content, 5),
|
||||||
|
resultText(toolCall),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditTool(toolCall: ToolCallView): string {
|
||||||
|
const args = parsedArgs(toolCall);
|
||||||
|
const path = stringField(args, "file_path") ?? "?";
|
||||||
|
const oldString = stringField(args, "old_string");
|
||||||
|
const newString = stringField(args, "new_string");
|
||||||
|
const change = oldString || newString
|
||||||
|
? compactLines([
|
||||||
|
oldString ? `- ${firstLine(oldString)}` : undefined,
|
||||||
|
newString ? `+ ${firstLine(newString)}` : undefined,
|
||||||
|
])
|
||||||
|
: undefined;
|
||||||
|
return compactLines([
|
||||||
|
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
|
||||||
|
change,
|
||||||
|
resultText(toolCall),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchTool(toolCall: ToolCallView): string {
|
||||||
|
const summary = toolCall.summary?.trim();
|
||||||
|
return compactLines([
|
||||||
|
`${toolCall.name} — ${
|
||||||
|
summary ? firstLine(summary) : stateSuffix(toolCall.state)
|
||||||
|
}`,
|
||||||
|
resultText(toolCall),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBashTool(toolCall: ToolCallView): string {
|
||||||
|
const args = parsedArgs(toolCall);
|
||||||
|
const command = stringField(args, "command");
|
||||||
|
return compactLines([
|
||||||
|
`Bash — ${stateSuffix(toolCall.state)}`,
|
||||||
|
command ? `$ ${command}` : argsText(toolCall),
|
||||||
|
resultText(toolCall),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefaultTool(toolCall: ToolCallView): string {
|
||||||
|
return compactLines([
|
||||||
|
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
||||||
|
argsText(toolCall),
|
||||||
|
resultText(toolCall),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallDetail(toolCall: ToolCallView): string {
|
||||||
|
return compactLines([
|
||||||
|
`id: ${toolCall.id}`,
|
||||||
|
`state: ${stateSuffix(toolCall.state)}`,
|
||||||
|
toolCall.summary ? `summary: ${toolCall.summary}` : undefined,
|
||||||
|
argsText(toolCall) ? `arguments:\n${argsText(toolCall)}` : undefined,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultText(toolCall: ToolCallView): string | undefined {
|
||||||
|
if (toolCall.output) {
|
||||||
|
return toolCall.output;
|
||||||
|
}
|
||||||
|
return toolCall.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function argsText(toolCall: ToolCallView): string {
|
||||||
|
const raw = toolCall.arguments ?? toolCall.argsStream;
|
||||||
|
if (!raw.trim()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parsed = parseJson(raw);
|
||||||
|
return parsed === undefined ? raw : jsonPreview(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedArgs(
|
||||||
|
toolCall: ToolCallView,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const parsed = parseJson(toolCall.arguments ?? toolCall.argsStream);
|
||||||
|
return isRecord(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(
|
||||||
|
value: Record<string, unknown> | undefined,
|
||||||
|
key: string,
|
||||||
|
): string | undefined {
|
||||||
|
const field = value?.[key];
|
||||||
|
return typeof field === "string" ? field : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactLines(lines: Array<string | undefined | null | false>): 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 usageText(
|
function usageText(
|
||||||
data: {
|
data: {
|
||||||
input_tokens: number | null;
|
input_tokens: number | null;
|
||||||
|
|
@ -387,17 +631,49 @@ function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine {
|
||||||
!block.finished,
|
!block.finished,
|
||||||
);
|
);
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return line(
|
return toolLine(cursor, {
|
||||||
cursor,
|
id: block.id,
|
||||||
"in_flight",
|
name: block.name,
|
||||||
`in-flight tool · ${block.name}`,
|
argsStream: block.args,
|
||||||
block.args,
|
arguments: block.state === "done" ? block.args : undefined,
|
||||||
`${block.id} · ${block.state ?? "pending"}`,
|
state: inFlightToolState(block.state),
|
||||||
block.state !== "done",
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inFlightToolState(
|
||||||
|
state: InFlightToolCallState | undefined,
|
||||||
|
): ToolCallState {
|
||||||
|
switch (state) {
|
||||||
|
case "streaming_args":
|
||||||
|
return "streaming_args";
|
||||||
|
case "done":
|
||||||
|
return "running";
|
||||||
|
case "pending":
|
||||||
|
default:
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateSuffix(state: ToolCallState): string {
|
||||||
|
switch (state) {
|
||||||
|
case "pending":
|
||||||
|
return "pending";
|
||||||
|
case "streaming_args":
|
||||||
|
return "streaming args";
|
||||||
|
case "running":
|
||||||
|
return "running";
|
||||||
|
case "done":
|
||||||
|
return "done";
|
||||||
|
case "error":
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(value: string): string {
|
||||||
|
return value.split(/\r?\n/, 1)[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function slugify(value: string): string {
|
function slugify(value: string): string {
|
||||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(
|
||||||
/^-|-$/g,
|
/^-|-$/g,
|
||||||
|
|
@ -405,6 +681,14 @@ function slugify(value: string): string {
|
||||||
) || "event";
|
) || "event";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJson(value: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function jsonPreview(value: unknown): string {
|
function jsonPreview(value: unknown): string {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2) ?? "null";
|
return JSON.stringify(value, null, 2) ?? "null";
|
||||||
|
|
@ -412,3 +696,7 @@ function jsonPreview(value: unknown): string {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user