import { Text } from '@hermes/ink' import { memo, useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { FACES, TOOL_VERBS, VERBS } from '../constants.js' import { isToolTrailResultLine, lastCotTrailIndex, pick, scaleHex, THINKING_COT_FADE, THINKING_COT_MAX, thinkingCotTail } from '../lib/text.js' import type { Theme } from '../theme.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 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 ? '└─ ' : '├─ '} const fmtElapsed = (ms: number) => { const sec = Math.max(0, ms) / 1000 return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { 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(() => { const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) return () => clearInterval(id) }, [spin]) return {spin.frames[frame]} } export const ToolTrail = memo(function ToolTrail({ t, tools = [], trail = [], activity = [], animateCot = false }: { t: Theme tools?: ActiveTool[] trail?: string[] activity?: ActivityItem[] animateCot?: boolean }) { const [now, setNow] = useState(() => Date.now()) useEffect(() => { if (!tools.length) { return } const id = setInterval(() => setNow(Date.now()), 200) return () => clearInterval(id) }, [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) => { const lastInBlock = i === rowCount - 1 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}` : ''} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) })} {act.map((item, k) => { const lastInBlock = trail.length + tools.length + k === rowCount - 1 return ( {activityGlyph(item)} {item.text} ) })} ) }) export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) { const [tick, setTick] = useState(0) useEffect(() => { const id = setInterval(() => setTick(v => v + 1), 1100) return () => clearInterval(id) }, []) const tail = thinkingCotTail(reasoning) const clipped = reasoning.length > THINKING_COT_MAX return ( <> {FACES[tick % FACES.length] ?? '(•_•)'}{' '} {VERBS[tick % VERBS.length] ?? 'thinking'}… {tail ? ( {clipped && Array.from({ length: Math.min(THINKING_COT_FADE, tail.length) }, (_, i) => ( {tail[i]} ))} {clipped ? tail.slice(THINKING_COT_FADE) : tail} ) : null} ) })