diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index 19e5a7ce..174343b7 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -48,6 +48,7 @@ describe('getToolGroupActionKind', () => { expect(getToolGroupActionKind(makeToolBlock('read-1', 'Read'))).toBe('read') expect(getToolGroupActionKind(makeToolBlock('grep-1', 'Grep'))).toBe('search') expect(getToolGroupActionKind(makeToolBlock('bash-1', 'Bash'))).toBe('command') + expect(getToolGroupActionKind(makeToolBlock('shell-1', 'run_shell_command'))).toBe('command') expect(getToolGroupActionKind(makeToolBlock('edit-1', 'Edit'))).toBe('mutation') }) }) diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index 7268aa8c..20a42c3c 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -97,7 +97,7 @@ export function getToolGroupActionKind(block: ToolCallBlock): ToolGroupActionKin if (name === 'Read' || name === 'NotebookRead') return 'read' if (name === 'Grep' || name === 'Glob' || name === 'LS') return 'search' - if (name === 'Bash' || name === 'CodexBash' || name === 'shell_command') return 'command' + if (name === 'Bash' || name === 'CodexBash' || name === 'shell_command' || name === 'run_shell_command') return 'command' if (name === 'Edit' || name === 'MultiEdit' || name === 'Write' || name === 'NotebookEdit' || name === 'CodexPatch' || name === 'CodexDiff') { return 'mutation' } diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index 7610cdca..7adfd28d 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -179,7 +179,6 @@ describe('ToolGroupCard', () => { expect(screen.getByText('Run 1 ยท Read 1')).toBeInTheDocument() expect(screen.queryByText('2 actions')).not.toBeInTheDocument() expect(screen.getByText('src/a.ts')).toBeInTheDocument() - expect(screen.getByText('Terminal')).toBeInTheDocument() expect(screen.getByText('bun test')).toBeInTheDocument() const firstRowButton = within(view.container) diff --git a/web/src/components/ToolCard/groupedPresentation.test.ts b/web/src/components/ToolCard/groupedPresentation.test.ts index f3135381..2453afba 100644 --- a/web/src/components/ToolCard/groupedPresentation.test.ts +++ b/web/src/components/ToolCard/groupedPresentation.test.ts @@ -45,7 +45,7 @@ function makeTool(id: string, name: string, input: unknown = {}): ToolCallBlock function makeGroup(tools: ToolCallBlock[]): ToolGroupBlock { const read = tools.filter((tool) => tool.tool.name === 'Read').length const search = tools.filter((tool) => tool.tool.name === 'Grep' || tool.tool.name === 'Glob').length - const command = tools.filter((tool) => tool.tool.name === 'Bash' || tool.tool.name === 'CodexBash' || tool.tool.name === 'shell_command').length + const command = tools.filter((tool) => tool.tool.name === 'Bash' || tool.tool.name === 'CodexBash' || tool.tool.name === 'shell_command' || tool.tool.name === 'run_shell_command').length const mutation = tools.filter((tool) => tool.tool.name === 'Edit' || tool.tool.name === 'Write' || tool.tool.name === 'MultiEdit').length const web = tools.filter((tool) => tool.tool.name === 'WebFetch' || tool.tool.name === 'WebSearch').length @@ -86,6 +86,10 @@ const tEn = makeTranslator(en as Dict) const tZh = makeTranslator(zhCN as Dict) describe('inferGroupedSummaryIntent', () => { + it('treats run_shell_command as a command intent', () => { + expect(inferGroupedSummaryIntent(makeTool('shell-1', 'run_shell_command', { command: 'bun test' }))).toBe('run-project-command') + }) + it('treats file inspection shell commands as inspect-files intent', () => { const tool = makeTool('shell-1', 'shell_command', { command: 'Get-ChildItem src -Recurse' }) expect(inferGroupedSummaryIntent(tool)).toBe('inspect-files') diff --git a/web/src/components/ToolCard/groupedPresentation.ts b/web/src/components/ToolCard/groupedPresentation.ts index 07c66691..0d2122e8 100644 --- a/web/src/components/ToolCard/groupedPresentation.ts +++ b/web/src/components/ToolCard/groupedPresentation.ts @@ -115,7 +115,7 @@ export function inferGroupedSummaryIntent(tool: ToolCallBlock): GroupedSummaryIn return 'open-web' } - if (toolName === 'Bash' || toolName === 'CodexBash' || toolName === 'shell_command') { + if (toolName === 'Bash' || toolName === 'CodexBash' || toolName === 'shell_command' || toolName === 'run_shell_command') { if (command && FILE_INSPECTION_COMMAND_RE.test(command)) { return 'inspect-files' } diff --git a/web/src/components/ToolCard/knownTools.test.tsx b/web/src/components/ToolCard/knownTools.test.tsx index a2f51f4b..774fa50a 100644 --- a/web/src/components/ToolCard/knownTools.test.tsx +++ b/web/src/components/ToolCard/knownTools.test.tsx @@ -1,5 +1,62 @@ import { describe, expect, it } from 'vitest' -import { getToolPresentation } from '@/components/ToolCard/knownTools' +import { formatTerminalCommandTitle, getToolPresentation } from '@/components/ToolCard/knownTools' + +describe('formatTerminalCommandTitle', () => { + it.each([ + ['bun run test --watch', 'bun run test'], + ['git status --short', 'git status'], + ['rg -n "foo" web/src', 'rg'], + ['sudo systemctl restart hapi', 'systemctl restart'], + ['CI=1 env NODE_ENV=test npm run lint -- --fix', 'npm run lint'], + ['/usr/bin/docker compose up -d', 'docker compose up'], + ['git -C /tmp/repo status', 'git'], + ['sudo -u root systemctl restart hapi', null], + ['bun test && bun typecheck', null], + ['', null], + ])('formats %j as %j', (command, expected) => { + expect(formatTerminalCommandTitle(command)).toBe(expected) + }) + + it.each(['Bash', 'CodexBash', 'shell_command', 'run_shell_command'])('uses the command fallback for %s', (toolName) => { + const presentation = getToolPresentation({ + toolName, + input: { command: 'git status --short' }, + result: null, + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.title).toBe('git status') + expect(presentation.subtitle).toBe('git status --short') + }) + + it('keeps the agent description authoritative', () => { + const presentation = getToolPresentation({ + toolName: 'Bash', + input: { command: 'git status --short' }, + result: null, + childrenCount: 0, + description: 'Inspect repository status', + metadata: null, + }) + + expect(presentation.title).toBe('Inspect repository status') + }) + + it('shortens a native title that only repeats the raw command', () => { + const presentation = getToolPresentation({ + toolName: 'run_shell_command', + input: { command: 'ls -la /tmp' }, + result: null, + childrenCount: 0, + description: 'ls -la /tmp', + metadata: null, + }) + + expect(presentation.title).toBe('ls') + }) +}) describe('getToolPresentation โ€” unknown tool semantic title + subtitle dedup', () => { it('promotes semantic title "Run shell" when toolName equals input.command (Gemini ACP case)', () => { @@ -44,7 +101,7 @@ describe('getToolPresentation โ€” unknown tool semantic title + subtitle dedup', expect(presentation.subtitle).toBe('*.ts') }) - it('keeps the original toolName when subtitle differs (no promotion needed)', () => { + it('uses a concise command title for run_shell_command', () => { const presentation = getToolPresentation({ toolName: 'run_shell_command', input: { command: 'ls -la /tmp' }, @@ -54,7 +111,7 @@ describe('getToolPresentation โ€” unknown tool semantic title + subtitle dedup', metadata: null, }) - expect(presentation.title).toBe('run_shell_command') + expect(presentation.title).toBe('ls') expect(presentation.subtitle).toBe('ls -la /tmp') }) diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 763ccbaa..fbe21f3a 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -19,6 +19,62 @@ import { const DEFAULT_ICON_CLASS = 'h-3.5 w-3.5' // Tool presentation registry for `hapi/web` (aligned with `hapi-app`). +const COMMANDS_WITH_SUBCOMMAND = new Set(['git', 'bun', 'npm', 'pnpm', 'yarn', 'docker', 'systemctl', 'cargo', 'go']) +const COMMAND_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/ +const AMBIGUOUS_SHELL_RE = /[;&|<>$`(){}\n\r]/ + +export function formatTerminalCommandTitle(command: string | null): string | null { + if (!command || AMBIGUOUS_SHELL_RE.test(command)) return null + + const parts = command.trim().split(/\s+/).filter(Boolean) + let index = 0 + while (COMMAND_ASSIGNMENT_RE.test(parts[index] ?? '')) index += 1 + + if (parts[index] === 'env') { + index += 1 + while (parts[index] === '-i' || parts[index] === '--ignore-environment' || COMMAND_ASSIGNMENT_RE.test(parts[index] ?? '')) index += 1 + } + if (parts[index] === 'sudo') { + index += 1 + while (parts[index] === '-n' || parts[index] === '--non-interactive' || parts[index] === '-E' || parts[index] === '--preserve-env') index += 1 + } + if (parts[index]?.startsWith('-')) return null + + const executable = parts[index] ? basename(parts[index]) : null + if (!executable) return null + + const subcommand = parts[index + 1]?.startsWith('-') ? null : parts[index + 1] + if (!subcommand || !COMMANDS_WITH_SUBCOMMAND.has(executable)) return executable + if ((executable === 'bun' || executable === 'npm' || executable === 'pnpm' || executable === 'yarn') && subcommand === 'run') { + const script = parts[index + 2] + return script && !script.startsWith('-') ? `${executable} run ${script}` : `${executable} run` + } + if (executable === 'docker' && subcommand === 'compose') { + const action = parts[index + 2] + return action && !action.startsWith('-') ? `docker compose ${action}` : 'docker compose' + } + return `${executable} ${subcommand}` +} + +function getTerminalCommand(input: unknown): string | null { + const command = getInputStringAny(input, ['command', 'cmd']) + if (command) return command + if (!isObject(input) || !Array.isArray(input.command)) return null + const parts = input.command.filter((part): part is string => typeof part === 'string') + return parts.length > 0 ? parts.join(' ') : null +} + +function getTerminalTitle(opts: ToolOpts): string { + const command = getTerminalCommand(opts.input) + if (opts.description && opts.description !== command) return opts.description + return formatTerminalCommandTitle(command) ?? opts.description ?? 'Terminal' +} + +function getTerminalSubtitle(opts: ToolOpts): string | null { + const command = getTerminalCommand(opts.input) + return command === getTerminalTitle(opts) ? null : command +} + export type ToolPresentation = { icon: ReactNode title: string @@ -116,8 +172,8 @@ export const knownTools: Record , - title: (opts) => opts.description ?? 'Terminal', - subtitle: (opts) => getInputStringAny(opts.input, ['command', 'cmd']), + title: getTerminalTitle, + subtitle: getTerminalSubtitle, minimal: true }, Glob: { @@ -158,16 +214,9 @@ export const knownTools: Record { - const command = getInputStringAny(opts.input, ['command', 'cmd']) - if (command) return command - if (isObject(opts.input) && Array.isArray(opts.input.command)) { - return opts.input.command.filter((part) => typeof part === 'string').join(' ') - } - return null + return getTerminalTitle(opts) }, + subtitle: getTerminalSubtitle, minimal: (opts) => { const result = isObject(opts.result) ? opts.result : null const stdout = result && typeof result.stdout === 'string' ? result.stdout.trim() : '' @@ -204,8 +253,14 @@ export const knownTools: Record , - title: (opts) => opts.description ?? 'Terminal', - subtitle: (opts) => getInputStringAny(opts.input, ['command', 'cmd']), + title: getTerminalTitle, + subtitle: getTerminalSubtitle, + minimal: true + }, + run_shell_command: { + icon: () => , + title: getTerminalTitle, + subtitle: getTerminalSubtitle, minimal: true }, Read: {