diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index b3279193..3e47b96a 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -141,6 +141,33 @@ describe('isEligibleForToolGrouping', () => { }) }) +describe('Codex activity headings', () => { + it('associates only an immediately preceding reasoning heading', () => { + const reasoning = makeToolBlock('reasoning-1', 'CodexReasoning', { title: 'Inspecting authentication' }) + const visible = buildVisibleChatBlocks([ + reasoning, + makeToolBlock('read-1', 'Read', { file_path: 'auth.ts' }), + makeToolBlock('read-2', 'Read', { file_path: 'session.ts' }), + ], { hasMoreMessages: false }) + + expect(visible).toHaveLength(2) + expect(isToolGroupBlock(visible[1])).toBe(true) + expect(isToolGroupBlock(visible[1]) ? visible[1].activityTitle : null).toBe('Inspecting authentication') + }) + + it('does not carry a heading across a text boundary', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('reasoning-1', 'CodexReasoning', { title: 'Inspecting authentication' }), + makeTextBlock('text-boundary'), + makeToolBlock('read-1', 'Read', { file_path: 'auth.ts' }), + makeToolBlock('read-2', 'Read', { file_path: 'session.ts' }), + ], { hasMoreMessages: false }) + + const group = visible.find(isToolGroupBlock) + expect(group?.activityTitle).toBeNull() + }) +}) + describe('buildVisibleChatBlocks', () => { it('groups contiguous eligible root tool cards', () => { const visible = buildVisibleChatBlocks([ diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index aac6c675..1fe5dd78 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -30,6 +30,7 @@ export type ToolGroupBlock = { defaultOpen: boolean historyState: 'complete' | 'needs-older-history' needsOlderHistory: boolean + activityTitle?: string | null summary: ToolGroupSummary } @@ -258,6 +259,11 @@ export function buildVisibleChatBlocks( const startsAtOldestVisibleBoundary = visibleBlocks.length === 0 const needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary + const previousBlock = visibleBlocks.at(-1) + const activityTitle = previousBlock?.kind === 'tool-call' + && previousBlock.tool.name === 'CodexReasoning' + ? getInputStringAny(previousBlock.tool.input, ['title']) + : null visibleBlocks.push({ kind: 'tool-group', id: createToolGroupId(tools, needsOlderHistory, previousGroups), @@ -269,6 +275,7 @@ export function buildVisibleChatBlocks( defaultOpen: false, historyState: needsOlderHistory ? 'needs-older-history' : 'complete', needsOlderHistory, + activityTitle, summary: summarizeToolGroup(tools) }) index = cursor - 1 diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index df3e66e6..32e2f188 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -100,7 +100,7 @@ describe('ToolGroupCard', () => { it('renders a collapsed target-first header', () => { const view = renderCard(makeGroup()) - expect(screen.getByRole('button', { name: /inspect project files/i })).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByRole('button', { name: /inspect a\.ts/i })).toHaveAttribute('aria-expanded', 'false') expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument() expect(screen.getByText('2 actions')).toBeInTheDocument() expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument() @@ -111,7 +111,7 @@ describe('ToolGroupCard', () => { it('expands to show compact rows and opens a detail dialog per row', async () => { const view = renderCard(makeGroup()) - const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i }) expect(view.container.querySelector('svg[data-state="closed"]')).toBeInTheDocument() fireEvent.click(groupToggle) @@ -212,7 +212,7 @@ describe('ToolGroupCard', () => { } const view = render() - const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i }) fireEvent.click(groupToggle) @@ -272,7 +272,7 @@ describe('ToolGroupCard', () => { } const view = render() - const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i }) fireEvent.click(groupToggle) @@ -327,7 +327,7 @@ describe('ToolGroupCard', () => { } const view = render() - const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i }) fireEvent.click(groupToggle) diff --git a/web/src/components/ToolCard/groupedPresentation.test.ts b/web/src/components/ToolCard/groupedPresentation.test.ts index 6301d07f..f3135381 100644 --- a/web/src/components/ToolCard/groupedPresentation.test.ts +++ b/web/src/components/ToolCard/groupedPresentation.test.ts @@ -114,6 +114,87 @@ describe('formatGroupedRowLabel', () => { }) describe('formatGroupedHeaderTitle', () => { + it('uses an immediately preceding Codex activity heading', () => { + const group = makeGroup([ + makeTool('read-activity-1', 'Read', { file_path: 'auth.ts' }), + makeTool('read-activity-2', 'Read', { file_path: 'session.ts' }), + ]) + group.activityTitle = 'Inspecting the authentication flow' + + expect(formatGroupedHeaderTitle(group, tEn)).toBe('Inspecting the authentication flow') + }) + + it('uses a specific file target instead of the generic inspection label', () => { + const group = makeGroup([ + makeTool('read-1', 'Read', { file_path: '/repo/src/auth.ts' }), + makeTool('read-2', 'Read', { file_path: '/repo/src/session.ts' }), + ]) + + expect(formatGroupedHeaderTitle(group, tEn)).toBe('Inspect auth.ts') + }) + + it('uses a specific search pattern', () => { + const group = makeGroup([ + makeTool('grep-1', 'Grep', { pattern: 'authToken' }), + makeTool('grep-2', 'Grep', { pattern: 'authToken' }), + ]) + + expect(formatGroupedHeaderTitle(group, tEn)).toBe('Search “authToken”') + }) + + it('extracts safe targets from Codex inspection and search commands', () => { + const inspect = makeGroup([ + makeTool('inspect-command-1', 'shell_command', { command: "sed -n '1,120p' web/src/auth.ts" }), + makeTool('inspect-command-2', 'shell_command', { command: 'cat web/src/session.ts' }), + ]) + const search = makeGroup([ + makeTool('search-command-1', 'shell_command', { command: "rg 'authToken' web/src" }), + makeTool('search-command-2', 'shell_command', { command: "grep 'authToken' cli/src/index.ts" }), + ]) + + expect(formatGroupedHeaderTitle(inspect, tEn)).toBe('Inspect auth.ts') + expect(formatGroupedHeaderTitle(search, tEn)).toBe('Search “authToken”') + }) + + it('skips search option values and redacts common token prefixes', () => { + const optioned = makeGroup([ + makeTool('search-option-1', 'shell_command', { command: "rg -g '*.ts' authToken web/src" }), + makeTool('search-option-2', 'shell_command', { command: "grep -m 2 authToken cli/src" }), + ]) + const credential = makeGroup([ + makeTool('search-secret-1', 'Grep', { pattern: 'ghp_1234567890abcdefghijklmnop' }), + makeTool('search-secret-2', 'Grep', { pattern: 'ghp_1234567890abcdefghijklmnop' }), + ]) + + expect(formatGroupedHeaderTitle(optioned, tEn)).toBe('Search “authToken”') + expect(formatGroupedHeaderTitle(credential, tEn)).toBe('Search project content') + expect(formatGroupedHeaderTitle(credential, tEn)).not.toContain('ghp_') + }) + + it('uses a safe project command but hides arbitrary command text', () => { + const safe = makeGroup([ + makeTool('cmd-1', 'Bash', { command: 'bun test' }), + makeTool('cmd-2', 'Bash', { command: 'bun test' }), + ]) + const sensitive = makeGroup([ + makeTool('cmd-3', 'Bash', { command: 'curl -H "Authorization: Bearer abc" example.com' }), + makeTool('cmd-4', 'Bash', { command: 'curl example.com' }), + ]) + + expect(formatGroupedHeaderTitle(safe, tEn)).toBe('Run bun test') + expect(formatGroupedHeaderTitle(sensitive, tEn)).toBe('Run project commands') + expect(formatGroupedHeaderTitle(sensitive, tEn)).not.toContain('Bearer') + }) + + it('prefers a Claude call description and truncates long labels', () => { + const first = makeTool('cmd-description-1', 'Bash', { command: 'node script.js' }) + first.tool.description = 'Check the authentication migration behavior before applying changes' + const second = makeTool('cmd-description-2', 'Bash', { command: 'node other.js' }) + const group = makeGroup([first, second]) + + expect(formatGroupedHeaderTitle(group, tEn)).toBe('Check the authentication migration behavior before applying changes') + }) + it('uses the primary activity without an inline +n suffix', () => { const group = makeGroup([ makeTool('shell-1', 'shell_command', { command: 'Get-ChildItem src -Recurse' }), @@ -123,7 +204,7 @@ describe('formatGroupedHeaderTitle', () => { makeTool('shell-5', 'shell_command', { command: 'cat README.md' }), ]) - expect(formatGroupedHeaderTitle(group, tZh)).toBe('检查项目文件') + expect(formatGroupedHeaderTitle(group, tZh)).toBe('检查 src') }) it('uses a neutral title for all-generic tool groups', () => { diff --git a/web/src/components/ToolCard/groupedPresentation.ts b/web/src/components/ToolCard/groupedPresentation.ts index 74b1731b..939c2c0b 100644 --- a/web/src/components/ToolCard/groupedPresentation.ts +++ b/web/src/components/ToolCard/groupedPresentation.ts @@ -13,8 +13,58 @@ export type GroupedSummaryIntent = | 'generic-command' | 'generic-tool' -const FILE_INSPECTION_COMMAND_RE = /\b(get-childitem|ls|dir|get-content|cat|type|tree)\b/i +const FILE_INSPECTION_COMMAND_RE = /\b(get-childitem|ls|dir|get-content|cat|type|tree)\b|\bsed\s+-n\b/i const CONTENT_SEARCH_COMMAND_RE = /\b(rg|grep|select-string|findstr)\b/i +const SAFE_PROJECT_COMMAND_RE = /^(?:(?:bun|npm|pnpm|yarn) (?:run )?(?:test|lint|build|typecheck)(?:[:\w.-]*)|git (?:status|diff|log)(?:\s+--?[\w.-]+)*|cargo (?:test|check)|go test(?:\s+\.\/\.\.\.)?|pytest(?:\s+-[\w-]+)*)$/i +const SENSITIVE_TEXT_RE = /(?:bearer\s+\S+|(?:api[_-]?key|token|password|secret)(?:\s*[:=]\s*\S+|\s+\S{12,})|(?:gh[pousr]_|github_pat_|sk-[a-z0-9_-]*|xox[baprs]-)[a-z0-9_-]{12,}|[a-f0-9]{32,}|[a-z0-9_+/=-]{40,})/i +const SEARCH_OPTIONS_WITH_VALUE = new Set([ + '-g', '--glob', '-t', '--type', '-A', '-B', '-C', '--context', + '--before-context', '--after-context', '-m', '--max-count' +]) +const MAX_SPECIFIC_LABEL_LENGTH = 72 + +function truncateLabel(value: string): string { + const normalized = value.replace(/\s+/g, ' ').trim() + return normalized.length > MAX_SPECIFIC_LABEL_LENGTH + ? `${normalized.slice(0, MAX_SPECIFIC_LABEL_LENGTH - 1)}…` + : normalized +} + +function safeLabelValue(value: string | null): string | null { + if (!value || SENSITIVE_TEXT_RE.test(value)) return null + return truncateLabel(value) +} + +function basename(value: string): string { + const parts = value.replace(/\\/g, '/').split('/').filter(Boolean) + return parts.at(-1) ?? value +} + +function simpleCommandParts(command: string): string[] | null { + if (/[;&|<>$`(){}\n\r]/.test(command)) return null + return command.trim().split(/\s+/).filter(Boolean) +} + +function getInspectionCommandTarget(command: string): string | null { + const parts = simpleCommandParts(command) + if (!parts || parts.length < 2) return null + const target = [...parts].reverse().find((part) => !part.startsWith('-') && !/^['"]?\d+(?:,\d+)?p['"]?$/.test(part)) + if (!target || target === parts[0]) return null + return safeLabelValue(target.replace(/^['"]|['"]$/g, '')) +} + +function getSearchCommandPattern(command: string): string | null { + const parts = simpleCommandParts(command) + if (!parts) return null + const executableIndex = parts.findIndex((part) => /^(?:rg|grep|select-string|findstr)$/i.test(part)) + if (executableIndex < 0) return null + for (let index = executableIndex + 1; index < parts.length; index += 1) { + const part = parts[index] + if (!part.startsWith('-')) return safeLabelValue(part.replace(/^['"]|['"]$/g, '')) + if (SEARCH_OPTIONS_WITH_VALUE.has(part)) index += 1 + } + return null +} function getCommandText(input: unknown): string | null { const direct = getInputStringAny(input, ['command', 'cmd']) @@ -103,8 +153,57 @@ function getPrimaryIntent(block: ToolGroupBlock): GroupedSummaryIntent { return primary } +function formatSpecificIntentTitle(block: ToolGroupBlock, intent: GroupedSummaryIntent, t: Translator): string | null { + const matching = block.tools.filter((tool) => inferGroupedSummaryIntent(tool) === intent) + const described = matching + .map((tool) => safeLabelValue(tool.tool.description)) + .find((value): value is string => value !== null) + if (described) return described + + if (intent === 'inspect-files' || intent === 'modify-files') { + for (const tool of matching) { + const target = safeLabelValue(getInputStringAny(tool.tool.input, ['file_path', 'path', 'file', 'filePath', 'notebook_path'])) + if (target) { + return t(intent === 'modify-files' ? 'toolGroup.friendly.editTarget' : 'toolGroup.friendly.inspectTarget', { + target: basename(target) + }) + } + if (intent === 'inspect-files') { + const command = getCommandText(tool.tool.input) + const commandTarget = command ? getInspectionCommandTarget(command) : null + if (commandTarget) return t('toolGroup.friendly.inspectTarget', { target: basename(commandTarget) }) + } + } + } + + if (intent === 'search-content') { + for (const tool of matching) { + const pattern = safeLabelValue(getInputStringAny(tool.tool.input, ['pattern', 'query'])) + if (pattern) return t('toolGroup.friendly.searchTarget', { target: pattern }) + const command = getCommandText(tool.tool.input) + const commandPattern = command ? getSearchCommandPattern(command) : null + if (commandPattern) return t('toolGroup.friendly.searchTarget', { target: commandPattern }) + } + } + + if (intent === 'run-project-command') { + for (const tool of matching) { + const command = safeLabelValue(getCommandText(tool.tool.input)) + if (command && SAFE_PROJECT_COMMAND_RE.test(command)) { + return t('toolGroup.friendly.runTarget', { target: command }) + } + } + } + + return null +} + export function formatGroupedHeaderTitle(block: ToolGroupBlock, t: Translator): string { + const activityTitle = safeLabelValue(block.activityTitle ?? null) + if (activityTitle) return activityTitle const primaryIntent = getPrimaryIntent(block) + const specificTitle = formatSpecificIntentTitle(block, primaryIntent, t) + if (specificTitle) return specificTitle if (primaryIntent === 'generic-tool') { return t('toolGroup.title') } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 1a656a70..e36ca7a0 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -442,6 +442,10 @@ export default { 'tool.requestUserInput.notePlaceholder': 'Add a note…', 'tool.requestUserInput.popupBlocked': 'Could not open the sign-in page. Allow popups and try again.', 'toolGroup.title': 'Tool activity', + 'toolGroup.friendly.inspectTarget': 'Inspect {target}', + 'toolGroup.friendly.searchTarget': 'Search “{target}”', + 'toolGroup.friendly.runTarget': 'Run {target}', + 'toolGroup.friendly.editTarget': 'Edit {target}', 'toolGroup.primary.fileTargets': '{target} +{n}', 'toolGroup.primary.commandTargets': '{target} +{n}', 'toolGroup.primary.searchTargets': '{target} +{n}', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index b69377d9..8ab241eb 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -446,6 +446,10 @@ export default { 'tool.requestUserInput.notePlaceholder': '添加备注…', 'tool.requestUserInput.popupBlocked': '无法打开登录页面。请允许弹出窗口后重试。', 'toolGroup.title': '工具活动', + 'toolGroup.friendly.inspectTarget': '检查 {target}', + 'toolGroup.friendly.searchTarget': '搜索“{target}”', + 'toolGroup.friendly.runTarget': '运行 {target}', + 'toolGroup.friendly.editTarget': '编辑 {target}', 'toolGroup.primary.fileTargets': '{target} 等 +{n}', 'toolGroup.primary.commandTargets': '{target} 等 +{n}', 'toolGroup.primary.searchTargets': '{target} 等 +{n}',