make a collapsed tool row say something
Five Bash rows in a trace read `cd /home/…/platform && git status…`, `echo "=== server-side…`, `echo "=== opencode handler…` — cut, every one of them, exactly where they started being useful. Two things conspired. The summary showed the head of a compound command, which is usually scaffolding: a `cd` into the repo, or an `echo` labelling output for a human. And both `slice()` and CSS `truncate` drop the tail, which is where the identity lives — the filename that distinguishes ten Reads sharing a directory, the target a command acts on. So skip a leading `cd`/`echo` up to its `&&`, and pin the tail as its own non-shrinking span so the head ellipsises and the cut lands in the middle at whatever width the panel is. Both are display only: expanding the row, and the copy button, still give the command verbatim. The right margin traded `done` for what the call found. Success was the loudest colour on the row and reported the least interesting fact about it, once per row; a failure still earns its red. In its place the count that used to cost an expand to learn — `no matches`, `12 lines`, `3 files`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ function getToolSummary(toolName: string, toolInput: Record<string, unknown>): s
|
||||
case 'Write':
|
||||
return (toolInput.file_path as string) ?? '';
|
||||
case 'Bash':
|
||||
return truncate((toolInput.command as string) ?? '', 80);
|
||||
return bashOperation((toolInput.command as string) ?? '');
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return (toolInput.pattern as string) ?? '';
|
||||
@@ -54,8 +54,72 @@ function getToolSummary(toolName: string, toolInput: Record<string, unknown>): s
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
/**
|
||||
* The first segment of a compound command is usually scaffolding — a `cd` into the repo, or an `echo`
|
||||
* labelling the output for a human — and it is exactly what a head-truncated summary has room to show.
|
||||
* A stack of rows then reads `cd /home/…/platform && git status…`, `echo "=== server-side…`, with the
|
||||
* grep that actually answered the question off the end of every one of them.
|
||||
*
|
||||
* Only a leading `cd`/`echo` is skipped, and only up to a following `&&`/`;`, so nothing that does work
|
||||
* is ever hidden. This is display-only: expanding the row, and the copy button, still give the real
|
||||
* command verbatim.
|
||||
*/
|
||||
const PREAMBLE = /^\s*(?:cd\s+[^&;|]+|echo\s+(?:"[^"]*"|'[^']*'|[^&;|]+))\s*(?:&&|;)\s*/;
|
||||
|
||||
function bashOperation(command: string): string {
|
||||
let rest = command;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const next = rest.replace(PREAMBLE, '');
|
||||
if (next === rest) break;
|
||||
rest = next;
|
||||
}
|
||||
// A command that is *only* a preamble (`cd somewhere`) still has to say something.
|
||||
return rest.trim() || command.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a summary so the browser truncates it in the **middle** instead of at the end.
|
||||
*
|
||||
* The end is where the identity lives: one filename per row distinguishes ten `Read`s that share a
|
||||
* directory, and a command's target is the last thing on the line. Cutting the tail — which both
|
||||
* `slice()` and CSS `truncate` do — throws away precisely the part you were scanning for. So the tail
|
||||
* is pinned as its own non-shrinking span and the head is left to ellipsise, which lands the cut in the
|
||||
* middle at whatever width the panel happens to be.
|
||||
*/
|
||||
const HEAD_MAX = 200;
|
||||
|
||||
function splitSummary(text: string): { head: string; tail: string } {
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
if (flat.length <= 48) return { head: flat, tail: '' };
|
||||
const window = flat.slice(-40);
|
||||
const cut = Math.max(window.lastIndexOf('/'), window.lastIndexOf(' '));
|
||||
// Prefer a real boundary; a tail cut mid-token still beats no tail at all.
|
||||
const tail = cut > 0 && cut < window.length - 1 ? window.slice(cut + 1) : window.slice(-24);
|
||||
const head = flat.slice(0, flat.length - tail.length);
|
||||
return { head: head.length > HEAD_MAX ? head.slice(0, HEAD_MAX) : head, tail };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the collapsed row never said: whether the call found anything.
|
||||
*
|
||||
* `done` reported only that it ran — the least interesting fact in a trace, in the loudest colour on the
|
||||
* row, repeated on every row. Whether a grep matched is the thing you were actually scanning for, and it
|
||||
* used to cost an expand to learn.
|
||||
*/
|
||||
function outputHint(toolName: string, output: string): string | null {
|
||||
const lines = output.split('\n').filter((l) => l.trim()).length;
|
||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
switch (toolName) {
|
||||
case 'Grep':
|
||||
return lines ? plural(lines, 'match') : 'no matches';
|
||||
case 'Glob':
|
||||
return lines ? plural(lines, 'file') : 'no files';
|
||||
case 'Read':
|
||||
case 'Bash':
|
||||
return lines ? plural(lines, 'line') : 'no output';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,10 +140,11 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const Icon = toolIcons[message.toolName] ?? Wrench;
|
||||
const summary = getToolSummary(message.toolName, message.toolInput);
|
||||
const { head, tail } = splitSummary(getToolSummary(message.toolName, message.toolInput));
|
||||
const pending = message.output === undefined;
|
||||
const isError = message.isError === true;
|
||||
const children = message.children ?? [];
|
||||
const hint = pending || isError ? null : outputHint(message.toolName, message.output ?? '');
|
||||
|
||||
// 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]);
|
||||
@@ -95,7 +160,10 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
|
||||
<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 min-w-0 flex-1 items-center font-mono text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{head}</span>
|
||||
{tail && <span className="shrink-0">{tail}</span>}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{stat && (stat.added > 0 || stat.removed > 0) && (
|
||||
<span className="tabular-nums text-xs">
|
||||
@@ -109,9 +177,10 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
{children.length} step{children.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{hint && <span className="text-xs tabular-nums text-muted-foreground">{hint}</span>}
|
||||
{pending && <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-warning" />}
|
||||
{!pending && !isError && <span className="text-xs text-success">done</span>}
|
||||
{!pending && isError && <span className="text-xs text-destructive">error</span>}
|
||||
{/* Success is the default case and says nothing worth a colour; only a failure earns one. */}
|
||||
{isError && <span className="text-xs text-destructive">error</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user