diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index 49aa621..0578c6a 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -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 { @@ -772,7 +774,7 @@ describe("pretty tool args", () => { render(); - 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(); - 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( + , + ); + expect(screen.getByText("🛠 bash")).toBeInTheDocument(); + expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la"); + }); + + it("read summary shows the path", () => { + render( + , + ); + 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( + , + ); + const summary = document.querySelector(".tool-summary"); + expect(summary?.textContent?.endsWith("…")).toBe(true); + }); + + it("edit tool renders a line diff of old/new text", () => { + render( + , + ); + 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( + , + ); + 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); + }); +}); diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx index 735a1a3..980014e 100644 --- a/web/src/ChatStream.tsx +++ b/web/src/ChatStream.tsx @@ -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(
 				{code.join("\n")}
 			
, ); - } 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; @@ -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 | null { + try { + const p: unknown = JSON.parse(argsText); + return typeof p === "object" && p !== null && !Array.isArray(p) + ? (p as Record) + : 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 | 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).oldText === "string" && + typeof (e as Record).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 = { + add: "+", + del: "-", + ctx: " ", +}; + +function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode { + return ( +
+			{lines.map((l, i) => (
+				
+					{DIFF_MARK[l.kind] + l.text}
+					{"\n"}
+				
+			))}
+		
+ ); +} + 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 (
🛠 {tool.name} - + {summary.length > 0 && {summary}} + {tool.running ? "working…" : status}
-
- {toolArgsEntries(tool.args).map((e, i) => ( -
- {e.k !== null && {e.k}} -
{e.v}
-
- ))} -
+ {diffBlocks === null ? ( +
+ {toolArgsEntries(tool.args).map((e, i) => ( +
+ {e.k !== null && {e.k}} +
{e.v}
+
+ ))} +
+ ) : ( +
+ {diffBlocks.map((b, i) => ( + + ))} +
+ )}
output
diff --git a/web/src/index.css b/web/src/index.css
index e597235..c0420fa 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -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);
+}