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}