mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): label terminal cards with concise command names (#1201)
* fix(web): label terminal cards with command names * fix(web): honor concise titles across shell cards
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, {
|
||||
},
|
||||
Bash: {
|
||||
icon: () => <TerminalIcon className={DEFAULT_ICON_CLASS} />,
|
||||
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<string, {
|
||||
return resolveDisplayPath(parsed.name, opts.metadata)
|
||||
}
|
||||
}
|
||||
return opts.description ?? 'Terminal'
|
||||
},
|
||||
subtitle: (opts) => {
|
||||
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<string, {
|
||||
},
|
||||
shell_command: {
|
||||
icon: () => <TerminalIcon className={DEFAULT_ICON_CLASS} />,
|
||||
title: (opts) => opts.description ?? 'Terminal',
|
||||
subtitle: (opts) => getInputStringAny(opts.input, ['command', 'cmd']),
|
||||
title: getTerminalTitle,
|
||||
subtitle: getTerminalSubtitle,
|
||||
minimal: true
|
||||
},
|
||||
run_shell_command: {
|
||||
icon: () => <TerminalIcon className={DEFAULT_ICON_CLASS} />,
|
||||
title: getTerminalTitle,
|
||||
subtitle: getTerminalSubtitle,
|
||||
minimal: true
|
||||
},
|
||||
Read: {
|
||||
|
||||
Reference in New Issue
Block a user