From 41f302fa73f5780e9be2f0fb7ebfcb3a8b77fef3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 00:00:58 -0500 Subject: [PATCH] fix(desktop): compact tool row titles Make completed desktop tool rows read like useful activity labels instead of raw plumbing: terminal rows use a dispatch-style shell summarizer for agent wrappers, and read_file rows keep the action plus filename and requested line range. The shell cleanup follows condensed-milk-pi's shape: split command compounds on real separators, strip pipe tails inside each segment, clean redirects/env prefixes, then classify setup/banner/status segments. Multi-command probes render as `first command + N commands`; the full command remains available in copy/detail. Read rows now render as `Read package.json` or `Read main.ts L25-34`, using requested positive offset/limit and returned line numbers only as fallback for negative/unknown offsets. --- .../assistant-ui/tool-fallback-model.test.ts | 94 +++++++- .../assistant-ui/tool-fallback-model.ts | 64 +++++- apps/desktop/src/i18n/en.ts | 2 +- apps/desktop/src/i18n/ja.ts | 2 +- apps/desktop/src/i18n/zh-hant.ts | 2 +- apps/desktop/src/i18n/zh.ts | 2 +- .../desktop/src/lib/summarize-command.test.ts | 110 +++++++++ apps/desktop/src/lib/summarize-command.ts | 216 ++++++++++++++++++ 8 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/lib/summarize-command.test.ts create mode 100644 apps/desktop/src/lib/summarize-command.ts diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts index 82061c61f0a..a378a40a2c5 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts @@ -140,10 +140,10 @@ describe('buildToolView title actions', () => { expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' file' }) expect(web.title).toBe('Reading example.com/docs') expect(web.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' example.com/docs' }) - expect(terminal.title).toBe('Running · npm test -- --runInBand') - expect(terminal.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' · npm test -- --runInBand' }) - expect(code.title).toBe('Scripting · print("hello")') - expect(code.titleAction).toEqual({ prefix: '', text: 'Scripting', suffix: ' · print("hello")' }) + expect(terminal.title).toBe('Running npm test -- --runInBand') + expect(terminal.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' npm test -- --runInBand' }) + expect(code.title).toBe('Scripting print("hello")') + expect(code.titleAction).toEqual({ prefix: '', text: 'Scripting', suffix: ' print("hello")' }) }) it('does not mark completed tool titles as pending actions', () => { @@ -153,6 +153,92 @@ describe('buildToolView title actions', () => { expect(view.titleAction).toBeUndefined() }) + it('uses the filename for completed read_file rows', () => { + const view = buildToolView( + part({ args: { path: './package.json' }, result: { content: '1|{"name":"demo"}' }, toolName: 'read_file' }), + '' + ) + + expect(view.title).toBe('Read package.json') + expect(view.subtitle).toBe('') + expect(view.titleAction).toBeUndefined() + }) + + it('adds a compact line range to line-scoped read_file rows', () => { + const view = buildToolView( + part({ + args: { limit: 10, offset: 25, path: './src/main.ts' }, + result: { content: '25|function toggleDock() {\n26| dock.classList.toggle("hidden");\n34|}' }, + toolName: 'read_file' + }), + '' + ) + + expect(view.title).toBe('Read main.ts L25-34') + expect(view.subtitle).toBe('') + }) + + it('uses the requested positive offset/limit for read_file row line ranges', () => { + const view = buildToolView( + part({ + args: { limit: 5, offset: 1, path: './package.json' }, + result: { content: '1|{\n2| "name": "bb-rainbows",\n3| "private": true,\n4| "version": "0.0.1",\n5| "type": "module",\n6| "description": "extra"' }, + toolName: 'read_file' + }), + '' + ) + + expect(view.title).toBe('Read package.json L1-5') + }) + + it('uses returned line numbers for negative-offset read_file rows', () => { + const view = buildToolView( + part({ + args: { limit: 2, offset: -2, path: './src/main.ts' }, + result: { content: '99|lastLine();\n100|done();' }, + toolName: 'read_file' + }), + '' + ) + + expect(view.title).toBe('Read main.ts L99-100') + }) + + it('renders compact terminal titles for session 20260624_231846_bdbd1e commands', () => { + const rows = [ + [ + 'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20; echo "lint_exit=${PIPESTATUS[0]}"', + 'Ran pnpm run lint' + ], + [ + 'cd /Users/brooklyn/www/bb-rainbows && pnpm run build 2>&1 | tail -20; echo "build_exit=${PIPESTATUS[0]}"', + 'Ran pnpm run build' + ], + [ + 'which node pnpm corepack; node -v; echo "---"; corepack --version 2>&1; echo "---pnpm via corepack---"; pnpm --version 2>&1 | tail -5', + 'Ran which node pnpm corepack + 3 commands' + ], + [ + 'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3; echo "--- proto node ---"; ls ~/.proto/tools/node/ 2>&1; echo "--- corepack cache ---"; ls ~/.cache/node/corepack/v1/pnpm/ 2>&1', + 'Ran ~/.proto/tools/node/24.11.0/bin/pnpm --version + 2 commands' + ], + [ + 'cd /Users/brooklyn/www/bb-rainbows && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack pnpm@10.20.0 --version 2>&1 | tail -3', + 'Ran COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack pnpm@10.20.0 --version' + ], + [ + 'cd /Users/brooklyn/www/bb-rainbows && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack use pnpm@10.20.0 2>&1 | tail -10; echo "exit=$?"', + 'Ran COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack use pnpm@10.20.0' + ] + ] as const + + for (const [command, expectedTitle] of rows) { + const view = buildToolView(part({ args: { command }, result: { output: 'ok', exit_code: 0 }, toolName: 'terminal' }), '') + + expect(view.title).toBe(expectedTitle) + } + }) + it('uses the runtime locale for title text and action placement', () => { setRuntimeI18nLocale('ja') diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index cf3c6a5c0fb..58c07317160 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -1,5 +1,6 @@ import { type ToolTitleKey, translateNow } from '@/i18n' import { normalizeExternalUrl } from '@/lib/external-link' +import { summarizeShellCommand } from '@/lib/summarize-command' import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' @@ -125,6 +126,45 @@ function fileEditBasename(path: string): string { return normalized.split('/').filter(Boolean).pop() || normalized } +function numericField(record: Record, key: string): number | undefined { + const value = record[key] + + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function readFileLineLabel(args: Record, result: Record): string { + if (numericField(args, 'offset') === undefined && numericField(args, 'limit') === undefined) { + return '' + } + + const content = firstStringField(result, ['content']) + const offset = numericField(args, 'offset') + const limit = numericField(args, 'limit') + + if (offset !== undefined && offset > 0) { + if (limit === undefined || limit <= 1) { + return `L${offset}` + } + + return `L${offset}-${offset + limit - 1}` + } + + const lines = content + .split('\n') + .map(line => /^(\d+)\|/.exec(line)?.[1]) + .filter((line): line is string => !!line) + .map(Number) + + if (lines.length === 0) { + return '' + } + + const start = lines[0]! + const end = lines[lines.length - 1]! + + return start === end ? `L${start}` : `L${start}-${end}` +} + const TOOL_META: Record = { browser_click: { icon: 'globe', @@ -1182,7 +1222,7 @@ function toolSubtitle( const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord) - return command ? compactPreview(command, 120) : 'Executed command' + return command ? compactPreview(summarizeShellCommand(command), 120) : 'Executed command' } if (toolName === 'read_file' || isFileEditTool(toolName)) { @@ -1290,7 +1330,7 @@ function toolDetailText( } } - if (part.toolName === 'read_file') { + if (part.toolName === 'read_file' && part.result !== undefined) { const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body']) if (content) { @@ -1401,7 +1441,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string } } - if (part.toolName === 'read_file') { + if (part.toolName === 'read_file' && part.result !== undefined) { if (hasSubstantialOutput) { return { label: copy.file, text: detail } } @@ -1514,11 +1554,27 @@ function dynamicTitle( return titledAction( action, - translateNow('assistant.tool.titleTemplates.actionCommand', action, compactPreview(command, 160)) + translateNow( + 'assistant.tool.titleTemplates.actionCommand', + action, + compactPreview(summarizeShellCommand(command), 160) + ) ) } } + if (part.toolName === 'read_file' && part.result !== undefined) { + const path = firstStringField(args, ['path', 'file', 'filepath']) + + if (path) { + const lineLabel = readFileLineLabel(args, result) + const target = [fileEditBasename(path), lineLabel].filter(Boolean).join(' ') + const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read')) + + return { title: translateNow('assistant.tool.titleTemplates.actionTarget', action, target) } + } + } + if (isFileEditTool(part.toolName)) { const path = fileEditPath(args, result) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2e1c9dbe365..c2120533743 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1921,7 +1921,7 @@ export const en: Translations = { web: 'Web' }, titleTemplates: { - actionCommand: (action, command) => `${action} · ${command}`, + actionCommand: (action, command) => `${action} ${command}`, actionQuoted: (action, value) => `${action} “${value}”`, actionTarget: (action, target) => `${action} ${target}`, prefixedDone: (prefix, action) => `${prefix} ${action}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 503492cfae6..ba2317b41f1 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2049,7 +2049,7 @@ export const ja = defineLocale({ web: 'Web' }, titleTemplates: { - actionCommand: (action, command) => `${action} · ${command}`, + actionCommand: (action, command) => `${action} ${command}`, actionQuoted: (action, value) => `「${value}」を${action}`, actionTarget: (action, target) => `${target} を${action}`, prefixedDone: (prefix, action) => `${prefix} ${action}`, diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index ce55a582e23..8285a9adc0a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1989,7 +1989,7 @@ export const zhHant = defineLocale({ web: '網頁' }, titleTemplates: { - actionCommand: (action, command) => `${action} · ${command}`, + actionCommand: (action, command) => `${action} ${command}`, actionQuoted: (action, value) => `${action}「${value}」`, actionTarget: (action, target) => `${action} ${target}`, prefixedDone: (prefix, action) => `${prefix}${action}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 478d3326abe..90613bd86a5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2097,7 +2097,7 @@ export const zh: Translations = { web: '网页' }, titleTemplates: { - actionCommand: (action, command) => `${action} · ${command}`, + actionCommand: (action, command) => `${action} ${command}`, actionQuoted: (action, value) => `${action}“${value}”`, actionTarget: (action, target) => `${action} ${target}`, prefixedDone: (prefix, action) => `${prefix}${action}`, diff --git a/apps/desktop/src/lib/summarize-command.test.ts b/apps/desktop/src/lib/summarize-command.test.ts new file mode 100644 index 00000000000..570a5d0ec46 --- /dev/null +++ b/apps/desktop/src/lib/summarize-command.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' + +import { summarizeShellCommand } from './summarize-command' + +describe('summarizeShellCommand', () => { + it('strips a leading cd and trailing tail + status echo', () => { + expect( + summarizeShellCommand( + 'cd /Users/me/www/bb-rainbows && pnpm run lint 2>&1 | tail -10; echo "lint_exit=${PIPESTATUS[0]}"' + ) + ).toBe('pnpm run lint') + }) + + it('keeps flags on the surviving command', () => { + expect(summarizeShellCommand('cd /x && pnpm run preview --port 4317 2>&1')).toBe( + 'pnpm run preview --port 4317' + ) + }) + + it('drops a source/activate prefix', () => { + expect(summarizeShellCommand('source .venv/bin/activate && pytest -q')).toBe('pytest -q') + }) + + it('skips leading env assignments', () => { + expect(summarizeShellCommand('cd /x && NODE_ENV=test FOO=bar vitest run 2>&1 | tail -5')).toBe( + 'NODE_ENV=test FOO=bar vitest run' + ) + }) + + it('compacts a genuine multi-command compound without listing every command', () => { + const compound = 'git add -A && git commit -m "wip"' + expect(summarizeShellCommand(compound)).toBe('git add -A + 1 command') + }) + + it('leaves a single bare command untouched', () => { + expect(summarizeShellCommand('git status --short')).toBe('git status --short') + }) + + it('does not split on operators inside quotes', () => { + const cmd = 'git commit -m "fix: a | b && c"' + expect(summarizeShellCommand(cmd)).toBe(cmd) + }) + + it('does not strip a redirection-looking char inside quotes', () => { + expect(summarizeShellCommand('cd /x && git commit -m "a > b"')).toBe('git commit -m "a > b"') + }) + + it('handles empty / whitespace input', () => { + expect(summarizeShellCommand('')).toBe('') + expect(summarizeShellCommand(' ')).toBe('') + }) + + it('returns the original when every segment is plumbing', () => { + const allSetup = 'cd /x && export FOO=1' + expect(summarizeShellCommand(allSetup)).toBe(allSetup) + }) + + it('collapses 2>&1 redirection on a plain pipeline', () => { + expect(summarizeShellCommand('cd /x && tsc --noEmit 2>&1 | tail -20')).toBe('tsc --noEmit') + }) + + it('drops a leading echo banner around a single command', () => { + expect( + summarizeShellCommand('echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3') + ).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version') + }) + + it('drops echo banners on both sides plus the trailing status echo', () => { + expect(summarizeShellCommand('echo "--- build ---"; npm run build 2>&1 | tail -5; echo "build_exit=$?"')).toBe( + 'npm run build' + ) + }) + + it('compacts a genuine multi-command probe from session 20260624_231846_bdbd1e', () => { + const probe = 'which node pnpm corepack; node -v; corepack --version 2>&1' + expect(summarizeShellCommand(probe)).toBe('which node pnpm corepack + 2 commands') + }) + + it('compacts the corepack diagnostic command from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'which node pnpm corepack; node -v; echo "---"; corepack --version 2>&1; echo "---pnpm via corepack---"; pnpm --version 2>&1 | tail -5' + ) + ).toBe('which node pnpm corepack + 3 commands') + }) + + it('compacts the proto/cache probe from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3; echo "--- proto node ---"; ls ~/.proto/tools/node/ 2>&1; echo "--- corepack cache ---"; ls ~/.cache/node/corepack/v1/pnpm/ 2>&1' + ) + ).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version + 2 commands') + }) + + it('summarizes the successful lint command from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20; echo "lint_exit=${PIPESTATUS[0]}"' + ) + ).toBe('pnpm run lint') + }) + + it('summarizes a background build command from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'cd /Users/brooklyn/www/bb-rainbows && pnpm run build 2>&1 | tail -20; echo "build_exit=${PIPESTATUS[0]}"' + ) + ).toBe('pnpm run build') + }) +}) diff --git a/apps/desktop/src/lib/summarize-command.ts b/apps/desktop/src/lib/summarize-command.ts new file mode 100644 index 00000000000..0030f21436b --- /dev/null +++ b/apps/desktop/src/lib/summarize-command.ts @@ -0,0 +1,216 @@ +// Adapted from condensed-milk-pi's command dispatcher: split compounds first, +// strip pipe tails (`| head`, `| tail`, ...), then clean redirects/env prefixes +// before deciding which segment is meaningful. This is display-only; the full +// command remains available through Copy / detail. +const SILENT_HEADS = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'unset', 'source', '.', 'true', 'false', ':']) +const PIPE_TAIL_HEADS = new Set(['head', 'tail', 'wc', 'sort', 'uniq']) + +const basename = (head: string): string => head.split('/').pop() || head + +// Split on command-chain separators, but NOT pipe. A pipe usually belongs to +// the segment's output plumbing (`cmd 2>&1 | tail -20`); condensed-milk strips +// that after segmenting instead of treating it as a separate producer. +function splitCompoundCommand(input: string): string[] { + const segments: string[] = [] + let buf = '' + let quote: '"' | "'" | null = null + + for (let i = 0; i < input.length; i += 1) { + const ch = input[i]! + + if (quote) { + buf += ch + + if (ch === quote && input[i - 1] !== '\\') { + quote = null + } + + continue + } + + if (ch === '"' || ch === "'") { + quote = ch + buf += ch + + continue + } + + const op = + input.startsWith('&&', i) || input.startsWith('||', i) + ? input.slice(i, i + 2) + : ch === ';' || ch === '\n' + ? ch + : '' + + if (op) { + segments.push(buf) + buf = '' + i += op.length - 1 + + continue + } + + buf += ch + } + + segments.push(buf) + + return segments.map(segment => stripPipeTail(segment.trim())).filter(Boolean) +} + +function splitWords(segment: string): string[] { + const words: string[] = [] + let buf = '' + let quote: '"' | "'" | null = null + + for (let i = 0; i < segment.length; i += 1) { + const ch = segment[i]! + + if (quote) { + buf += ch + + if (ch === quote && segment[i - 1] !== '\\') { + quote = null + } + + continue + } + + if (ch === '"' || ch === "'") { + quote = ch + buf += ch + + continue + } + + if (/\s/.test(ch)) { + if (buf) { + words.push(buf) + buf = '' + } + + continue + } + + buf += ch + } + + if (buf) { + words.push(buf) + } + + return words +} + +// The command word of a segment, skipping any `FOO=bar` env assignments. +function headWord(segment: string): string { + const tokens = splitWords(segment) + let index = 0 + + while (index < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[index]!)) { + index += 1 + } + + return basename(tokens[index] ?? '') +} + +function stripPipeTail(segment: string): string { + const words = splitWords(segment) + const out: string[] = [] + + for (let i = 0; i < words.length; i += 1) { + const word = words[i]! + + if (word === '|' && PIPE_TAIL_HEADS.has(basename(words[i + 1] ?? ''))) { + break + } + + out.push(word) + } + + return out.join(' ').trim() +} + +function cleanSegment(segment: string): string { + const words = splitWords(segment) + const out: string[] = [] + + for (let i = 0; i < words.length; i += 1) { + const word = words[i]! + + if (/^\d*(?:>>?|<)$/.test(word)) { + i += 1 + + continue + } + + if (/^\d*(?:>&|<&)\d+$/.test(word) || /^\d*>&\d+$/.test(word)) { + continue + } + + out.push(word) + } + + return out.join(' ').trim() +} + +function isBoundaryEcho(segment: string): boolean { + const words = splitWords(segment) + + if (basename(words[0] ?? '') !== 'echo') { + return false + } + + // Banner/status echoes are UI plumbing. Do not treat arbitrary `echo $VALUE` + // as noise; it may be the command's actual output. + const rest = words.slice(1).join(' ') + + return /-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS/.test(rest) +} + +/** + * Reduce a verbose shell command to the "main" command, for display only. + * + * Agents wrap real work in plumbing — `cd && 2>&1 | tail -N; echo + * "x_exit=${PIPESTATUS[0]}"` — which buries the command the user actually cares + * about. This peels that wrapper off using small head-word allowlists instead of + * one giant regex: + * + * 1. split into segments on top-level `&&` `||` `;` (quote-aware) + * 2. strip trailing pipe tails (`| head`, `| tail`, `| wc`, ...) + * 3. clean env var prefixes / redirects + * 4. drop setup/banner/status segments + * + * If one real command survives, show it. If multiple real commands survive, + * show a short `first command + N commands` label instead of flooding the row + * with every probe. The full command is always still available via Copy/detail. + */ +export function summarizeShellCommand(raw: string): string { + const original = (raw ?? '').trim() + + if (!original) { + return '' + } + + const segments = splitCompoundCommand(original) + + if (segments.length <= 1) { + return cleanSegment(original) || original + } + + const core = segments.map(cleanSegment).filter(segment => { + const head = headWord(segment) + + return segment && !SILENT_HEADS.has(head) && !isBoundaryEcho(segment) + }) + + if (core.length === 0) { + return original + } + + if (core.length === 1) { + return core[0]! + } + + return `${core[0]} + ${core.length - 1} ${core.length === 2 ? 'command' : 'commands'}` +}