diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 726ea9f9c..0a0ec2f03 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -6,7 +6,6 @@ import { join } from 'node:path' import { Box, Text, useApp, useInput, useStdout } from 'ink' import { useCallback, useEffect, useRef, useState } from 'react' -import { ActivityLane } from './components/activityLane.js' import { Banner, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' @@ -14,7 +13,7 @@ import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { type PasteEvent, TextInput } from './components/textInput.js' -import { Thinking } from './components/thinking.js' +import { Thinking, ToolTrail } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import { useCompletion } from './hooks/useCompletion.js' @@ -278,6 +277,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [pastes, setPastes] = useState([]) const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null) const [streaming, setStreaming] = useState('') + const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) @@ -374,6 +374,7 @@ export function App({ gw }: { gw: GatewayClient }) { const idle = () => { setThinking(false) setTools([]) + setTurnTrail([]) setBusy(false) setClarify(null) setApproval(null) @@ -1005,6 +1006,7 @@ export function App({ gw }: { gw: GatewayClient }) { setBusy(true) setReasoning('') setActivity([]) + setTurnTrail([]) turnToolsRef.current = [] break @@ -1021,7 +1023,11 @@ export function App({ gw }: { gw: GatewayClient }) { p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' ) } - if (statusTimerRef.current) clearTimeout(statusTimerRef.current) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + statusTimerRef.current = setTimeout(() => { statusTimerRef.current = null setStatus(busyRef.current ? 'running…' : 'ready') @@ -1067,7 +1073,6 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.complete': { const mark = p.error ? '✗' : '✓' - const tone = p.error ? 'error' : 'info' toolCompleteRibbonRef.current = null setTools(prev => { @@ -1077,16 +1082,13 @@ export function App({ gw }: { gw: GatewayClient }) { const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` toolCompleteRibbonRef.current = { label, line } - turnToolsRef.current = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) + const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) + turnToolsRef.current = next + setTurnTrail(next) return prev.filter(t => t.id !== p.tool_id) }) - if (toolCompleteRibbonRef.current) { - const { line, label } = toolCompleteRibbonRef.current - pushActivity(line, tone, label) - } - break } @@ -1787,16 +1789,14 @@ export function App({ gw }: { gw: GatewayClient }) { ))} + + + {thinking && !tools.length && !streaming && } + {streaming && ( )} - {(thinking || tools.length > 0) && (!streaming || tools.length > 0) && ( - - )} - - {busy && } - {pasteReview && ( diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 76d0d1743..91d1fe8c3 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -7,6 +7,7 @@ import type { Theme } from '../theme.js' import type { Msg } from '../types.js' import { Md } from './markdown.js' +import { ToolTrail } from './thinking.js' export const MessageLine = memo(function MessageLine({ cols, @@ -19,8 +20,6 @@ export const MessageLine = memo(function MessageLine({ msg: Msg t: Theme }) { - const { body, glyph, prefix } = ROLE[msg.role](t) - if (msg.role === 'tool') { return ( @@ -29,6 +28,8 @@ export const MessageLine = memo(function MessageLine({ ) } + const { body, glyph, prefix } = ROLE[msg.role](t) + const content = (() => { if (msg.role === 'assistant') { return hasAnsi(msg.text) ? {msg.text} : @@ -59,6 +60,12 @@ export const MessageLine = memo(function MessageLine({ )} + {msg.tools?.length ? ( + + + + ) : null} + @@ -68,20 +75,6 @@ export const MessageLine = memo(function MessageLine({ {content} - - {!!msg.tools?.length && ( - - {msg.tools.map((tool, i) => ( - - {t.brand.tool} {tool} - - ))} - - )} ) }) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index b2aff0355..be30a3dc4 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -6,13 +6,13 @@ import { FACES, TOOL_VERBS, VERBS } from '../constants.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -const THINK_POOL: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] -const TOOL_POOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] +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)]! -function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { - const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL_POOL : THINK_POOL)]) +export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { + const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL : THINK)]) const [frame, setFrame] = useState(0) useEffect(() => { @@ -24,15 +24,38 @@ function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think return {spin.frames[frame]} } -export const Thinking = memo(function Thinking({ - reasoning, +export const ToolTrail = memo(function ToolTrail({ t, - tools + tools = [], + trail = [] }: { - reasoning: string t: Theme - tools: ActiveTool[] + tools?: ActiveTool[] + trail?: string[] }) { + if (!trail.length && !tools.length) { + return null + } + + return ( + <> + {trail.map((line, i) => ( + + {t.brand.tool} {line} + + ))} + + {tools.map(tool => ( + + {TOOL_VERBS[tool.name] ?? tool.name} + {tool.context ? `: ${tool.context}` : ''} + + ))} + + ) +}) + +export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) { const [tick, setTick] = useState(0) useEffect(() => { @@ -41,31 +64,16 @@ export const Thinking = memo(function Thinking({ return () => clearInterval(id) }, []) - const verb = VERBS[tick % VERBS.length] ?? 'thinking' - const face = FACES[tick % FACES.length] ?? '(•_•)' const tail = reasoning.slice(-160).replace(/\n/g, ' ') - const hasReasoning = !!tail - return ( - <> - {tools.map(tool => ( - - {TOOL_VERBS[tool.name] ?? tool.name} - {tool.context ? `: ${tool.context}` : ''} - - ))} - - {!tools.length && !hasReasoning && ( - - {face} {verb}… - - )} - - {tail && ( - - 💭 {tail} - - )} - + return tail ? ( + + 💭 {tail} + + ) : ( + + {FACES[tick % FACES.length] ?? '(•_•)'} {VERBS[tick % VERBS.length] ?? 'thinking'} + … + ) })