web: tool cards show what ran — summary line (bash command / file path) + edit/write line diffs (LCS, +/- coloring); 286 tests green

This commit is contained in:
2026-09-01 13:17:54 +00:00
parent f5ee3a0f2d
commit 3b799c69fb
3 changed files with 361 additions and 23 deletions
+132 -2
View File
@@ -5,8 +5,10 @@ import type { ChatMessage, ToolState } from "./derive";
import ChatStream, {
Bubble,
TypingIndicator,
lineDiff,
renderMarkdown,
toolArgsEntries,
toolSummaryText,
} from "./ChatStream";
function msg(partial: Partial<ChatMessage>): ChatMessage {
@@ -772,7 +774,7 @@ describe("pretty tool args", () => {
render(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])} />);
expect(screen.getByText("ls -la")).toBeInTheDocument();
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
expect(document.querySelector(".tool-args")?.textContent).not.toContain('"command"');
});
@@ -786,7 +788,7 @@ describe("pretty tool args", () => {
render(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])} />);
expect(screen.getByText("src/main.ts")).toBeInTheDocument();
expect(document.querySelector(".tool-summary")?.textContent).toBe("src/main.ts");
expect(document.querySelector(".tool-args .arg-k")).toBeNull();
});
@@ -805,3 +807,131 @@ describe("pretty tool args", () => {
expect(entries[0]?.v).toBe("1");
});
});
describe("tool summary + diff", () => {
const tool = (args: string, name = "bash"): ToolState => ({
id: "t1",
name,
args,
running: false,
isError: false,
preview: "",
});
it("bash summary shows the command", () => {
render(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
})}
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
/>,
);
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
});
it("read summary shows the path", () => {
render(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }],
})}
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
/>,
);
expect(document.querySelector(".tool-summary")?.textContent).toBe("src/main.ts");
});
it("long summary is truncated with ellipsis", () => {
const long: string = "x".repeat(300);
expect(toolSummaryText(`{"command":"${long}"}`)).toBe(long);
render(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
})}
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
/>,
);
const summary = document.querySelector(".tool-summary");
expect(summary?.textContent?.endsWith("…")).toBe(true);
});
it("edit tool renders a line diff of old/new text", () => {
render(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "edit", argsJson: "{}" }],
})}
tools={new Map([
[
"t1",
tool(
'{"path":"a.ts","edits":[{"oldText":"const a = 1;\\nconst b = 2;","newText":"const a = 3;\\nconst b = 2;"}]}',
"edit",
),
],
])}
/>,
);
expect(document.querySelector(".diff-del")?.textContent).toContain("-const a = 1;");
expect(document.querySelector(".diff-add")?.textContent).toContain("+const a = 3;");
expect(document.querySelector(".diff-ctx")?.textContent).toContain(" const b = 2;");
expect(document.querySelector(".tool-args")).toBeNull();
});
it("write tool renders content as added lines", () => {
render(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "write", argsJson: "{}" }],
})}
tools={new Map([
["t1", tool('{"path":"new.ts","content":"hello\\nworld"}', "write")],
])}
/>,
);
const adds = document.querySelectorAll(".diff-add");
expect(adds).toHaveLength(2);
expect(adds[0]?.textContent).toContain("+hello");
expect(adds[1]?.textContent).toContain("+world");
});
});
describe("lineDiff", () => {
it("identical text is all context", () => {
const d = lineDiff("a\nb", "a\nb");
expect(d).toEqual([
{ kind: "ctx", text: "a" },
{ kind: "ctx", text: "b" },
]);
});
it("single line change is del + add", () => {
const d = lineDiff("a\nb\nc", "a\nx\nc");
expect(d).toEqual([
{ kind: "ctx", text: "a" },
{ kind: "del", text: "b" },
{ kind: "add", text: "x" },
{ kind: "ctx", text: "c" },
]);
});
it("pure addition and deletion", () => {
expect(lineDiff("a", "a\nb")).toEqual([
{ kind: "ctx", text: "a" },
{ kind: "add", text: "b" },
]);
expect(lineDiff("a\nb", "a")).toEqual([
{ kind: "ctx", text: "a" },
{ kind: "del", text: "b" },
]);
});
it("oversized blocks fall back to del-all + add-all", () => {
const a = Array.from({ length: 401 }, (_, i) => `l${i}`).join("\n");
const d = lineDiff(a, "x");
expect(d.filter((l) => l.kind === "del")).toHaveLength(401);
expect(d.filter((l) => l.kind === "add")).toHaveLength(1);
});
});
+184 -21
View File
@@ -108,20 +108,20 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
n += 1;
code = null;
}
} else if (code !== null) {
code.push(line);
} else {
} else if (code === null) {
plain.push(line);
} else {
code.push(line);
}
}
if (code !== null) {
if (code === null) {
flushPlain();
} else {
out.push(
<pre className="md-code" key={`c-${n}`}>
{code.join("\n")}
</pre>,
);
} else {
flushPlain();
}
return out;
}
@@ -162,16 +162,27 @@ function CopyButton({ text }: { text: string }): React.ReactNode {
);
}
// ---------- pretty tool args ----------
// Keys shown first when present (most readable "what is this tool doing"
// signal); the rest follow in their original order.
const ARG_PRIORITY: string[] = [
"command", "path", "file_path", "pattern", "url", "query", "content",
"prompt", "task", "description",
"command",
"path",
"file_path",
"pattern",
"url",
"query",
"content",
"prompt",
"task",
"description",
];
// Above this many lines per side, LCS is skipped and the whole old block is
// rendered removed + the whole new block added.
const DIFF_MAX_LINES: number = 400;
export interface ArgEntry {
k: string | null;
v: string;
@@ -186,8 +197,7 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
} catch {
return [{ k: null, v: argsText }];
}
if (typeof parsed === "string")
return [{ k: null, v: parsed }];
if (typeof parsed === "string") return [{ k: null, v: parsed }];
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
return [{ k: null, v: argsText }];
const obj = parsed as Record<string, unknown>;
@@ -202,7 +212,7 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
const v =
typeof raw === "string"
? raw
: JSON.stringify(raw, null, 2) ?? String(raw);
: (JSON.stringify(raw, null, 2) ?? String(raw));
entries.push({ k, v });
}
if (entries.length === 1 && entries[0] !== undefined)
@@ -210,6 +220,148 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
return entries;
}
/** One-line "what is this tool doing" for the collapsed summary line:
* the highest-priority arg's value (bash → command, read/write/edit → path). */
export function toolSummaryText(argsText: string): string {
const first = toolArgsEntries(argsText)[0];
return first === undefined ? "" : first.v;
}
// ---------- edit/write diff view ----------
export interface DiffLine {
kind: "ctx" | "add" | "del";
text: string;
}
/** Line-level LCS diff between two text blocks. */
export function lineDiff(a: string, b: string): DiffLine[] {
const left: string[] = a.split("\n");
const right: string[] = b.split("\n");
if (left.length > DIFF_MAX_LINES || right.length > DIFF_MAX_LINES)
return [
...left.map((text): DiffLine => ({ kind: "del", text })),
...right.map((text): DiffLine => ({ kind: "add", text })),
];
const n: number = left.length;
const m: number = right.length;
const dp: number[][] = Array.from({ length: n + 1 }, () =>
Array.from({ length: m + 1 }, (): number => 0),
);
for (let i = n - 1; i >= 0; i--) {
const row: number[] | undefined = dp[i];
if (row === undefined) continue;
for (let j = m - 1; j >= 0; j--) {
const a: string | undefined = left[i];
const b: string | undefined = right[j];
row[j] =
a !== undefined && a === b
? (dp[i + 1]?.[j + 1] ?? 0) + 1
: Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
}
}
const out: DiffLine[] = [];
let i: number = 0;
let j: number = 0;
while (i < n && j < m) {
const a: string | undefined = left[i];
const b: string | undefined = right[j];
if (a !== undefined && a === b) {
out.push({ kind: "ctx", text: a });
i += 1;
j += 1;
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
if (a !== undefined) out.push({ kind: "del", text: a });
i += 1;
} else {
if (b !== undefined) out.push({ kind: "add", text: b });
j += 1;
}
}
for (; i < n; i++) {
const a: string | undefined = left[i];
if (a !== undefined) out.push({ kind: "del", text: a });
}
for (; j < m; j++) {
const b: string | undefined = right[j];
if (b !== undefined) out.push({ kind: "add", text: b });
}
return out;
}
interface TextEdit {
oldText: string;
newText: string;
}
function parseArgsObj(argsText: string): Record<string, unknown> | null {
try {
const p: unknown = JSON.parse(argsText);
return typeof p === "object" && p !== null && !Array.isArray(p)
? (p as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/** Diff blocks for edit/write tool args; null when the tool is neither or
* the args do not carry the expected fields. */
export function toolDiffBlocks(
name: string,
argsText: string,
): DiffLine[][] | null {
const obj: Record<string, unknown> | null = parseArgsObj(argsText);
if (obj === null) return null;
if (name === "write" && typeof obj.content === "string")
return [
obj.content
.split("\n")
.slice(0, DIFF_MAX_LINES)
.map((text): DiffLine => ({ kind: "add", text })),
];
if (name !== "edit") return null;
const edits: TextEdit[] = [];
if (Array.isArray(obj.edits)) {
for (const e of obj.edits) {
if (
typeof e === "object" &&
e !== null &&
typeof (e as Record<string, unknown>).oldText === "string" &&
typeof (e as Record<string, unknown>).newText === "string"
)
edits.push(e as TextEdit);
}
} else if (
typeof obj.oldText === "string" &&
typeof obj.newText === "string"
) {
edits.push({ oldText: obj.oldText, newText: obj.newText });
}
return edits.length === 0
? null
: edits.map((e): DiffLine[] => lineDiff(e.oldText, e.newText));
}
const DIFF_MARK: Record<DiffLine["kind"], string> = {
add: "+",
del: "-",
ctx: " ",
};
function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
return (
<pre className="diff">
{lines.map((l, i) => (
<span key={i} className={`diff-line diff-${l.kind}`}>
{DIFF_MARK[l.kind] + l.text}
{"\n"}
</span>
))}
</pre>
);
}
function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running
? "running…"
@@ -219,23 +371,34 @@ function ToolCard({ tool }: { tool: ToolState }) {
const statusClass: string = tool.isError
? "tool-status-err"
: "tool-status-ok";
const summary: string = oneLine(toolSummaryText(tool.args));
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
return (
<details className="tool-card">
<summary>
<span className="tool-name">🛠 {tool.name}</span>
<span className={tool.running ? "" : statusClass}>
{summary.length > 0 && <span className="tool-summary">{summary}</span>}
<span className={`tool-status ${tool.running ? "" : statusClass}`}>
{tool.running ? "working…" : status}
</span>
</summary>
<div className="tool-body">
<div className="tool-args">
{toolArgsEntries(tool.args).map((e, i) => (
<div key={e.k ?? i} className="tool-arg">
{e.k !== null && <span className="arg-k">{e.k}</span>}
<pre className="arg-v">{e.v}</pre>
</div>
))}
</div>
{diffBlocks === null ? (
<div className="tool-args">
{toolArgsEntries(tool.args).map((e, i) => (
<div key={e.k ?? i} className="tool-arg">
{e.k !== null && <span className="arg-k">{e.k}</span>}
<pre className="arg-v">{e.v}</pre>
</div>
))}
</div>
) : (
<div className="tool-diffs">
{diffBlocks.map((b, i) => (
<DiffBlock key={i} lines={b} />
))}
</div>
)}
<div>
<span className="arg-k">output</span>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
+45
View File
@@ -687,6 +687,18 @@ a:hover {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
flex-shrink: 0;
}
.bubble .tool-card summary .tool-summary {
flex: 1;
min-width: 40px;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--mono);
color: var(--text-dim);
}
.bubble .tool-card summary .tool-status {
flex-shrink: 0;
}
.bubble .tool-card .tool-body {
border-top: 1px solid var(--border);
@@ -1468,3 +1480,36 @@ mark.hit {
font-size: 12.5px;
color: var(--text);
}
/* ---------- edit/write diff ---------- */
.tool-diffs {
margin-bottom: 10px;
}
.diff {
margin: 0 0 6px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
background: var(--bg-veil);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 0;
overflow-x: auto;
white-space: pre;
}
.diff-line {
display: block;
padding: 0 10px;
}
.diff-del {
color: var(--danger);
background: var(--danger-soft);
}
.diff-add {
color: var(--ok);
background: rgba(52, 211, 153, 0.08);
}
.diff-ctx {
color: var(--text-faint);
}