import { Box, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { estimateTokensRough, fmtK, formatToolCall, parseToolTrailResultLine, pick, THINKING_COT_MAX, thinkingPreview, toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const fmtElapsed = (ms: number) => { const sec = Math.max(0, ms) / 1000 return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } // ── Primitives ─────────────────────────────────────────────────────── 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]} } interface DetailRow { color: string content: ReactNode dimColor?: boolean key: string } function Detail({ color, content, dimColor }: DetailRow) { return ( {content} ) } function StreamCursor({ color, dimColor, streaming = false, visible = false }: { color: string dimColor?: boolean streaming?: boolean visible?: boolean }) { const [on, setOn] = useState(true) useEffect(() => { const id = setInterval(() => setOn(v => !v), 420) return () => clearInterval(id) }, []) return visible ? ( {streaming && on ? '▍' : ' '} ) : null } function Chevron({ count, onClick, open, suffix, t, title, tone = 'dim' }: { count?: number onClick: () => void open: boolean suffix?: string t: Theme title: string tone?: 'dim' | 'error' | 'warn' }) { const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim return ( {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} {suffix ? ( {' '} {suffix} ) : null} ) } // ── Thinking ───────────────────────────────────────────────────────── export const Thinking = memo(function Thinking({ active = false, mode = 'truncated', reasoning, streaming = false, t }: { active?: boolean mode?: ThinkingMode reasoning: string streaming?: boolean t: Theme }) { const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX) const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) return ( {preview ? ( mode === 'full' ? ( └{' '} {lines.map((line, index) => ( {line || ' '} {index === lines.length - 1 ? ( ) : null} ))} ) : ( {preview} ) ) : active ? ( ) : null} ) }) // ── ToolTrail ──────────────────────────────────────────────────────── interface Group { color: string content: ReactNode details: DetailRow[] key: string } export const ToolTrail = memo(function ToolTrail({ busy = false, detailsMode = 'collapsed', reasoningActive = false, reasoning = '', reasoningTokens, reasoningStreaming = false, t, tools = [], toolTokens, trail = [], activity = [] }: { busy?: boolean detailsMode?: DetailsMode reasoningActive?: boolean reasoning?: string reasoningTokens?: number reasoningStreaming?: boolean t: Theme tools?: ActiveTool[] toolTokens?: number trail?: string[] activity?: ActivityItem[] }) { const [now, setNow] = useState(() => Date.now()) const [openThinking, setOpenThinking] = useState(false) const [openTools, setOpenTools] = useState(false) const [openMeta, setOpenMeta] = useState(false) useEffect(() => { if (!tools.length || (detailsMode === 'collapsed' && !openTools)) { return } const id = setInterval(() => setNow(Date.now()), 500) return () => clearInterval(id) }, [detailsMode, openTools, tools.length]) useEffect(() => { if (detailsMode === 'expanded') { setOpenThinking(true) setOpenTools(true) setOpenMeta(true) } if (detailsMode === 'hidden') { setOpenThinking(false) setOpenTools(false) setOpenMeta(false) } }, [detailsMode]) const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX) if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) { return null } // ── Build groups + meta ──────────────────────────────────────── const groups: Group[] = [] const meta: DetailRow[] = [] const pushDetail = (row: DetailRow) => (groups.at(-1)?.details ?? meta).push(row) for (const [i, line] of trail.entries()) { const parsed = parseToolTrailResultLine(line) if (parsed) { groups.push({ color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, details: [], key: `tr-${i}` }) if (parsed.detail) { pushDetail({ color: parsed.mark === '✗' ? t.color.error : t.color.dim, content: parsed.detail, dimColor: parsed.mark !== '✗', key: `tr-${i}-d` }) } continue } if (line.startsWith('drafting ')) { groups.push({ color: t.color.cornsilk, content: toolTrailLabel(line.slice(9).replace(/…$/, '').trim()), details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], key: `tr-${i}` }) continue } if (line === 'analyzing tool output…') { pushDetail({ color: t.color.dim, dimColor: true, key: `tr-${i}`, content: groups.length ? ( <> {line} ) : ( line ) }) continue } meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` }) } for (const tool of tools) { groups.push({ color: t.color.cornsilk, key: tool.id, details: [], content: ( <> {formatToolCall(tool.name, tool.context || '')} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) }) } for (const item of activity.slice(-4)) { const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·' const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` }) } // ── Derived ──────────────────────────────────────────────────── const hasTools = groups.length > 0 const hasMeta = meta.length > 0 const hasThinking = !!cot || reasoningActive || (busy && !hasTools) const thinkingLive = reasoningActive || reasoningStreaming const tokenCount = reasoningTokens !== undefined ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0 const toolTokenCount = toolTokens ?? 0 const totalTokenCount = tokenCount + toolTokenCount const thinkingTokensLabel = tokenCount > 0 ? `~${fmtK(tokenCount)} tokens` : null const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null // ── Hidden: errors/warnings only ────────────────────────────── if (detailsMode === 'hidden') { const alerts = activity.filter(i => i.tone !== 'info').slice(-2) return alerts.length ? ( {alerts.map(i => ( {i.tone === 'error' ? '✗' : '!'} {i.text} ))} ) : null } // ── Shared render fragments ──────────────────────────────────── const thinkingBlock = hasThinking ? ( busy ? ( ) : cot ? ( ) : ( } dimColor key="cot" /> ) ) : null const toolBlock = hasTools ? groups.map(g => ( {g.content} {g.details.map(d => ( ))} )) : null const metaBlock = hasMeta ? meta.map((row, i) => ( {i === meta.length - 1 ? '└ ' : '├ '} {row.content} )) : null const totalBlock = totalTokensLabel ? ( Σ {totalTokensLabel} ) : null // ── Expanded: flat, no accordions ────────────────────────────── if (detailsMode === 'expanded') { return ( {thinkingBlock} {toolBlock} {metaBlock} {totalBlock} ) } // ── Collapsed: clickable accordions ──────────────────────────── const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') ? 'error' : activity.some(i => i.tone === 'warn') ? 'warn' : 'dim' return ( {hasThinking && ( <> setOpenThinking(v => !v)}> {openThinking ? '▾ ' : '▸ '} Thinking {thinkingTokensLabel ? ( {' '} {thinkingTokensLabel} ) : null} {openThinking && thinkingBlock} )} {hasTools && ( <> setOpenTools(v => !v)} open={openTools} suffix={toolTokensLabel} t={t} title="Tool calls" /> {openTools && toolBlock} )} {hasMeta && ( <> setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} /> {openMeta && metaBlock} )} {totalBlock} ) })