diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 16fff5455f4..57cbfcc6982 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -265,7 +265,7 @@ describe('codex item bodies', () => { }) }) - it('names a classified listFiles command `list`', () => { + it('names a classified listFiles command `list` and stands `.` in for a null path', () => { expect( codexItemBody({ type: 'commandExecution', @@ -279,7 +279,40 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'list', - input: { command: 'ls', cwd: '/repo' }, + input: { command: 'ls', cwd: '/repo', path: '.' }, + state: 'completed' + }) + }) + + it('keeps the listed directory when listFiles carries one', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-list-path', + command: 'ls src', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'listFiles', command: 'ls src', path: 'src' }] + }) + ).toMatchObject({ name: 'list', input: { path: 'src' } }) + }) + + it('leaves the other classes without a stand-in target', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-read-null', + command: 'cat', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'read', command: 'cat', path: null, name: null }] + }) + ).toEqual({ + kind: 'tool-call', + name: 'read', + input: { command: 'cat', cwd: '/repo' }, state: 'completed' }) }) diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index e82ba7a3d23..ef54d595467 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -182,10 +182,19 @@ export type CodexJournalItem = { * A `Map`, not an object — an object index answers `__proto__` with a truthy * non-string. Every other action type stays an unclassified `shell` row. */ -const COMMAND_ACTION_CLASSES = new Map([ +type CommandActionClass = { + name: string + keys: readonly string[] + /** Stand-in target when Codex classifies the command but sends no field for it. */ + fallback?: Record +} + +const COMMAND_ACTION_CLASSES = new Map([ ['read', { name: 'read', keys: ['path', 'name'] }], ['search', { name: 'search', keys: ['query', 'path'] }], - ['listFiles', { name: 'list', keys: ['path'] }] + // A bare `ls` arrives with `path: null`; its target is the cwd, so say so + // rather than leaving the row as a word with no argument. + ['listFiles', { name: 'list', keys: ['path'], fallback: { path: '.' } }] ]) /** The first classified `commandActions` entry; null leaves the row exactly as @@ -204,7 +213,7 @@ function commandActionFacts( if (classified === undefined) { continue } - const fields: Record = {} + const fields: Record = { ...classified.fallback } for (const key of classified.keys) { const value = readString(record, key) if (value !== null) { diff --git a/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx new file mode 100644 index 00000000000..7d222f4d474 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx @@ -0,0 +1,51 @@ +import { Bot, Eye, Folder, Globe, Pencil, Plug, Search, SquareTerminal } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + NATIVE_CHAT_TOOL_ICON_NAMES, + nativeChatToolIconName, + type NativeChatToolCategory, + type NativeChatToolIconName +} from '../../../../shared/native-chat-tool-icon' + +/** Glyph name to component. Exported so a call site that places the icon itself + * (the run header's active slot) resolves it by lookup, not by constructing a + * component mid-render. */ +export const NATIVE_CHAT_TOOL_GLYPHS: Record = { + eye: Eye, + search: Search, + folder: Folder, + 'square-terminal': SquareTerminal, + pencil: Pencil, + globe: Globe, + plug: Plug, + bot: Bot +} + +/** + * The category glyph on a tool row. Decorative — the word beside it is the + * accessible name — so it is `aria-hidden` and must never render without that + * word. Fixed 16px slot with a 14px glyph keeps every row left-aligned, + * including rows whose category this vocabulary doesn't model. + */ +export function NativeChatToolIcon({ + rowWord, + category, + className +}: { + /** The word the row renders. Ignored when `category` is given. */ + rowWord: string + /** For rows named after a tool or agent rather than their category. */ + category?: NativeChatToolCategory + className?: string +}): React.JSX.Element { + const Glyph = + NATIVE_CHAT_TOOL_GLYPHS[ + category ? NATIVE_CHAT_TOOL_ICON_NAMES[category] : nativeChatToolIconName(rowWord) + ] + return ( + + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index 965ed0ad176..960941faa8a 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -213,4 +213,72 @@ describe('NativeChatToolRun', () => { expect(container.querySelector('.lucide-check')).toBeInTheDocument() expect(container.querySelector('.lucide-circle-alert')).toBeNull() }) + + it('shows the category glyph beside the word a classified row is named by', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'read', + input: { command: "sed -n '1,200p' notes.txt", path: 'notes.txt' }, + state: 'completed' + } + ] + + const { container } = render() + + const glyph = container.querySelector('.lucide-eye') + expect(glyph).toBeInTheDocument() + expect(glyph).toHaveAttribute('aria-hidden') + expect(screen.getByText('read')).toBeInTheDocument() + }) + + it('keeps one glyph for a category while it runs and once it completes', () => { + const running: NativeChatBlock[] = [ + { type: 'tool-call', name: 'search', input: { query: 'beta' }, state: 'running' } + ] + const { container, rerender } = render( + + ) + + expect(container.querySelector('.lucide-search')).toBeInTheDocument() + expect(container.querySelector('.lucide-wrench')).toBeNull() + + rerender( + + ) + + expect(container.querySelector('.lucide-search')).toBeInTheDocument() + }) + + it('falls back to the terminal glyph rather than an empty slot for an unmodelled row', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'apply_patch', input: { command: 'apply' }, state: 'completed' } + ] + + const { container } = render() + + expect(container.querySelector('.lucide-square-terminal')).toBeInTheDocument() + }) + + it('gives the bare list row a `.` argument instead of a lone word', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'list', + input: { command: 'ls', cwd: '/repo', path: '.' }, + state: 'completed' + } + ] + + const { container } = render() + + expect(container.querySelector('.lucide-folder')).toBeInTheDocument() + expect(screen.getByTitle('.')).toHaveTextContent('.') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index f16d01331f8..ed0efda1ced 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -2,6 +2,10 @@ import { useEffect, useState } from 'react' import { Check, ChevronRight, SquareTerminal, Wrench } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { + NATIVE_CHAT_TOOL_ICON_NAMES, + nativeChatToolCategory +} from '../../../../shared/native-chat-tool-icon' import { isToolCallBlock, isToolResultBlock, @@ -15,6 +19,7 @@ import { truncateToolDetail } from './native-chat-tool-summary' import { NativeChatDiffView } from './NativeChatDiffView' +import { NATIVE_CHAT_TOOL_GLYPHS, NativeChatToolIcon } from './NativeChatToolIcon' const COMMAND_TOOL_NAMES = new Set([ 'bash', @@ -106,6 +111,8 @@ function ToolLine({ )} aria-expanded={hasDetail ? expanded : undefined} > + {/* Decorative category glyph; the word beside it is the row's name. */} + {name} @@ -189,10 +196,17 @@ export function NativeChatToolRun({ // The turn caret opens the activity group, while each child tool remains // collapsed. The global expand toolbar still opens child details together. const expandToolLines = expandOverride === undefined ? open : false - const ActiveToolIcon = + const activeCategory = latestActiveCall ? nativeChatToolCategory(latestActiveCall.name) : null + const uncategorizedActiveIcon = latestActiveCall && COMMAND_TOOL_NAMES.has(normalizedToolName(latestActiveCall.name)) ? SquareTerminal : Wrench + // A classified row keeps its category glyph while it runs, so the icon does not + // change when the row settles; anything else keeps the generic tool icon. + const ActiveToolIcon = + activeCategory === null + ? uncategorizedActiveIcon + : NATIVE_CHAT_TOOL_GLYPHS[NATIVE_CHAT_TOOL_ICON_NAMES[activeCategory]] const fallbackLabel = callCount === 1 ? translate('components.native-chat.tool.countOne', '1 tool call') diff --git a/src/shared/native-chat-tool-icon.test.ts b/src/shared/native-chat-tool-icon.test.ts new file mode 100644 index 00000000000..0c27774af12 --- /dev/null +++ b/src/shared/native-chat-tool-icon.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { + NATIVE_CHAT_TOOL_ICON_NAMES, + nativeChatToolCategory, + nativeChatToolIconName, + type NativeChatToolCategory +} from './native-chat-tool-icon' + +const ALL_CATEGORIES: NativeChatToolCategory[] = [ + 'read', + 'search', + 'listFiles', + 'unknown', + 'fileChange', + 'webSearch', + 'mcpToolCall', + 'subAgentActivity' +] + +describe('native chat tool icons', () => { + it('names a glyph for every category in the vocabulary', () => { + expect(NATIVE_CHAT_TOOL_ICON_NAMES).toEqual({ + read: 'eye', + search: 'search', + listFiles: 'folder', + unknown: 'square-terminal', + fileChange: 'pencil', + webSearch: 'globe', + mcpToolCall: 'plug', + subAgentActivity: 'bot' + }) + expect(Object.keys(NATIVE_CHAT_TOOL_ICON_NAMES).sort()).toEqual([...ALL_CATEGORIES].sort()) + }) + + it('gives each category a distinct glyph so rows are told apart by icon', () => { + const glyphs = ALL_CATEGORIES.map((category) => NATIVE_CHAT_TOOL_ICON_NAMES[category]) + expect(new Set(glyphs).size).toBe(glyphs.length) + }) + + it('maps the row words the lanes render to their category', () => { + expect(nativeChatToolCategory('read')).toBe('read') + expect(nativeChatToolCategory('search')).toBe('search') + expect(nativeChatToolCategory('list')).toBe('listFiles') + expect(nativeChatToolCategory('shell')).toBe('unknown') + expect(nativeChatToolCategory('edit')).toBe('fileChange') + expect(nativeChatToolCategory('web search')).toBe('webSearch') + }) + + it('resolves the glyph for each classified row word', () => { + expect(nativeChatToolIconName('read')).toBe('eye') + expect(nativeChatToolIconName('search')).toBe('search') + expect(nativeChatToolIconName('list')).toBe('folder') + expect(nativeChatToolIconName('shell')).toBe('square-terminal') + expect(nativeChatToolIconName('edit')).toBe('pencil') + expect(nativeChatToolIconName('web search')).toBe('globe') + }) + + it('reads a row word regardless of case or surrounding space', () => { + expect(nativeChatToolIconName(' Read ')).toBe('eye') + expect(nativeChatToolIconName('WebSearch')).toBe('globe') + }) + + it('falls back to the terminal glyph for a word outside the vocabulary', () => { + expect(nativeChatToolCategory('apply_patch')).toBeNull() + expect(nativeChatToolIconName('apply_patch')).toBe('square-terminal') + expect(nativeChatToolIconName('')).toBe('square-terminal') + }) + + it('does not answer a prototype key with a glyph', () => { + expect(nativeChatToolCategory('__proto__')).toBeNull() + expect(nativeChatToolCategory('constructor')).toBeNull() + expect(nativeChatToolIconName('__proto__')).toBe('square-terminal') + }) +}) diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts new file mode 100644 index 00000000000..734e0e088c3 --- /dev/null +++ b/src/shared/native-chat-tool-icon.ts @@ -0,0 +1,72 @@ +/** + * The category vocabulary for native-chat tool rows, and the one glyph each + * category keeps. A row is `icon + word + argument`: the icon is decorative and + * the word carries identity, so a renderer must never draw the glyph alone. + * + * The glyph is fixed per category across running/completed/failed — only tone + * changes, plus a trailing mark on failure. A row that swapped glyphs when it + * finished would read as changing identity. + */ +export type NativeChatToolCategory = + | 'read' + | 'search' + | 'listFiles' + | 'unknown' + | 'fileChange' + | 'webSearch' + | 'mcpToolCall' + | 'subAgentActivity' + +/** lucide glyph ids. Spelled the same by `lucide-react` and `lucide-react-native`, + * so desktop and mobile can resolve one name to their own component. */ +export type NativeChatToolIconName = + | 'eye' + | 'search' + | 'folder' + | 'square-terminal' + | 'pencil' + | 'globe' + | 'plug' + | 'bot' + +/** Category to glyph. All eight are named now, though only the classified shell + * categories reach a row today; MCP and web-search rows land separately. */ +export const NATIVE_CHAT_TOOL_ICON_NAMES: Record = { + read: 'eye', + search: 'search', + listFiles: 'folder', + unknown: 'square-terminal', + fileChange: 'pencil', + webSearch: 'globe', + mcpToolCall: 'plug', + subAgentActivity: 'bot' +} + +/** + * Row word to category. Keyed by the word a lane actually renders, not by the + * protocol type, because that word is all a row model carries. `mcpToolCall` and + * `subAgentActivity` are absent by design — those rows are named after the tool + * or the agent, so their renderer passes the category itself. + * A `Map`, not an object: an object index answers `__proto__` with a truthy value. + */ +const CATEGORY_BY_ROW_WORD = new Map([ + ['read', 'read'], + ['search', 'search'], + ['list', 'listFiles'], + ['shell', 'unknown'], + ['edit', 'fileChange'], + ['web search', 'webSearch'], + ['websearch', 'webSearch'] +]) + +/** The category a row word names, or null when the lane emitted something this + * vocabulary doesn't model yet. */ +export function nativeChatToolCategory(rowWord: string): NativeChatToolCategory | null { + return CATEGORY_BY_ROW_WORD.get(rowWord.trim().toLowerCase()) ?? null +} + +/** The glyph for a row word. Never empty: an unmodelled word gets the terminal + * glyph so rows stay left-aligned when a lane ships a type we don't name. */ +export function nativeChatToolIconName(rowWord: string): NativeChatToolIconName { + return NATIVE_CHAT_TOOL_ICON_NAMES[nativeChatToolCategory(rowWord) ?? 'unknown'] +}