render edit and write tool calls as real diffs
the most-looked-at panel in the chat was the worst rendered: an Edit showed old_string and new_string as two flat monospace blobs, so the only question you had — what changed — was the one thing you had to work out by eye. adds a small LCS line diff rather than a dependency; jsdiff would ship to the browser to do a textbook algorithm that is shorter than its own integration. no line numbers, deliberately: Edit fragments carry no file position, and an invented line number is worse than none. the collapsed row now carries +n −m, so the size of an edit is legible without expanding it, and copy yields the resulting text rather than a key: value dump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight, Bot } from 'lucide-react';
|
||||
import type { ChatMessage } from '../types';
|
||||
import type { DiffLine } from './line-diff';
|
||||
import { diffStat, editDiff } from './line-diff';
|
||||
import { CopyButton } from './CopyButton';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
@@ -56,6 +58,20 @@ function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy gives you the thing you'd want to paste — the command, or the text the file ends up containing —
|
||||
* never the `key: value` dump, which is a rendering of the call and not usable as anything.
|
||||
*/
|
||||
function copyableInput(toolName: string, toolInput: Record<string, unknown>): string {
|
||||
const pick = (key: string): string | null => (typeof toolInput[key] === 'string' ? (toolInput[key] as string) : null);
|
||||
if (toolName === 'Bash') return pick('command') ?? JSON.stringify(toolInput, null, 2);
|
||||
if (toolName === 'Edit') return pick('new_string') ?? JSON.stringify(toolInput, null, 2);
|
||||
if (toolName === 'Write') return pick('content') ?? JSON.stringify(toolInput, null, 2);
|
||||
return Object.entries(toolInput)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -65,6 +81,10 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
const isError = message.isError === true;
|
||||
const children = message.children ?? [];
|
||||
|
||||
// Computed even while collapsed, because the +/− counts are what make the collapsed row worth reading.
|
||||
const diff = useMemo(() => editDiff(message.toolName, message.toolInput), [message.toolName, message.toolInput]);
|
||||
const stat = diff ? diffStat(diff) : null;
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<button
|
||||
@@ -77,6 +97,13 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
<span className="font-medium text-foreground">{message.toolName}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{summary}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{stat && (stat.added > 0 || stat.removed > 0) && (
|
||||
<span className="tabular-nums text-xs">
|
||||
{stat.added > 0 && <span className="text-success">+{stat.added}</span>}
|
||||
{stat.added > 0 && stat.removed > 0 && ' '}
|
||||
{stat.removed > 0 && <span className="text-destructive">−{stat.removed}</span>}
|
||||
</span>
|
||||
)}
|
||||
{children.length > 0 && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{children.length} step{children.length === 1 ? '' : 's'}
|
||||
@@ -92,15 +119,9 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
<div className="ml-7 mt-1 space-y-2 text-xs">
|
||||
<div className="rounded-md bg-muted/50 p-2 overflow-x-auto group/input">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">Input</div>
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">{diff ? 'Diff' : 'Input'}</div>
|
||||
<CopyButton
|
||||
text={
|
||||
message.toolName === 'Bash'
|
||||
? ((message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2))
|
||||
: Object.entries(message.toolInput)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join('\n')
|
||||
}
|
||||
text={copyableInput(message.toolName, message.toolInput)}
|
||||
className="!opacity-0 group-hover/input:!opacity-60 hover:!opacity-100"
|
||||
/>
|
||||
</div>
|
||||
@@ -108,6 +129,8 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-words">
|
||||
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
|
||||
</pre>
|
||||
) : diff ? (
|
||||
<DiffView lines={diff} />
|
||||
) : (
|
||||
<pre className="font-mono whitespace-pre-wrap break-words text-foreground/80">
|
||||
{Object.entries(message.toolInput)
|
||||
@@ -142,6 +165,66 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An Edit or Write, rendered as a diff instead of two opaque blobs. This is the most-looked-at panel in
|
||||
* the app — an agent that mostly edits code spends most of its visible output here — and it used to show
|
||||
* `old_string: <400 lines>` / `new_string: <400 lines>` as flat monospace, which meant the one question
|
||||
* you actually had (what changed?) was the one thing you had to work out by eye.
|
||||
*
|
||||
* The tint carries the meaning and the text stays at normal contrast: colouring whole lines success-green
|
||||
* makes code markedly harder to read, and the gutter marker is enough to say which side a line is on. That
|
||||
* marker is `select-none` so dragging across the diff copies the code and not a column of +/− noise.
|
||||
*/
|
||||
const DIFF_MAX_LINES = 40;
|
||||
|
||||
const DiffView = ({ lines }: { lines: DiffLine[] }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hidden = lines.length - DIFF_MAX_LINES;
|
||||
const shown = expanded ? lines : lines.slice(0, DIFF_MAX_LINES);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded font-mono">
|
||||
{shown.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-2 px-1 ${
|
||||
line.kind === 'add' ? 'bg-success/10' : line.kind === 'del' ? 'bg-destructive/10' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3 shrink-0 select-none text-center ${
|
||||
line.kind === 'add'
|
||||
? 'text-success'
|
||||
: line.kind === 'del'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : ' '}
|
||||
</span>
|
||||
<span
|
||||
className={`whitespace-pre-wrap break-words ${
|
||||
line.kind === 'ctx' ? 'text-muted-foreground' : 'text-foreground/90'
|
||||
}`}
|
||||
>
|
||||
{line.text || ' '}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline"
|
||||
>
|
||||
{expanded ? 'Show less' : `Show more (${hidden} more lines)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What a subagent did, in order — its own tool calls nested one level further, its prose as plain text.
|
||||
* Deliberately not markdown-rendered: this is a trace, and it sits inside an already-nested panel.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// A line diff, written here rather than pulled in. jsdiff would do this, but it is a runtime dependency
|
||||
// shipped to the browser to run a textbook LCS, and the LCS is shorter than the integration would be.
|
||||
//
|
||||
// Deliberately no line numbers. An Edit's `old_string` / `new_string` are fragments with no file
|
||||
// position attached, so any number we printed beside them would be a number we invented — and a
|
||||
// plausible-looking wrong line number is worse than none.
|
||||
|
||||
export type DiffLine = { kind: 'add' | 'del' | 'ctx'; text: string };
|
||||
|
||||
/**
|
||||
* The LCS table is O(n*m); at the cap that is ~640k int32s, which is fine, and past it the panel was
|
||||
* never going to be readable anyway. `null` means "too big to diff" and the caller falls back to
|
||||
* showing the raw strings.
|
||||
*/
|
||||
const MAX_LINES = 800;
|
||||
|
||||
export function lineDiff(before: string, after: string): DiffLine[] | null {
|
||||
const a = before.split('\n');
|
||||
const b = after.split('\n');
|
||||
if (a.length > MAX_LINES || b.length > MAX_LINES) return null;
|
||||
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const width = m + 1;
|
||||
const lcs = new Int32Array((n + 1) * width);
|
||||
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i * width + j] =
|
||||
a[i] === b[j]
|
||||
? lcs[(i + 1) * width + (j + 1)]! + 1
|
||||
: Math.max(lcs[(i + 1) * width + j]!, lcs[i * width + (j + 1)]!);
|
||||
}
|
||||
}
|
||||
|
||||
const out: DiffLine[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ kind: 'ctx', text: a[i]! });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[(i + 1) * width + j]! >= lcs[i * width + (j + 1)]!) {
|
||||
out.push({ kind: 'del', text: a[i]! });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ kind: 'add', text: b[j]! });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) out.push({ kind: 'del', text: a[i++]! });
|
||||
while (j < m) out.push({ kind: 'add', text: b[j++]! });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Counts for the collapsed row, so the size of an edit is visible without expanding it. */
|
||||
export function diffStat(lines: DiffLine[]): { added: number; removed: number } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const line of lines) {
|
||||
if (line.kind === 'add') added++;
|
||||
else if (line.kind === 'del') removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* What an Edit/Write tool call is actually changing, or null when the call is neither. `Write` has no
|
||||
* prior text on the wire, so it reads as an all-addition diff — which is what writing a new file is.
|
||||
*/
|
||||
export function editDiff(toolName: string, input: Record<string, unknown>): DiffLine[] | null {
|
||||
if (toolName === 'Edit') {
|
||||
const before = input.old_string;
|
||||
const after = input.new_string;
|
||||
if (typeof before !== 'string' || typeof after !== 'string') return null;
|
||||
return lineDiff(before, after);
|
||||
}
|
||||
if (toolName === 'Write') {
|
||||
const content = input.content;
|
||||
if (typeof content !== 'string') return null;
|
||||
// Built directly rather than diffed against '': `''.split('\n')` is `['']`, not `[]`, so an empty
|
||||
// "before" side would open every new file with a phantom deleted blank line.
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > MAX_LINES) return null;
|
||||
return lines.map((text) => ({ kind: 'add' as const, text }));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user