diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts index c469883079..36ca9a0ad6 100644 --- a/ui-tui/src/__tests__/constants.test.ts +++ b/ui-tui/src/__tests__/constants.test.ts @@ -20,9 +20,9 @@ describe('constants', () => { }) }) - it('TOOL_VERBS maps known tools', () => { - expect(TOOL_VERBS.terminal).toContain('terminal') - expect(TOOL_VERBS.read_file).toContain('reading') + it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => { + expect(TOOL_VERBS.terminal).toBe('terminal') + expect(TOOL_VERBS.read_file).toBe('reading') }) it('INTERPOLATION_RE matches {!cmd}', () => { diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 1d61b71b1f..55b6a272b3 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,21 +1,36 @@ import { describe, expect, it } from 'vitest' -import { fmtK, sameToolTrailGroup } from '../lib/text.js' +import { fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js' + +describe('isToolTrailResultLine', () => { + it('detects completion markers', () => { + expect(isToolTrailResultLine('foo ✓')).toBe(true) + expect(isToolTrailResultLine('foo ✗')).toBe(true) + expect(isToolTrailResultLine('drafting x…')).toBe(false) + }) +}) + +describe('lastCotTrailIndex', () => { + it('finds last non-result line', () => { + expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1) + expect(lastCotTrailIndex(['only result ✓'])).toBe(-1) + }) +}) describe('sameToolTrailGroup', () => { it('matches bare check lines', () => { - expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✓')).toBe(true) - expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✗')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching ✓')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching ✗')).toBe(true) }) it('matches contextual lines', () => { - expect(sameToolTrailGroup('🔍 searching', '🔍 searching: * ✓')).toBe(true) - expect(sameToolTrailGroup('🔍 searching', '🔍 searching: foo ✓')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching: * ✓')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching: foo ✓')).toBe(true) }) it('rejects other tools', () => { - expect(sameToolTrailGroup('🔍 searching', '📖 reading ✓')).toBe(false) - expect(sameToolTrailGroup('🔍 searching', '🔍 searching extra ✓')).toBe(false) + expect(sameToolTrailGroup('searching', 'reading ✓')).toBe(false) + expect(sameToolTrailGroup('searching', 'searching extra ✓')).toBe(false) }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 0a0ec2f030..06399ba7ad 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,7 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { compactPreview, fmtK, hasInterpolation, pick, sameToolTrailGroup } from './lib/text.js' +import { compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -363,6 +363,19 @@ export function App({ gw }: { gw: GatewayClient }) { }) }, []) + const pushTrail = useCallback((line: string) => { + setTurnTrail(prev => { + if (prev.at(-1) === line) { + return prev + } + + const next = [...prev, line].slice(-8) + turnToolsRef.current = next + + return next + }) + }, []) + const rpc = useCallback( (method: string, params: Record = {}) => gw.request(method, params).catch((e: Error) => { @@ -1067,6 +1080,13 @@ export function App({ gw }: { gw: GatewayClient }) { break + case 'tool.generating': + if (p?.name) { + pushTrail(`drafting ${p.name}…`) + } + + break + case 'tool.start': setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }]) @@ -1082,11 +1102,18 @@ export function App({ gw }: { gw: GatewayClient }) { const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` toolCompleteRibbonRef.current = { label, line } - const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) - turnToolsRef.current = next - setTurnTrail(next) + const remaining = prev.filter(t => t.id !== p.tool_id) + const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line] - return prev.filter(t => t.id !== p.tool_id) + if (!remaining.length) { + next.push('analyzing tool output…') + } + + const pruned = next.slice(-8) + turnToolsRef.current = pruned + setTurnTrail(pruned) + + return remaining }) break @@ -1148,7 +1175,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.complete': { const wasInterrupted = interruptedRef.current const savedReasoning = reasoningRef.current.trim() - const savedTools = [...turnToolsRef.current] + const savedTools = turnToolsRef.current.filter(isToolTrailResultLine) const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart() idle() @@ -1204,7 +1231,7 @@ export function App({ gw }: { gw: GatewayClient }) { break } }, - [appendMessage, dequeue, newSession, pushActivity, send, sys] + [appendMessage, dequeue, newSession, pushActivity, pushTrail, send, sys] ) onEventRef.current = onEvent @@ -1789,9 +1816,15 @@ export function App({ gw }: { gw: GatewayClient }) { ))} - + - {thinking && !tools.length && !streaming && } + {busy && !tools.length && !streaming && } {streaming && ( diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index be30a3dc4c..5dbcfdab47 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -3,16 +3,29 @@ import { memo, useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { FACES, TOOL_VERBS, VERBS } from '../constants.js' +import { isToolTrailResultLine, lastCotTrailIndex } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool } from '../types.js' +import type { ActiveTool, ActivityItem } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! +const tone = (item: ActivityItem, t: Theme) => + item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim + +const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '⚠' : '·') + +const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '} + export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { - const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL : THINK)]) + const [spin] = useState(() => { + const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] + + return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } + }) + const [frame, setFrame] = useState(0) useEffect(() => { @@ -27,30 +40,81 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: export const ToolTrail = memo(function ToolTrail({ t, tools = [], - trail = [] + trail = [], + activity = [], + animateCot = false }: { t: Theme tools?: ActiveTool[] trail?: string[] + activity?: ActivityItem[] + animateCot?: boolean }) { - if (!trail.length && !tools.length) { + if (!trail.length && !tools.length && !activity.length) { return null } + const act = activity.slice(-4) + const rowCount = trail.length + tools.length + act.length + const activeCotIdx = animateCot && !tools.length ? lastCotTrailIndex(trail) : -1 + return ( <> - {trail.map((line, i) => ( - - {t.brand.tool} {line} - - ))} + {trail.map((line, i) => { + const lastInBlock = i === rowCount - 1 - {tools.map(tool => ( - - {TOOL_VERBS[tool.name] ?? tool.name} - {tool.context ? `: ${tool.context}` : ''} - - ))} + if (isToolTrailResultLine(line)) { + return ( + + + {line} + + ) + } + + if (i === activeCotIdx) { + return ( + + + {line} + + ) + } + + return ( + + + {line} + + ) + })} + + {tools.map((tool, j) => { + const lastInBlock = trail.length + j === rowCount - 1 + + return ( + + + {TOOL_VERBS[tool.name] ?? tool.name} + {tool.context ? `: ${tool.context}` : ''} + + ) + })} + + {act.map((item, k) => { + const lastInBlock = trail.length + tools.length + k === rowCount - 1 + + return ( + + + {activityGlyph(item)} {item.text} + + ) + })} ) }) @@ -66,14 +130,18 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st const tail = reasoning.slice(-160).replace(/\n/g, ' ') - return tail ? ( - - 💭 {tail} - - ) : ( - - {FACES[tick % FACES.length] ?? '(•_•)'} {VERBS[tick % VERBS.length] ?? 'thinking'} - … - + return ( + <> + + {FACES[tick % FACES.length] ?? '(•_•)'}{' '} + {VERBS[tick % VERBS.length] ?? 'thinking'}… + + + {tail ? ( + + 💭 {tail} + + ) : null} + ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 2d755c3420..59fc639282 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -60,23 +60,24 @@ export const ROLE: Record { body: string; glyph: string; pre } export const TOOL_VERBS: Record = { - browser: '🌐 browsing', - clarify: '❓ asking', - create_file: '📝 creating', - delegate_task: '🤖 delegating', - delete_file: '🗑️ deleting', - execute_code: '⚡ executing', - image_generate: '🎨 generating', - list_files: '📂 listing', - memory: '🧠 remembering', - patch: '🩹 patching', - read_file: '📖 reading', - run_command: '⚙️ running', - search_code: '🔍 searching', - search_files: '🔍 searching', - terminal: '💻 terminal', - web_search: '🌐 searching', - write_file: '✏️ writing' + browser: 'browsing', + clarify: 'asking', + create_file: 'creating', + delegate_task: 'delegating', + delete_file: 'deleting', + execute_code: 'executing', + image_generate: 'generating', + list_files: 'listing', + memory: 'remembering', + patch: 'patching', + read_file: 'reading', + run_command: 'running', + search_code: 'searching', + search_files: 'searching', + terminal: 'terminal', + web_extract: 'extracting', + web_search: 'searching', + write_file: 'writing' } export const VERBS = [ diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 44d6a31522..ddb6f9fdd4 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -35,10 +35,24 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +/** Tool completed / failed row in the inline trail (not CoT prose). */ +export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') + /** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`) +/** Index of the last non-result trail line, or -1. */ +export const lastCotTrailIndex = (trail: readonly string[]) => { + for (let i = trail.length - 1; i >= 0; i--) { + if (!isToolTrailResultLine(trail[i]!)) { + return i + } + } + + return -1 +} + export const estimateRows = (text: string, w: number, compact = false) => { let inCode = false let rows = 0