fix: sexier cots

This commit is contained in:
Brooklyn Nicholson 2026-04-09 18:33:25 -05:00
parent 6e24b9947e
commit 7e813a30e0
6 changed files with 191 additions and 60 deletions

View file

@ -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}', () => {

View file

@ -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)
})
})

View file

@ -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<string, unknown> = {}) =>
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 }) {
))}
<Box flexDirection="column" paddingX={1}>
<ToolTrail t={theme} tools={tools} trail={turnTrail} />
<ToolTrail
activity={busy ? activity : []}
animateCot={busy && !streaming}
t={theme}
tools={tools}
trail={turnTrail}
/>
{thinking && !tools.length && !streaming && <Thinking key={turnKey} reasoning={reasoning} t={theme} />}
{busy && !tools.length && !streaming && <Thinking key={turnKey} reasoning={reasoning} t={theme} />}
{streaming && (
<MessageLine cols={cols} compact={compact} msg={{ role: 'assistant', text: streaming }} t={theme} />

View file

@ -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 = <T,>(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 }) => <Text dimColor>{last ? '└─ ' : '├─ '}</Text>
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) => (
<Text color={line.endsWith(' ✗') ? t.color.error : t.color.dim} dimColor={!line.endsWith(' ✗')} key={`t-${i}`}>
{t.brand.tool} {line}
</Text>
))}
{trail.map((line, i) => {
const lastInBlock = i === rowCount - 1
{tools.map(tool => (
<Text color={t.color.dim} key={tool.id}>
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
{tool.context ? `: ${tool.context}` : ''}
</Text>
))}
if (isToolTrailResultLine(line)) {
return (
<Text
color={line.endsWith(' ✗') ? t.color.error : t.color.dim}
dimColor={!line.endsWith(' ✗')}
key={`t-${i}`}
>
<TreeFork last={lastInBlock} />
{line}
</Text>
)
}
if (i === activeCotIdx) {
return (
<Text color={t.color.dim} key={`c-${i}`}>
<TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="think" /> {line}
</Text>
)
}
return (
<Text color={t.color.dim} dimColor key={`c-${i}`}>
<TreeFork last={lastInBlock} />
{line}
</Text>
)
})}
{tools.map((tool, j) => {
const lastInBlock = trail.length + j === rowCount - 1
return (
<Text color={t.color.dim} key={tool.id}>
<TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
{tool.context ? `: ${tool.context}` : ''}
</Text>
)
})}
{act.map((item, k) => {
const lastInBlock = trail.length + tools.length + k === rowCount - 1
return (
<Text color={tone(item, t)} dimColor={item.tone === 'info'} key={`a-${item.id}`}>
<TreeFork last={lastInBlock} />
{activityGlyph(item)} {item.text}
</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 ? (
<Text color={t.color.dim} dimColor wrap="truncate-end">
💭 {tail}
</Text>
) : (
<Text color={t.color.dim}>
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'} {VERBS[tick % VERBS.length] ?? 'thinking'}
</Text>
return (
<>
<Text color={t.color.dim}>
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'}{' '}
{VERBS[tick % VERBS.length] ?? 'thinking'}
</Text>
{tail ? (
<Text color={t.color.dim} dimColor wrap="truncate-end">
💭 {tail}
</Text>
) : null}
</>
)
})

View file

@ -60,23 +60,24 @@ export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; pre
}
export const TOOL_VERBS: Record<string, string> = {
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 = [

View file

@ -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