mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
}
|
||||
Reference in New Issue
Block a user