From fc40beca68a79d618a12f87a5fbe5e5934f268ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 03:09:11 +0000 Subject: [PATCH] make a collapsed tool row say something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/apps/Chat/components/ToolActivity.tsx | 83 +++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx index d68a6410..3761e02c 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx @@ -30,7 +30,7 @@ function getToolSummary(toolName: string, toolInput: Record): 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): 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) => { {message.toolName} - {summary} + + {head} + {tail && {tail}} + {stat && (stat.added > 0 || stat.removed > 0) && ( @@ -109,9 +177,10 @@ export const ToolActivity = ({ message }: ToolActivityProps) => { {children.length} step{children.length === 1 ? '' : 's'} )} + {hint && {hint}} {pending && } - {!pending && !isError && done} - {!pending && isError && error} + {/* Success is the default case and says nothing worth a colour; only a failure earns one. */} + {isError && error}