diff --git a/mobile/src/session/MobileNativeChatToolRun.tsx b/mobile/src/session/MobileNativeChatToolRun.tsx index db3ddbea063..6745570e334 100644 --- a/mobile/src/session/MobileNativeChatToolRun.tsx +++ b/mobile/src/session/MobileNativeChatToolRun.tsx @@ -14,9 +14,9 @@ import { describeActiveToolCall, formatActiveToolLabel, formatToolCallCount, - isCommandToolName, selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' +import { isShellActivityToolRow } from '../../../src/shared/native-chat-tool-icon' import type { NativeChatBlock } from '../../../src/shared/native-chat-types' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-message-styles' @@ -195,7 +195,11 @@ export function ToolRun({ } callCount ||= pairs.length const summary = summarizeToolRun(blocks) - const ActiveToolIcon = activeCall && isCommandToolName(activeCall.name) ? SquareTerminal : Wrench + // Read through the shared category vocabulary, not the tool name: Codex names + // a classified shell row `read`/`search`/`list`, and a wrench on one of those + // would call a command that ran a generic tool. + const ActiveToolIcon = + activeCall && isShellActivityToolRow(activeCall.name) ? SquareTerminal : Wrench return ( diff --git a/src/shared/native-chat-tool-icon.test.ts b/src/shared/native-chat-tool-icon.test.ts index 56464b0f347..2ff6091c54c 100644 --- a/src/shared/native-chat-tool-icon.test.ts +++ b/src/shared/native-chat-tool-icon.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + isShellActivityToolRow, NATIVE_CHAT_TOOL_ICON_NAMES, nativeChatToolCategory, nativeChatToolIconName, @@ -108,6 +109,44 @@ describe('native chat tool icons', () => { } }) + it('reads a classified shell row as terminal activity, not as a named tool', () => { + // A lane with only a terminal and a generic glyph (mobile) asks this instead + // of `isCommandToolName`, which answers false for the words Codex publishes + // for a command that really ran. + for (const word of ['read', 'search', 'list', 'shell', 'bash', 'run_terminal_cmd']) { + expect(isShellActivityToolRow(word)).toBe(true) + } + // Claude's own filesystem tools share those categories, so they read as + // terminal activity on that lane too — it has no closer glyph for them. + for (const word of ['Read', 'Grep', 'Glob']) { + expect(isShellActivityToolRow(word)).toBe(true) + } + for (const word of ['Edit', 'Diff', 'Task', 'WebFetch', 'TodoWrite', 'AskUserQuestion', '']) { + expect(isShellActivityToolRow(word)).toBe(false) + } + }) + + it('answers terminal activity for exactly the shell categories, and no others', () => { + // A `Record` of the whole vocabulary, so a category added later cannot join + // or leave the terminal set without this listing changing. + const rowWordByCategory: Record = { + read: 'read', + search: 'search', + listFiles: 'list', + unknown: 'shell', + fileChange: 'Edit', + webSearch: 'WebFetch', + mcpToolCall: 'mcp__linear__create_issue', + subAgentActivity: 'Task', + todoList: 'TodoWrite', + other: 'AskUserQuestion' + } + + expect( + ALL_CATEGORIES.filter((category) => isShellActivityToolRow(rowWordByCategory[category])) + ).toEqual(['read', 'search', 'listFiles', 'unknown']) + }) + it('does not answer a prototype key with a glyph', () => { expect(nativeChatToolCategory('__proto__')).toBeNull() expect(nativeChatToolCategory('constructor')).toBeNull() diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index 7b334bacd1d..288b5a47206 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -108,3 +108,22 @@ export function nativeChatToolCategory(rowWord: string): NativeChatToolCategory export function nativeChatToolIconName(rowWord: string): NativeChatToolIconName { return NATIVE_CHAT_TOOL_ICON_NAMES[nativeChatToolCategory(rowWord) ?? 'other'] } + +/** The categories a shell command produces: Codex's three classified classes, + * plus an unclassified one. Kept here rather than in a lane so the classified + * words and `shell` stay one answer. */ +const SHELL_ACTIVITY_CATEGORIES: ReadonlySet = new Set([ + 'read', + 'search', + 'listFiles', + 'unknown' +]) + +/** Whether a row reads as terminal activity. For a lane with no per-category + * glyph (mobile), which only chooses between a terminal and a generic tool: + * keying that on the tool name alone would call Codex's classified `read` / + * `search` / `list` rows generic tools, though a shell command produced them. */ +export function isShellActivityToolRow(rowWord: string): boolean { + const category = nativeChatToolCategory(rowWord) + return category !== null && SHELL_ACTIVITY_CATEGORIES.has(category) +}