summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/core/chunks/groups.test.ts234
-rw-r--r--src/core/chunks/groups.ts124
-rw-r--r--src/core/chunks/index.ts2
-rw-r--r--src/core/metrics/place.test.ts98
-rw-r--r--src/core/metrics/place.ts6
-rw-r--r--src/core/metrics/placement-scenarios.test.ts16
-rw-r--r--src/features/chat/index.ts2
-rw-r--r--src/features/chat/logic/tool-summary.test.ts281
-rw-r--r--src/features/chat/logic/tool-summary.ts263
-rw-r--r--src/features/chat/ui.test.ts6
-rw-r--r--src/features/chat/ui/ChatView.svelte81
-rw-r--r--src/features/chat/ui/ChatView.test.ts180
-rw-r--r--src/features/chat/ui/LazyCollapse.svelte2
-rw-r--r--src/features/chat/ui/ToolCard.svelte111
14 files changed, 1030 insertions, 376 deletions
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts
index db632a3..cc2aa40 100644
--- a/src/core/chunks/groups.test.ts
+++ b/src/core/chunks/groups.test.ts
@@ -3,16 +3,16 @@ import { describe, expect, it } from "vitest";
import { groupRenderedChunks } from "./groups";
import type { RenderedChunk } from "./types";
-const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({
+const text = (seq: number, role: Role, t: string): RenderedChunk => ({
seq,
role,
chunk: { type: "text", text: t },
- provisional,
+ provisional: false,
});
-
const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
seq,
role: "assistant",
+ provisional,
chunk: {
type: "tool-call",
toolCallId: id,
@@ -20,12 +20,11 @@ const call = (seq: number, id: string, stepId?: string, provisional = false): Re
input: { id },
...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
},
- provisional,
});
-
const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
seq,
role: "tool",
+ provisional,
chunk: {
type: "tool-result",
toolCallId: id,
@@ -34,165 +33,120 @@ const result = (seq: number, id: string, stepId?: string, provisional = false):
isError: false,
...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
},
- provisional,
});
+function toolGroups(rows: readonly RenderedChunk[]) {
+ return groupRenderedChunks(rows).map((g) => {
+ if (g.kind !== "tool") throw new Error("expected a tool group");
+ return g;
+ });
+}
+
describe("groupRenderedChunks", () => {
it("returns no groups for an empty stream", () => {
expect(groupRenderedChunks([])).toEqual([]);
});
-
- it("passes non-tool chunks through as single groups, in order", () => {
- const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]);
- expect(groups).toHaveLength(2);
- expect(groups.every((g) => g.kind === "single")).toBe(true);
- });
-
- it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => {
- const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]);
- expect(groups).toHaveLength(2);
- expect(groups.map((g) => g.kind)).toEqual(["single", "single"]);
+ it("passes non-tool chunks through unchanged and in order", () => {
+ const rows = [text(1, "user", "hi"), text(2, "assistant", "hello")];
+ expect(groupRenderedChunks(rows)).toEqual(rows.map((chunk) => ({ kind: "single", chunk })));
});
-
- it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => {
- const groups = groupRenderedChunks([
- call(1, "a"),
- call(2, "b"),
- result(3, "a"),
- result(4, "b"),
+ it("pairs a single call and result into one group", () => {
+ const c = call(1, "a", "s1");
+ const r = result(2, "a", "s1");
+ expect(toolGroups([c, r])).toEqual([
+ {
+ kind: "tool",
+ entry: { toolCallId: "a", toolName: "tool-a", call: c.chunk, result: r.chunk },
+ stepId: "s1",
+ provisional: false,
+ },
]);
- expect(groups).toHaveLength(4);
- expect(groups.every((g) => g.kind === "single")).toBe(true);
});
-
- it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => {
- const groups = groupRenderedChunks([
+ it("renders parallel calls independently in call order, with out-of-order results", () => {
+ const groups = toolGroups([
call(1, "a", "s1"),
call(2, "b", "s1"),
- result(3, "a", "s1"),
- result(4, "b", "s1"),
+ result(3, "b", "s1"),
+ result(4, "a", "s1"),
]);
- expect(groups).toHaveLength(1);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.stepId).toBe("s1");
- expect(g.entries).toHaveLength(2);
- expect(g.entries[0]?.call.toolCallId).toBe("a");
- expect(g.entries[0]?.result?.content).toBe("result-a");
- expect(g.entries[1]?.call.toolCallId).toBe("b");
- expect(g.entries[1]?.result?.content).toBe("result-b");
+ expect(groups.map((g) => g.entry.toolCallId)).toEqual(["a", "b"]);
+ expect(groups.map((g) => g.entry.result?.content)).toEqual(["result-a", "result-b"]);
});
-
- it("positions the batch at the first call and keeps surrounding chunks in order", () => {
+ it("pairs legacy chunks without stepId and mismatching stepIds by toolCallId", () => {
+ const groups = toolGroups([
+ call(1, "a"),
+ result(2, "a"),
+ call(3, "b", "s1"),
+ result(4, "b", "s2"),
+ ]);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.entry.result).not.toBeNull();
+ expect(groups[1]?.stepId).toBe("s1");
+ });
+ it("keeps surrounding text at its position and absorbs results", () => {
const groups = groupRenderedChunks([
text(1, "assistant", "before"),
call(2, "a", "s1"),
- call(3, "b", "s1"),
- result(4, "a", "s1"),
- result(5, "b", "s1"),
- text(6, "assistant", "after"),
+ text(3, "assistant", "between"),
+ call(4, "b", "s1"),
+ result(5, "a"),
+ result(6, "b"),
+ text(7, "assistant", "after"),
]);
- expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool", "single", "tool", "single"]);
});
-
- it("marks the batch provisional when any of its calls/results is provisional", () => {
- const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.provisional).toBe(true);
- expect(g.entries).toHaveLength(2);
- expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet)
+ it("keeps pending calls and orphan results", () => {
+ const groups = toolGroups([call(1, "pending"), result(2, "orphan", "s2", true)]);
+ expect(groups[0]?.entry.result).toBeNull();
+ expect(groups[1]?.entry.call).toBeNull();
+ expect(groups[1]?.entry.toolName).toBe("tool-orphan");
+ expect(groups[1]?.provisional).toBe(true);
+ expect(groups[1]?.stepId).toBe("s2");
});
-
- it("batches one step while leaving a different single-call step ungrouped", () => {
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- call(3, "c", "s2"),
- result(4, "a", "s1"),
- result(5, "b", "s1"),
- result(6, "c", "s2"),
- ]);
- expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]);
- const batch = groups[0];
- if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(batch.entries).toHaveLength(2);
- // the s2 single call + its result remain as separate single groups
- const singles = groups.slice(1);
- expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call");
- expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result");
+ it.each([
+ [true, false],
+ [false, true],
+ [false, false],
+ ])("combines call/result provisional flags (%s, %s)", (cp, rp) => {
+ expect(toolGroups([call(1, "a", "s1", cp), result(2, "a", "s1", rp)])[0]?.provisional).toBe(
+ cp || rp,
+ );
});
-
- // --- Duplicate toolCallId hardening (fix-toolcall-duplicate-keys) ---
- // A provider double-emission (e.g. openrouter/stealth/ox-alpha) can persist the
- // SAME toolCallId twice within one step. The renderer must NEVER crash on that
- // (Svelte `each_key_duplicate` aborts the reactive flush — the tab appears dead).
-
- it("dedupes duplicate toolCallIds within a batch", () => {
- // The PII_REDACTED_CONVERSATION_SHORT_01 pattern: every id emitted twice, in order (seq 1,2 then 3,4).
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- call(3, "a", "s1"),
- call(4, "b", "s1"),
+ it("prefers call provenance, falling back to result provenance", () => {
+ const groups = toolGroups([
+ { ...call(1, "a", "s1"), turnId: "t1" },
+ { ...result(2, "a", "s2"), turnId: "t2" },
+ call(3, "b"),
+ { ...result(4, "b", "s3"), turnId: "t3" },
]);
- expect(groups).toHaveLength(1);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.entries).toHaveLength(2);
- expect(g.entries.map((e) => e.call.toolCallId)).toEqual(["a", "b"]);
- });
-
- it("dedupes duplicate toolCallIds and pairs each unique call with its result", () => {
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- call(3, "a", "s1"),
- call(4, "b", "s1"),
- result(5, "a", "s1"),
- result(6, "b", "s1"),
- result(7, "a", "s1"),
- result(8, "b", "s1"),
+ expect(groups.map((g) => [g.stepId, g.turnId])).toEqual([
+ ["s1", "t1"],
+ ["s3", "t3"],
]);
- expect(groups).toHaveLength(1);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.entries).toHaveLength(2);
- expect(g.entries[0]?.call.toolCallId).toBe("a");
- expect(g.entries[0]?.result?.content).toBe("result-a");
- expect(g.entries[1]?.call.toolCallId).toBe("b");
- expect(g.entries[1]?.result?.content).toBe("result-b");
});
-
- it("keeps the first occurrence of a duplicate toolCallId and skips the second", () => {
- const firstA = call(1, "a", "s1"); // first occurrence of a
- const firstB = call(2, "b", "s1"); // first occurrence of b
- const groups = groupRenderedChunks([
- firstA,
- firstB,
- call(3, "a", "s1"), // duplicate of a — skipped
- call(4, "b", "s1"), // duplicate of b — skipped
+ it("dedupes calls/results across steps, first occurrence wins", () => {
+ const first = call(1, "a", "s1", true);
+ const firstResult = result(4, "a", "s1");
+ const groups = toolGroups([
+ first,
+ call(2, "a", "s2"),
+ call(3, "b"),
+ firstResult,
+ result(5, "a", "s2"),
+ result(6, "b"),
+ result(7, "b"),
]);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.entries).toHaveLength(2);
- // The FIRST occurrence (by stream order) is the one kept — referential
- // equality against the seq-1 / seq-2 chunks (the entry stores the inner
- // chunk; the outer `seq` lives on the RenderedChunk wrapper).
- expect(g.entries[0]?.call).toBe(firstA.chunk);
- expect(g.entries[1]?.call).toBe(firstB.chunk);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.entry.call).toBe(first.chunk);
+ expect(groups[0]?.entry.result).toBe(firstResult.chunk);
+ expect(groups[0]?.provisional).toBe(true);
});
-
- it("keeps the first (provisional) occurrence of a duplicate and marks the batch provisional", () => {
- // One provisional + one committed chunk with the SAME id: the first
- // (provisional) occurrence wins, and the batch stays provisional.
- const prov = call(1, "a", "s1", true);
- const groups = groupRenderedChunks([prov, call(2, "a", "s1")]);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.provisional).toBe(true);
- expect(g.entries).toHaveLength(1);
- expect(g.entries[0]?.call).toBe(prov.chunk);
- expect(g.entries[0]?.result).toBeNull();
+ it("dedupes orphan results too", () => {
+ expect(toolGroups([result(1, "a"), result(2, "a")])).toHaveLength(1);
+ });
+ it("positions a card at its call even when its result came first", () => {
+ const c = call(3, "a");
+ const groups = groupRenderedChunks([result(1, "a"), text(2, "assistant", "before call"), c]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool"]);
});
});
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts
index 9df0fa5..c8493cc 100644
--- a/src/core/chunks/groups.ts
+++ b/src/core/chunks/groups.ts
@@ -1,111 +1,65 @@
import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire";
import type { RenderedChunk } from "./types";
-/**
- * One tool call within a batch, paired with its result (matched by `toolCallId`).
- * `result` is null while the call is still pending (no result chunk yet).
- */
-export interface ToolBatchEntry {
- readonly call: ToolCallChunk;
+/** One tool call and its response. Null call = unloaded history; null result = pending. */
+export interface ToolEntry {
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly call: ToolCallChunk | null;
readonly result: ToolResultChunk | null;
}
-/**
- * A render group: either a single rendered chunk (rendered as today) or a batch
- * of tool calls the model emitted together in one step (shared `stepId`), to be
- * rendered as one grouped unit.
- */
export type RenderGroup =
| { readonly kind: "single"; readonly chunk: RenderedChunk }
| {
- readonly kind: "tool-batch";
- readonly stepId: string;
- readonly entries: readonly ToolBatchEntry[];
+ readonly kind: "tool";
+ readonly entry: ToolEntry;
readonly provisional: boolean;
- /** The producing turn (from the batch's first call chunk), when known. */
+ readonly stepId?: string;
readonly turnId?: string;
};
/**
- * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId`
- * (the backend's authoritative batch key) where the step has 2+ calls become one
- * `tool-batch` group, positioned at the first call and pairing each call with its
- * `tool-result` (by `toolCallId`); the absorbed result chunks are not emitted on
- * their own. Single tool calls (one per step, or no `stepId` — e.g. pre-0.2.0
- * replay rows) and every non-tool chunk render as `single` groups, in order.
- *
- * Pure: input → output, no DOM, no Svelte.
+ * One independent group per call, at the call's position, absorbing its result
+ * by toolCallId (including legacy chunks without stepId). Orphan results stay
+ * visible at their own position. First occurrence wins for duplicate ids, so
+ * the stable t<toolCallId> render keys are always unique, even on corrupt replay.
+ * Pure: input → output; no DOM, effects, or Svelte.
*/
export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] {
- // 1. Steps that batched 2+ tool calls.
- const callsPerStep = new Map<string, number>();
- for (const rc of rendered) {
- if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) {
- callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1);
- }
- }
- const batchSteps = new Set<string>();
- for (const [stepId, count] of callsPerStep) {
- if (count >= 2) batchSteps.add(stepId);
- }
-
- // 2. toolCallIds belonging to a batch (so their results are absorbed), and a
- // lookup of result chunks by toolCallId for pairing.
- const batchCallIds = new Set<string>();
- const resultByCallId = new Map<string, ToolResultChunk>();
+ const calls = new Map<string, RenderedChunk>();
+ const results = new Map<string, RenderedChunk>();
for (const rc of rendered) {
- const chunk = rc.chunk;
- if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
- batchCallIds.add(chunk.toolCallId);
- } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) {
- resultByCallId.set(chunk.toolCallId, chunk);
- }
+ const c = rc.chunk;
+ if (c.type === "tool-call" && !calls.has(c.toolCallId)) calls.set(c.toolCallId, rc);
+ if (c.type === "tool-result" && !results.has(c.toolCallId)) results.set(c.toolCallId, rc);
}
- // 3. Emit groups in stream order; each batch lands at its first call.
const groups: RenderGroup[] = [];
- const emittedSteps = new Set<string>();
+ const emitted = new Set<string>();
for (const rc of rendered) {
- const chunk = rc.chunk;
-
- if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
- const stepId = chunk.stepId;
- if (emittedSteps.has(stepId)) continue;
- emittedSteps.add(stepId);
-
- const entries: ToolBatchEntry[] = [];
- // Dedupe by toolCallId within the batch: a provider double-emission (or any
- // corrupted transcript) can carry the SAME call id on multiple tool-call
- // chunks in one step. The first occurrence (stream order) wins; later ones
- // are skipped so the keyed `{#each}` in the UI never sees duplicate keys
- // (Svelte `each_key_duplicate` aborts the reactive flush — tab appears dead).
- const seenCallIds = new Set<string>();
- let provisional = false;
- let turnId: string | undefined;
- for (const inner of rendered) {
- if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) {
- if (seenCallIds.has(inner.chunk.toolCallId)) continue;
- seenCallIds.add(inner.chunk.toolCallId);
- const result = resultByCallId.get(inner.chunk.toolCallId) ?? null;
- entries.push({ call: inner.chunk, result });
- if (inner.provisional) provisional = true;
- turnId ??= inner.turnId;
- }
- }
- groups.push(
- turnId !== undefined
- ? { kind: "tool-batch", stepId, entries, provisional, turnId }
- : { kind: "tool-batch", stepId, entries, provisional },
- );
+ const c = rc.chunk;
+ if (c.type !== "tool-call" && c.type !== "tool-result") {
+ groups.push({ kind: "single", chunk: rc });
continue;
}
-
- if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) {
- continue; // absorbed into its batch
- }
-
- groups.push({ kind: "single", chunk: rc });
+ if (emitted.has(c.toolCallId)) continue;
+ // A matching call owns the card's position, even if the result came first.
+ if (c.type === "tool-result" && calls.has(c.toolCallId)) continue;
+ emitted.add(c.toolCallId);
+ const callRow = calls.get(c.toolCallId);
+ const resultRow = results.get(c.toolCallId);
+ const call = callRow?.chunk.type === "tool-call" ? callRow.chunk : null;
+ const result = resultRow?.chunk.type === "tool-result" ? resultRow.chunk : null;
+ const stepId = call?.stepId ?? result?.stepId;
+ const turnId = callRow?.turnId ?? resultRow?.turnId;
+ groups.push({
+ kind: "tool",
+ entry: { toolCallId: c.toolCallId, toolName: call?.toolName ?? c.toolName, call, result },
+ provisional: Boolean(callRow?.provisional || resultRow?.provisional),
+ ...(stepId !== undefined ? { stepId } : {}),
+ ...(turnId !== undefined ? { turnId } : {}),
+ });
}
-
return groups;
}
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts
index bdd6ce4..3822a79 100644
--- a/src/core/chunks/index.ts
+++ b/src/core/chunks/index.ts
@@ -1,4 +1,4 @@
-export type { RenderGroup, ToolBatchEntry } from "./groups";
+export type { RenderGroup, ToolEntry } from "./groups";
export { groupRenderedChunks } from "./groups";
export { resolveImageUrl } from "./image-url";
export {
diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts
index 393d23c..7695e21 100644
--- a/src/core/metrics/place.test.ts
+++ b/src/core/metrics/place.test.ts
@@ -28,31 +28,36 @@ function assistantGroup(seq: number, text: string): RenderGroup {
};
}
-function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup {
+function toolCallGroup(stepId: string, toolCallId: string): RenderGroup {
return {
- kind: "single",
- chunk: {
- seq,
- role: "assistant",
- chunk: {
+ kind: "tool",
+ stepId,
+ provisional: false,
+ entry: {
+ toolCallId,
+ toolName: "test",
+ result: null,
+ call: {
type: "tool-call",
toolCallId,
toolName: "test",
input: {},
stepId: stepId as StepId,
},
- provisional: false,
},
};
}
-function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup {
+function toolResultGroup(stepId: string, toolCallId: string): RenderGroup {
return {
- kind: "single",
- chunk: {
- seq,
- role: "tool",
- chunk: {
+ kind: "tool",
+ stepId,
+ provisional: false,
+ entry: {
+ toolCallId,
+ toolName: "test",
+ call: null,
+ result: {
type: "tool-result",
toolCallId,
toolName: "test",
@@ -60,29 +65,10 @@ function toolResultGroup(seq: number, stepId: string, toolCallId: string): Rende
isError: false,
stepId: stepId as StepId,
},
- provisional: false,
},
};
}
-function toolBatchGroup(stepId: string, toolCallIds: string[]): RenderGroup {
- return {
- kind: "tool-batch",
- stepId,
- entries: toolCallIds.map((id) => ({
- call: {
- type: "tool-call" as const,
- toolCallId: id,
- toolName: "test",
- input: {},
- stepId: stepId as StepId,
- },
- result: null,
- })),
- provisional: false,
- };
-}
-
function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics {
return {
stepId: stepId as StepId,
@@ -169,9 +155,9 @@ describe("interleaveTurnMetrics", () => {
it("head-aligned: segment i gets entries[i]", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
- const g4 = toolCallGroup(4, "s2", "c2");
+ const g4 = toolCallGroup("s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -192,7 +178,7 @@ describe("interleaveTurnMetrics", () => {
it("a trailing segment with no entry (in-flight turn) renders no metrics", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const step = makeStep("s1", 100, 50);
@@ -220,26 +206,28 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 2, "t1");
});
- it("tool step anchors inline after its tool-batch group", () => {
+ it("tool step anchors inline after its LAST independent tool card", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("t#0", ["c1", "c2"]);
+ const g2 = toolCallGroup("t#0", "c1");
+ const parallel = toolCallGroup("t#0", "c2");
const g3 = assistantGroup(3, "a1");
const step0 = makeStep("t#0", 100, 50);
const step1 = makeStep("t#1", 200, 80);
const turn = makeEntry("t1", 300, 130, [step0, step1]);
- const rows = interleaveTurnMetrics([g1, g2, g3], [turn]);
+ const rows = interleaveTurnMetrics([g1, g2, parallel, g3], [turn]);
- expect(rows).toHaveLength(5);
+ expect(rows).toHaveLength(6);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
- expectStepMetricsAt(rows, 2, "t#0", 0);
- expectGroupAt(rows, 3, g3);
- expectTurnMetricsAt(rows, 4, "t1");
+ expectGroupAt(rows, 2, parallel);
+ expectStepMetricsAt(rows, 3, "t#0", 0);
+ expectGroupAt(rows, 4, g3);
+ expectTurnMetricsAt(rows, 5, "t1");
});
it("single tool-call group anchors its step", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(3, "a1");
const step = makeStep("s1", 100, 50);
const turn = makeEntry("t1", 100, 50, [step]);
@@ -255,7 +243,7 @@ describe("interleaveTurnMetrics", () => {
it("single tool-result group anchors its step", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolResultGroup(2, "s1", "c1");
+ const g2 = toolResultGroup("s1", "c1");
const g3 = assistantGroup(3, "a1");
const step = makeStep("s1", 100, 50);
const turn = makeEntry("t1", 100, 50, [step]);
@@ -271,9 +259,9 @@ describe("interleaveTurnMetrics", () => {
it("multi-step: each tool step inline, unanchored text step skipped", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("t#0", ["c1"]);
+ const g2 = toolCallGroup("t#0", "c1");
const g3 = assistantGroup(2, "thinking");
- const g4 = toolBatchGroup("t#1", ["c2", "c3"]);
+ const g4 = toolCallGroup("t#1", "c2");
const g5 = assistantGroup(3, "a1");
const step0 = makeStep("t#0", 100, 50);
const step1 = makeStep("t#1", 200, 80);
@@ -294,10 +282,10 @@ describe("interleaveTurnMetrics", () => {
it("multiple turns head-aligned with inline steps", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const g4 = userGroup(3, "q2");
- const g5 = toolCallGroup(4, "s2", "c2");
+ const g5 = toolCallGroup("s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -332,7 +320,7 @@ describe("interleaveTurnMetrics", () => {
it("fewer metrics than segments: trailing segments are bare", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const g5 = userGroup(5, "q3");
@@ -361,7 +349,7 @@ describe("interleaveTurnMetrics", () => {
// stepId groups) — the failure case. The text-only turn MUST get its OWN
// entry (t3), NOT the trimmed t1's stale metrics.
const g3 = userGroup(3, "q2");
- const g4 = toolBatchGroup("s2", ["c2"]);
+ const g4 = toolCallGroup("s2", "c2");
const g5 = assistantGroup(4, "tool-reply");
const g6 = userGroup(5, "q3");
const g7 = assistantGroup(6, "text-reply");
@@ -436,7 +424,7 @@ describe("interleaveTurnMetrics", () => {
it("in-flight turn (no durationMs) still produces turn row", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const step = makeStep("s1", 100, 50);
const turn: TurnMetricsEntry = {
turnId: "t1",
@@ -459,7 +447,7 @@ describe("interleaveTurnMetrics", () => {
it("leading non-turn groups emit as plain group rows", () => {
const g0 = assistantGroup(1, "system msg");
const g1 = userGroup(2, "q1");
- const g2 = toolCallGroup(3, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const step = makeStep("s1", 100, 50);
const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]);
@@ -473,7 +461,7 @@ describe("interleaveTurnMetrics", () => {
it("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -509,7 +497,7 @@ describe("interleaveTurnMetrics", () => {
it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const step1 = makeStep("s1", 100, 50);
const entry = makeProgressiveEntry("t1", [step1]);
@@ -524,7 +512,7 @@ describe("interleaveTurnMetrics", () => {
it("entry with total emits step rows + a turn-metrics row", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const step1 = makeStep("s1", 100, 50);
const entry = makeEntry("t1", 100, 50, [step1]);
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
index 9f8b109..d4c343a 100644
--- a/src/core/metrics/place.ts
+++ b/src/core/metrics/place.ts
@@ -3,13 +3,11 @@ import type { RenderGroup } from "../chunks";
import type { MetricsRow, StepMetrics, TurnMetricsEntry } from "./types";
function groupStepId(g: RenderGroup): string | undefined {
- if (g.kind === "tool-batch") return g.stepId;
- const c = g.chunk.chunk;
- return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined;
+ return g.kind === "tool" ? g.stepId : undefined;
}
function groupTurnId(g: RenderGroup): string | undefined {
- return g.kind === "tool-batch" ? g.turnId : g.chunk.turnId;
+ return g.kind === "tool" ? g.turnId : g.chunk.turnId;
}
function isUser(g: RenderGroup): boolean {
diff --git a/src/core/metrics/placement-scenarios.test.ts b/src/core/metrics/placement-scenarios.test.ts
index 16499f7..ce3ed5b 100644
--- a/src/core/metrics/placement-scenarios.test.ts
+++ b/src/core/metrics/placement-scenarios.test.ts
@@ -36,9 +36,9 @@ function layout(rows: readonly MetricsRow[]): string[] {
const c = r.group.chunk.chunk;
if (c.type === "text") out.push(`${r.group.chunk.role}:${c.text}`);
else if (c.type === "image") out.push(`${r.group.chunk.role}:<img>`);
- else if (c.type === "tool-call") out.push(`call:${c.toolCallId}`);
- else if (c.type === "tool-result") out.push(`result:${c.toolCallId}`);
else if (c.type === "error") out.push(`error:${c.message}`);
+ } else {
+ out.push(`tool:${r.group.entry.toolCallId}`);
}
}
return out;
@@ -245,8 +245,7 @@ for (const legacy of [false, true]) {
expect(sim.layout()).toEqual([
...before,
"user:q5",
- "call:k1",
- "result:k1",
+ "tool:k1",
"[step t5#0]",
"assistant:a5",
"[turn t5]",
@@ -291,8 +290,7 @@ for (const legacy of [false, true]) {
"assistant:a1",
"[turn t1]",
"user:q2",
- "call:k1",
- "result:k1",
+ "tool:k1",
"[step t2#0]",
"user:steer!",
]);
@@ -304,12 +302,10 @@ for (const legacy of [false, true]) {
"assistant:a1",
"[turn t1]",
"user:q2",
- "call:k1",
- "result:k1",
+ "tool:k1",
"[step t2#0]",
"user:steer!",
- "call:k2",
- "result:k2",
+ "tool:k2",
"[step t2#1]",
"assistant:a2",
"[turn t2]",
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 6de8164..59da39b 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -2,7 +2,7 @@ export type {
ProviderRetryView,
RenderedChunk,
RenderGroup,
- ToolBatchEntry,
+ ToolEntry,
} from "../../core/chunks";
export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
new file mode 100644
index 0000000..5caa1f1
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, it } from "vitest";
+import type { ToolEntry } from "../../../core/chunks";
+import { viewToolEntry } from "./tool-summary";
+
+function entry(
+ name: string,
+ input: unknown,
+ content: string | null = "ok",
+ isError = false,
+): ToolEntry {
+ return {
+ toolCallId: "a",
+ toolName: name,
+ call: { type: "tool-call", toolCallId: "a", toolName: name, input },
+ result:
+ content === null
+ ? null
+ : { type: "tool-result", toolCallId: "a", toolName: name, content, isError },
+ };
+}
+
+const view = (name: string, input: unknown, content: string | null = "ok", isError = false) =>
+ viewToolEntry(entry(name, input, content, isError));
+const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
+
+describe("tool summaries", () => {
+ it.each([
+ ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"],
+ ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"],
+ ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"],
+ ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"],
+ ["read_file", { path: "src/a.ts" }, "src/a.ts"],
+ ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"],
+ ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"],
+ ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"],
+ ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"],
+ [
+ "lsp",
+ { operation: "references", path: "src/a.ts", line: 12, character: 3 },
+ "references src/a.ts:12:3",
+ ],
+ ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"],
+ [
+ "todo_write",
+ { todos: [{ status: "pending" }, { status: "completed" }, null] },
+ "3 tasks · 1 completed",
+ ],
+ ["load_skill", { name: "research_methodology" }, "research_methodology"],
+ [
+ "consult_vision",
+ { question: "What is in this picture?", imageIds: [1] },
+ "What is in this picture?",
+ ],
+ ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"],
+ ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"],
+ ["summon_agent", { task: "Review the patch" }, "Review the patch"],
+ ["summon_agent", { agent: "Opus" }, "Opus"],
+ ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"],
+ ["read_agent", { conversationId: "abcd" }, "abcd"],
+ ["open_agent", { conversationId: "abcd" }, "abcd"],
+ ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"],
+ ["list_agents", { relationship: "descendants" }, "descendants"],
+ ["list_agents", { query: "review" }, "review"],
+ ["list_agent_models", { query: "Opus" }, "Opus"],
+ ])("%s derives a meaningful call summary from %j", (name, input, expected) => {
+ expect(view(name, input).callSummary).toBe(expected);
+ });
+
+ it("formats edit call badges and old/new details without hiding multiline text", () => {
+ const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" };
+ const card = view("edit_file", input);
+ expect(card.callBadges).toEqual(["+3 −2"]);
+ expect(card.callDetail).toEqual({
+ kind: "edit",
+ path: "a.ts",
+ oldText: input.oldString,
+ newText: input.newString,
+ });
+ expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([
+ "+1 −0",
+ ]);
+ expect(view("edit_file", { path: "a" }).callBadges).toEqual([]);
+ });
+ it("preserves exact shell commands in the expanded detail", () => {
+ expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({
+ kind: "command",
+ text: "ls\n pwd",
+ });
+ });
+ it("shows scalar fields and pretty nested JSON", () => {
+ expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "path", value: "a.ts", multiline: false },
+ { key: "count", value: "2", multiline: false },
+ { key: "nested", value: '{\n "a": true\n}', multiline: true },
+ ],
+ });
+ });
+ it.each([
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ ])("generic fallback recognizes %s", (key) => {
+ expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target");
+ });
+ it("generic fallback prefers the first meaningful argument, else compact JSON", () => {
+ expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a");
+ expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe('{"a":1,"b":"x"}');
+ });
+ it.each([
+ "not JSON",
+ "{bad",
+ null,
+ undefined,
+ 42,
+ true,
+ [1, "a"],
+ {},
+ "null",
+ '"hi"',
+ ])("tolerates malformed/non-object input %j", (input) => {
+ expect(() => view("read_file", input)).not.toThrow();
+ expect(view("read_file", input).callSummary).not.toBe("");
+ expect(view("read_file", input).callDetail.kind).toBe("fields");
+ });
+ it.each([
+ "run_shell",
+ "read_file",
+ "edit_file",
+ "write_file",
+ "web_search",
+ "lsp",
+ "todo_write",
+ "load_skill",
+ "consult_vision",
+ "youtube_transcript",
+ "summon_agent",
+ "read_agent",
+ ])("%s falls back safely for missing arguments", (name) => {
+ for (const input of [null, undefined, {}, "{bad"]) {
+ expect(() => view(name, input)).not.toThrow();
+ expect(view(name, input).callSummary.trim()).not.toBe("");
+ }
+ });
+ it("parses JSON-string inputs", () => {
+ const card = view("run_shell", '{"command":"bun run test"}');
+ expect(card.callSummary).toBe("bun run test");
+ expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" });
+ });
+ it("handles unexpected built-in argument types and prototype-looking tool names", () => {
+ expect(view("todo_write", { todos: "wrong" }).callSummary).toBe('{"todos":"wrong"}');
+ expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields");
+ expect(view("constructor", { path: "a" }).callSummary).toBe("a");
+ expect(view("__proto__", {}).callSummary).toBe("{}");
+ expect(
+ view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary,
+ ).toBe("good");
+ });
+ it("does not throw on unserializable input", () => {
+ const cyclic: Record<string, unknown> = {};
+ cyclic.self = cyclic;
+ expect(view("mcp", cyclic).callSummary).toBe("[unserializable input]");
+ expect(() => view("mcp", { integer: 1n })).not.toThrow();
+ });
+ it("caps and whitespace-collapses header strings but retains full detail", () => {
+ const command = ` echo\n${"x".repeat(300)}`;
+ const card = view("run_shell", { command });
+ expect(card.callSummary.length).toBe(120);
+ expect(card.callSummary.endsWith("…")).toBe(true);
+ expect(card.callSummary).not.toMatch(/\n/);
+ expect(card.callDetail).toEqual({ kind: "command", text: command });
+ });
+ it("represents pending entries with no response summary/output", () => {
+ expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({
+ status: "pending",
+ resultSummary: null,
+ output: null,
+ });
+ });
+ it("represents orphan results with unknown call details, not pending", () => {
+ const e = { ...entry("read_file", {}, "data"), call: null };
+ expect(viewToolEntry(e)).toMatchObject({
+ name: "read_file",
+ status: "ok",
+ callSummary: "call unavailable",
+ resultSummary: "1 line",
+ });
+ expect(viewToolEntry(e).callDetail).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "call", value: "Call unavailable (earlier history unloaded)", multiline: false },
+ ],
+ });
+ });
+});
+
+describe("tool response facts", () => {
+ it.each([
+ ["run_shell", "one\ntwo\n", false, "2 lines"],
+ ["run_shell", "done", false, "1 line"],
+ ["read_file", "1\tfile text\n", false, "1 line"],
+ ["read_file", "(empty file: a.ts)", false, "empty file"],
+ ["mcp", "", false, "no output"],
+ ["mcp", " \n\t", false, "no output"],
+ ["mcp", "a short response", false, "a short response"],
+ ["mcp", "x".repeat(200), false, "1 line"],
+ ["web_search", "a\nb\nc", false, "3 lines"],
+ ["run_shell", "", true, "failed"],
+ ["mcp", "Permission denied", true, "failed · Permission denied"],
+ ["run_shell", "error\ndetail", true, "failed · 2 lines"],
+ ["run_shell", "partial\n\n[Command timed out]", true, "timed out"],
+ ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"],
+ ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · 2 lines · truncated"],
+ ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"],
+ [
+ "edit_file",
+ 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2',
+ false,
+ "3 replacements · diagnostics",
+ ],
+ ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"],
+ ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"],
+ ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"],
+ ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => {
+ const card = view(name, {}, content, isError);
+ expect(card.resultSummary).toBe(expected);
+ expect(card.status).toBe(isError ? "error" : "ok");
+ expect(card.output?.isError).toBe(isError);
+ });
+ it("does not infer success on an error that resembles a write result", () => {
+ expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch(
+ /^failed/,
+ );
+ });
+});
+
+describe("tool output elision", () => {
+ it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({
+ kind: "full",
+ lines: lines(n),
+ isError: false,
+ });
+ });
+ it.each([
+ 13, 14, 100,
+ ])("shows first/last six of %i lines, retaining everything for expansion", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({
+ kind: "elided",
+ head: lines(n).slice(0, 6),
+ hiddenCount: n - 12,
+ tail: lines(n).slice(-6),
+ all: lines(n),
+ isError: true,
+ });
+ });
+ it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => {
+ expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full");
+ expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({
+ kind: "elided",
+ hiddenCount: 1,
+ });
+ });
+ it("preserves meaningful blank lines including an extra trailing blank line", () => {
+ expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({
+ kind: "full",
+ lines: ["a", "", "b", ""],
+ isError: false,
+ });
+ });
+});
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
new file mode 100644
index 0000000..3b75a6b
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.ts
@@ -0,0 +1,263 @@
+import type { ToolEntry } from "../../../core/chunks";
+
+export interface ToolCardView {
+ readonly name: string;
+ readonly status: "pending" | "ok" | "error";
+ readonly callSummary: string;
+ readonly callBadges: readonly string[];
+ readonly resultSummary: string | null;
+ readonly callDetail:
+ | { readonly kind: "command"; readonly text: string }
+ | {
+ readonly kind: "edit";
+ readonly path: string;
+ readonly oldText: string;
+ readonly newText: string;
+ }
+ | {
+ readonly kind: "fields";
+ readonly fields: readonly {
+ readonly key: string;
+ readonly value: string;
+ readonly multiline: boolean;
+ }[];
+ };
+ readonly output: ToolOutputView | null;
+}
+
+export type ToolOutputView =
+ | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean }
+ | {
+ readonly kind: "elided";
+ readonly head: readonly string[];
+ readonly hiddenCount: number;
+ readonly tail: readonly string[];
+ readonly all: readonly string[];
+ readonly isError: boolean;
+ };
+
+type Args = Readonly<Record<string, unknown>>;
+type CallSummary = (args: Args) => string;
+
+function oneLine(text: string, cap = 120): string {
+ const compact = text.replace(/\s+/g, " ").trim();
+ return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact;
+}
+
+function normalize(input: unknown): unknown {
+ if (typeof input !== "string") return input;
+ try {
+ return JSON.parse(input);
+ } catch {
+ return input;
+ }
+}
+
+function asArgs(input: unknown): Args {
+ return typeof input === "object" && input !== null && !Array.isArray(input)
+ ? (input as Args)
+ : {};
+}
+
+function serialize(input: unknown, pretty = false): string {
+ if (typeof input === "string") return input;
+ try {
+ return JSON.stringify(input, null, pretty ? 2 : undefined) ?? String(input);
+ } catch {
+ return "[unserializable input]";
+ }
+}
+
+function arg(args: Args, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = Object.hasOwn(args, key) ? args[key] : undefined;
+ if (typeof value === "string" && value.trim()) return value;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ }
+ return "";
+}
+
+function lineCount(text: string): number {
+ return text === "" ? 0 : outputLines(text).length;
+}
+
+/** Drop one final newline terminator, not meaningful blank lines within the output. */
+function outputLines(content: string): string[] {
+ const normalized = content.replace(/\r\n/g, "\n");
+ return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n");
+}
+
+function readSummary(args: Args): string {
+ const path = arg(args, "path");
+ const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1;
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null;
+ return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`;
+}
+
+function lspSummary(args: Args): string {
+ const line = arg(args, "line");
+ const character = arg(args, "character");
+ return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`;
+}
+
+function todoSummary(args: Args): string {
+ if (!Array.isArray(args.todos)) return "";
+ const done = args.todos.filter((t) => asArgs(t).status === "completed").length;
+ return `${args.todos.length} tasks · ${done} completed`;
+}
+
+const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model");
+const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message");
+
+/** Optional built-in polish; all unknown/MCP names use the same generic path. */
+const summaries: Readonly<Record<string, CallSummary>> = {
+ run_shell: (args) => arg(args, "command"),
+ read_file: readSummary,
+ edit_file: (args) => arg(args, "path"),
+ write_file: (args) => arg(args, "path"),
+ web_search: (args) => arg(args, "query", "url"),
+ lsp: lspSummary,
+ todo_write: todoSummary,
+ load_skill: (args) => arg(args, "name"),
+ consult_vision: (args) => arg(args, "question", "path"),
+ youtube_transcript: (args) => arg(args, "url"),
+ summon_agent: summarizeAgent,
+ send_agent_message: summarizeConversation,
+ read_agent: summarizeConversation,
+ open_agent: summarizeConversation,
+ cancel_agent_message: summarizeConversation,
+ list_agents: (args) => arg(args, "query", "relationship", "workspaceId"),
+ list_agent_models: (args) => arg(args, "query"),
+};
+
+function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] {
+ if (name === "run_shell" && typeof args.command === "string") {
+ return { kind: "command", text: args.command };
+ }
+ if (name === "edit_file" && typeof args.path === "string") {
+ return {
+ kind: "edit",
+ path: args.path,
+ oldText: typeof args.oldString === "string" ? args.oldString : "",
+ newText: typeof args.newString === "string" ? args.newString : "",
+ };
+ }
+ const entries = Object.entries(args);
+ return {
+ kind: "fields",
+ fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => {
+ const text = serialize(value, true);
+ return { key: String(key), value: text, multiline: text.includes("\n") };
+ }),
+ };
+}
+
+function bytesLabel(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function responseSummary(name: string, content: string, isError: boolean): string {
+ const lines = outputLines(content);
+ const first = oneLine(lines.find((line) => line.trim()) ?? "", 72);
+ const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`;
+ const timeout = /\[Command timed out\]/i.test(content);
+ const truncated = /\[Output truncated[^\]]*\]/i.test(content);
+ let summary: string;
+ if (timeout) summary = "timed out";
+ else if (isError)
+ summary = `failed${lines.length > 1 ? ` · ${count}` : first ? ` · ${first}` : ""}`;
+ else if (!content.trim()) summary = "no output";
+ else if (/^\(empty file\b/i.test(content)) summary = "empty file";
+ else {
+ const replaced =
+ name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null;
+ const written =
+ name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null;
+ if (replaced) {
+ summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`;
+ if (content.trim().includes("\n")) summary += " · diagnostics";
+ } else if (written) {
+ summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`;
+ } else if (
+ name === "run_shell" ||
+ name === "read_file" ||
+ lines.length > 1 ||
+ first.length > 60
+ ) {
+ summary = count;
+ } else summary = first;
+ }
+ return oneLine(`${summary}${truncated ? " · truncated" : ""}`, 96);
+}
+
+export function viewToolEntry(entry: ToolEntry): ToolCardView {
+ const input = normalize(entry.call?.input);
+ const args = asArgs(input);
+ const summarize = Object.hasOwn(summaries, entry.toolName)
+ ? summaries[entry.toolName]
+ : undefined;
+ const generic = arg(
+ args,
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ );
+ const builtInSummary = summarize?.(args).trim();
+ const callSummary =
+ entry.call === null
+ ? "call unavailable"
+ : oneLine(builtInSummary || generic || serialize(input));
+ const callBadges: string[] = [];
+ if (entry.toolName === "edit_file" && entry.call !== null) {
+ if (typeof args.oldString === "string" && typeof args.newString === "string") {
+ callBadges.push(`+${lineCount(args.newString)} −${lineCount(args.oldString)}`);
+ }
+ }
+ const result = entry.result;
+ const lines = result === null ? [] : outputLines(result.content);
+ return {
+ name: entry.toolName,
+ status: result === null ? "pending" : result.isError ? "error" : "ok",
+ callSummary,
+ callBadges,
+ callDetail:
+ entry.call === null
+ ? {
+ kind: "fields",
+ fields: [
+ {
+ key: "call",
+ value: "Call unavailable (earlier history unloaded)",
+ multiline: false,
+ },
+ ],
+ }
+ : detail(entry.toolName, input, args),
+ resultSummary:
+ result === null ? null : responseSummary(entry.toolName, result.content, result.isError),
+ output:
+ result === null
+ ? null
+ : lines.length <= 12
+ ? { kind: "full", lines, isError: result.isError }
+ : {
+ kind: "elided",
+ head: lines.slice(0, 6),
+ hiddenCount: lines.length - 12,
+ tail: lines.slice(-6),
+ all: lines,
+ isError: result.isError,
+ },
+ };
+}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 3c7fd0d..c3a76d4 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -210,7 +210,7 @@ describe("ChatView", () => {
expect(log.children).toHaveLength(0);
});
- it("groups batched tool calls (shared stepId) into one DaisyUI list", async () => {
+ it("renders parallel tool calls (shared stepId) as separate bubbles", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -254,10 +254,12 @@ describe("ChatView", () => {
const { container } = render(ChatView, { props: { chunks } });
await openAllCollapses(container);
- // Batched calls render as collapsible cards (one per call), not a list.
+ // Parallel calls render as independent collapsible cards (one per bubble).
const collapses = container.querySelectorAll(".collapse");
expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
+
// Both call names + the available result are shown; the result is absorbed
// (no standalone tool-result card).
expect(screen.getByText("read_file")).toBeInTheDocument();
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 5e4513c..18fea7c 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -11,6 +11,7 @@
} from "../../../core/metrics";
import { Markdown } from "../../markdown";
import LazyCollapse from "./LazyCollapse.svelte";
+ import ToolCard from "./ToolCard.svelte";
const badgeClass = {
success: "badge-success",
@@ -73,6 +74,7 @@
const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ // Tool cards use toolCallId keys across pending/result/seal and orphan page-in.
// Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
// survives the provisional→committed (seq null → seq N) transition, so the
// collapse's open/close state is NOT lost when a turn seals. The ordinal
@@ -89,8 +91,8 @@
}
const group = row.group;
let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
+ if (group.kind === "tool") {
+ key = `t${group.entry.toolCallId}`;
} else if (group.chunk.chunk.type === "thinking") {
key = `think${thinking++}`;
} else if (group.chunk.seq != null) {
@@ -103,22 +105,6 @@
});
</script>
-{#snippet wrench()}
- <svg
- class="h-4 w-4 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- ><path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- /></svg
- >
-{/snippet}
-
{#snippet chunkRow(rendered: RenderedChunk)}
{#if rendered.role === "user"}
<!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
@@ -158,36 +144,6 @@
</LazyCollapse>
</div>
</div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a collapsible card (collapsed by default,
- like thinking). Title shows the tool name; content shows the
- input/output. Same chat-start grid shim as the thinking block. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "tool-call"}
- {@const call = rendered.chunk}
- <LazyCollapse label="Toggle tool call">
- {#snippet title()}
- {@render wrench()}
- <span>{call.toolName}</span>
- {/snippet}
- <pre class="overflow-x-auto text-xs">{JSON.stringify(call.input, null, 2)}</pre>
- </LazyCollapse>
- {:else}
- {@const res = rendered.chunk}
- <LazyCollapse label="Toggle tool result" titleClass={res.isError ? "text-error" : ""}>
- {#snippet title()}
- {@render wrench()}
- <span>{res.toolName}</span>
- {#if res.isError}
- <span class="badge badge-error badge-xs">error</span>
- {/if}
- {/snippet}
- <pre class="max-h-96 overflow-auto text-xs">{res.content}</pre>
- </LazyCollapse>
- {/if}
- </div>
- </div>
{:else}
<!-- Assistant text / system / error: an INVISIBLE speech bubble — same
chat-start grid as the user bubble, so it inherits identical left spacing. -->
@@ -287,31 +243,10 @@
{:else if row.group.kind === "single"}
{@render chunkRow(row.group.chunk)}
{:else}
- <!-- Batched tool calls (one step): each entry is a collapsible card.
- Click to expand and see the input/output. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="flex flex-col gap-1">
- {#each row.group.entries as entry, entryIndex (entryIndex)}
- <LazyCollapse label="Toggle tool call">
- {#snippet title()}
- {@render wrench()}
- <span>{entry.call.toolName}</span>
- {#if entry.result?.isError}
- <span class="badge badge-error badge-xs">error</span>
- {:else if entry.result === null}
- <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
- {/if}
- {/snippet}
- <pre class="overflow-x-auto text-xs">{JSON.stringify(entry.call.input, null, 2)}</pre>
- {#if entry.result}
- <pre
- class="mt-1 max-h-96 overflow-auto text-xs"
- class:text-error={entry.result.isError}>{entry.result.content}</pre>
- {/if}
- </LazyCollapse>
- {/each}
- </div>
+ <!-- Each call owns one bubble; the matching result is folded into its card. -->
+ <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble min-w-0 w-full bg-transparent">
+ <ToolCard entry={row.group.entry} />
</div>
</div>
{/if}
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
index fcac312..d12088b 100644
--- a/src/features/chat/ui/ChatView.test.ts
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -19,7 +19,7 @@ async function openAllCollapses(container: HTMLElement): Promise<void> {
// Svelte `each_key_duplicate` (which aborts the reactive flush and makes the
// tab appear dead). See fix-toolcall-duplicate-keys plan Part 1.
describe("ChatView — duplicate toolCallIds", () => {
- it("renders a tool batch with duplicated toolCallIds without throwing, showing one card per unique call", async () => {
+ it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -96,7 +96,7 @@ describe("ChatView — duplicate toolCallIds", () => {
},
provisional: false,
},
- // Duplicate results too — absorbed into the single deduped batch.
+ // Duplicate results too — absorbed into the corresponding cards.
{
seq: 7,
role: "tool",
@@ -128,10 +128,11 @@ describe("ChatView — duplicate toolCallIds", () => {
const { container } = render(ChatView, { props: { chunks } });
await openAllCollapses(container);
- // No each_key_duplicate crash. Batched calls render as collapsible cards —
+ // No each_key_duplicate crash. Calls render as independent collapsible cards —
// exactly ONE per unique toolCallId (deduped), not 4.
const collapses = container.querySelectorAll(".collapse");
expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
// Both unique tool names shown; each result rendered once (deduped).
expect(screen.getByText("read_file")).toBeInTheDocument();
@@ -161,7 +162,7 @@ describe("ChatView — lazy collapse bodies", () => {
expect(screen.getByText("run_shell")).toBeInTheDocument();
expect(screen.queryByText("HUGE-OUTPUT")).toBeNull();
- const box = screen.getByRole("checkbox", { name: "Toggle tool result" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
await userEvent.click(box);
expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
@@ -170,3 +171,174 @@ describe("ChatView — lazy collapse bodies", () => {
expect(container.querySelectorAll(".chat-row")).toHaveLength(1);
});
});
+
+function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] {
+ const call: RenderedChunk = {
+ seq: committed ? 1 : null,
+ role: "assistant",
+ provisional: !committed,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ input: { command: "bun run test" },
+ },
+ };
+ return content === null
+ ? [call]
+ : [
+ call,
+ {
+ seq: committed ? 2 : null,
+ role: "tool",
+ provisional: !committed,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ content,
+ isError,
+ },
+ },
+ ];
+}
+
+describe("ChatView — tool cards", () => {
+ it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => {
+ const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(container.querySelectorAll(".collapse")).toHaveLength(1);
+ expect(screen.getByText("bun run test")).toBeInTheDocument();
+ expect(screen.getByText("→ 2 lines")).toBeInTheDocument();
+ expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ expect(box).not.toBeChecked();
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ expect(screen.getByText("first second")).toBeInTheDocument();
+ expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull();
+ });
+ it("transitions pending → result → sealed without losing open state", async () => {
+ const { container, rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
+ expect(screen.getByLabelText("Running")).toHaveClass("loading-spinner");
+ expect(container.querySelector(".collapse-title")).not.toHaveTextContent("→");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ await userEvent.click(box);
+ expect(screen.getByText("running…")).toBeInTheDocument();
+ await rerender({ chunks: shellChunks("done") });
+ expect(screen.queryByLabelText("Running")).toBeNull();
+ expect(screen.getByLabelText("Succeeded")).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(box).toBeChecked();
+ await rerender({ chunks: shellChunks("done", false, true) });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("done")).toBeInTheDocument();
+ });
+ it("keeps errors collapsed with a failure icon and tints the expanded output", async () => {
+ const { container } = render(ChatView, {
+ props: { chunks: shellChunks("Permission denied", true) },
+ });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ expect(box).not.toBeChecked();
+ expect(screen.getByLabelText("Failed")).toHaveClass("text-error");
+ expect(screen.getByLabelText("Failed")).toHaveTextContent("✗");
+ expect(screen.getByText("→ failed · Permission denied")).toHaveClass("text-error");
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error");
+ });
+ it("renders an orphan result as the same card and retains state when its call is paged back in", async () => {
+ const chunks = shellChunks("contents", false, true);
+ const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(screen.getByText("call unavailable")).toBeInTheDocument();
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ await userEvent.click(box);
+ expect(screen.getByText(/Call unavailable \(earlier history unloaded\)/)).toBeInTheDocument();
+ expect(screen.getByText("contents")).toBeInTheDocument();
+ await rerender({ chunks });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ });
+ it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => {
+ const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n");
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ await userEvent.click(box);
+ const response = screen.getByRole("region", { name: "Response" });
+ expect(response).toHaveTextContent("output-1");
+ expect(response).toHaveTextContent("output-6");
+ expect(response).not.toHaveTextContent("output-7");
+ expect(response).toHaveTextContent("output-8");
+ expect(response).toHaveTextContent("output-13");
+ const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await userEvent.click(toggle);
+ expect(response).toHaveTextContent("output-7");
+ expect(response.querySelectorAll("pre")).toHaveLength(1);
+ expect(response.querySelector("pre")?.textContent).toBe(output);
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" }));
+ expect(response).not.toHaveTextContent("output-7");
+ expect(box).toBeChecked();
+ // The separate output-fold state also survives sealing.
+ await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" }));
+ expect(response).toHaveTextContent("output-7");
+ await rerender({ chunks: shellChunks(output, false, true) });
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ expect(response).toHaveTextContent("output-7");
+ });
+ it("shows 12 output lines without an inner toggle", async () => {
+ const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n");
+ render(ChatView, { props: { chunks: shellChunks(output) } });
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" }));
+ expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull();
+ expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12");
+ });
+ it("renders edit details and treats tool output as text, not executable markup", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ input: { path: "a.ts", oldString: "before", newString: "after\nsecond" },
+ },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>',
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("+2 −1")).toBeInTheDocument();
+ expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call edit_file" }));
+ expect(screen.getByText("before")).toHaveClass("text-error");
+ expect(screen.getByText("after second")).toHaveClass("text-success");
+ expect(container.querySelector("script")).toBeNull();
+ expect(container).toHaveTextContent("<script>alert(1)</script>");
+ });
+});
diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte
index 0f66451..912adb4 100644
--- a/src/features/chat/ui/LazyCollapse.svelte
+++ b/src/features/chat/ui/LazyCollapse.svelte
@@ -33,7 +33,7 @@
<div class="collapse w-full rounded-box bg-base-200 text-sm">
<input type="checkbox" aria-label={label} bind:checked={open} />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium {titleClass}">
+ <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}">
{@render title()}
</div>
<div class="collapse-content">
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
new file mode 100644
index 0000000..8c6e325
--- /dev/null
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -0,0 +1,111 @@
+<script lang="ts">
+ import type { ToolEntry } from "../index";
+ import { viewToolEntry } from "../logic/tool-summary";
+ import LazyCollapse from "./LazyCollapse.svelte";
+
+ let { entry }: { entry: ToolEntry } = $props();
+ const card = $derived(viewToolEntry(entry));
+ let showAll = $state(false);
+</script>
+
+<LazyCollapse label={`Toggle tool call ${card.name}`}>
+ {#snippet title()}
+ {#if card.status === "pending"}
+ <span class="loading loading-spinner loading-xs shrink-0" aria-label="Running"></span>
+ {:else if card.status === "error"}
+ <span class="shrink-0 text-error" aria-label="Failed">✗</span>
+ {:else}
+ <span class="shrink-0 text-success" aria-label="Succeeded">✓</span>
+ {/if}
+ <svg
+ class="h-4 w-4 shrink-0 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ aria-hidden="true"
+ >
+ <path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ />
+ </svg>
+ <span class="max-w-[35%] shrink-0 truncate" title={card.name}>{card.name}</span>
+ <span class="min-w-0 flex-1 truncate opacity-70" title={card.callSummary}
+ >{card.callSummary}</span
+ >
+ {#each card.callBadges as badge}
+ <span class="shrink-0 text-xs opacity-70">{badge}</span>
+ {/each}
+ {#if card.resultSummary !== null}
+ <span
+ class="max-w-[40%] shrink-0 truncate text-xs"
+ class:text-error={card.status === "error"}
+ title={card.resultSummary}
+ >
+ → {card.resultSummary}
+ </span>
+ {/if}
+ {/snippet}
+ <div class="min-w-0 space-y-3 text-xs">
+ <section aria-label="Call">
+ <div class="mb-1 font-medium opacity-60">Call</div>
+ {#if card.callDetail.kind === "command"}
+ <pre class="overflow-x-auto">$ {card.callDetail.text}</pre>
+ {:else if card.callDetail.kind === "edit"}
+ <div class="mb-1 break-all font-mono">{card.callDetail.path}</div>
+ <div class="space-y-1">
+ <div class="opacity-60">− old</div>
+ <pre class="overflow-x-auto text-error">{card.callDetail.oldText}</pre>
+ <div class="opacity-60">+ new</div>
+ <pre class="overflow-x-auto text-success">{card.callDetail.newText}</pre>
+ </div>
+ {:else}
+ <dl class="space-y-1">
+ {#each card.callDetail.fields as field}
+ <div>
+ <dt class="opacity-60">{field.key}</dt>
+ <dd>
+ <pre
+ class="overflow-x-auto"
+ class:whitespace-pre-wrap={!field.multiline}>{field.value}</pre>
+ </dd>
+ </div>
+ {/each}
+ </dl>
+ {/if}
+ </section>
+ <section aria-label="Response">
+ <div class="mb-1 font-medium opacity-60">Response</div>
+ {#if card.output === null}
+ <span class="opacity-60">running…</span>
+ {:else}
+ <div class:text-error={card.output.isError}>
+ {#if card.output.kind === "full"}
+ <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre>
+ {:else}
+ {#if showAll}
+ <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre>
+ {:else}
+ <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre>
+ {/if}
+ <button
+ class="btn btn-ghost btn-xs my-1"
+ aria-expanded={showAll}
+ onclick={() => {
+ showAll = !showAll;
+ }}
+ >
+ {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`}
+ </button>
+ {#if !showAll}
+ <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre>
+ {/if}
+ {/if}
+ </div>
+ {/if}
+ </section>
+ </div>
+</LazyCollapse>