feat(native-chat): give tool rows a category glyph beside their word

A row named only by a word makes the reader parse text to tell a read from
a search. Pair the word with an icon: icon for category, word for action,
argument for target.

Name the full eight-category vocabulary in `src/shared/native-chat-tool-icon.ts`
now — read/search/listFiles/unknown/fileChange/webSearch/mcpToolCall/
subAgentActivity — even though only the classified shell categories reach a row
today, so the MCP and web-search rows landing separately inherit these names
rather than coining their own. Glyph ids are the lucide spelling shared by
`lucide-react` and `lucide-react-native`, so mobile can resolve one name to its
own component when it adopts this; mobile rows stay text-only for now.

The glyph is decorative and `aria-hidden`: the word is the accessible name, and
never renders without it. One glyph per category, fixed across running,
completed, and failed — a row that swapped icons on completion would read as
changing identity — so the run header's active row also takes its category glyph
instead of the generic wrench it fell back to once these rows stopped being
called `shell`. A word outside the vocabulary gets the terminal glyph rather
than a blank slot, so rows stay left-aligned.

Also stand `.` in for a `listFiles` action whose `path` is null, which is what a
bare `ls` sends. The row named the action and then showed the raw argv as its
target; now it names the directory it listed.
This commit is contained in:
Merge Sim
2026-09-05 00:40:50 -07:00
parent 67cc962cb5
commit 40b4513498
7 changed files with 327 additions and 6 deletions
@@ -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'
})
})
@@ -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<string, string>
}
const COMMAND_ACTION_CLASSES = new Map<string, CommandActionClass>([
['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<string, string> = {}
const fields: Record<string, string> = { ...classified.fallback }
for (const key of classified.keys) {
const value = readString(record, key)
if (value !== null) {
@@ -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<NativeChatToolIconName, LucideIcon> = {
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 (
<span className={cn('flex size-4 shrink-0 items-center justify-center', className)}>
<Glyph aria-hidden className="size-3.5" />
</span>
)
}
@@ -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(<NativeChatToolRun blocks={blocks} expandSignal />)
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(
<NativeChatToolRun blocks={running} expandSignal activeTurnIsWorking />
)
expect(container.querySelector('.lucide-search')).toBeInTheDocument()
expect(container.querySelector('.lucide-wrench')).toBeNull()
rerender(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'search', input: { query: 'beta' }, state: 'completed' }
]}
expandSignal
activeTurnIsWorking={false}
/>
)
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(<NativeChatToolRun blocks={blocks} expandSignal />)
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(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(container.querySelector('.lucide-folder')).toBeInTheDocument()
expect(screen.getByTitle('.')).toHaveTextContent('.')
})
})
@@ -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. */}
<NativeChatToolIcon rowWord={name} className="text-muted-foreground" />
<code className="shrink-0 font-mono text-xs font-semibold text-foreground/90 transition-colors group-hover:text-foreground">
{name}
</code>
@@ -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')
+74
View File
@@ -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')
})
})
+72
View File
@@ -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<NativeChatToolCategory, NativeChatToolIconName> = {
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<string, NativeChatToolCategory>([
['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']
}