import { Box, NoSelect, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' import { compactPreview, estimateTokensRough, fmtK, formatToolCall, parseToolTrailResultLine, pick, thinkingPreview, toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, 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` } type TreeBranch = 'mid' | 'last' type TreeRails = readonly boolean[] const nextTreeRails = (rails: TreeRails, branch: TreeBranch) => [...rails, branch === 'mid'] const treeLead = (rails: TreeRails, branch: TreeBranch) => `${rails.map(on => (on ? '│ ' : ' ')).join('')}${branch === 'mid' ? '├─ ' : '└─ '}` // ── Primitives ─────────────────────────────────────────────────────── function TreeRow({ branch, children, rails = [], stemColor, stemDim = true, t }: { branch: TreeBranch children: ReactNode rails?: TreeRails stemColor?: string stemDim?: boolean t: Theme }) { const lead = treeLead(rails, branch) return ( {lead} {children} ) } function TreeTextRow({ branch, color, content, dimColor, rails = [], t, wrap = 'wrap-trim' }: { branch: TreeBranch color: string content: ReactNode dimColor?: boolean rails?: TreeRails t: Theme wrap?: 'truncate-end' | 'wrap' | 'wrap-trim' }) { const text = dimColor ? ( {content} ) : ( {content} ) return ( {text} ) } function TreeNode({ branch, children, header, open, rails = [], t }: { branch: TreeBranch children?: (rails: boolean[]) => ReactNode header: ReactNode open: boolean rails?: TreeRails t: Theme }) { return ( {header} {open ? children?.(nextTreeRails(rails, branch)) : null} ) } export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const spin = useMemo(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } }, [variant]) const [frame, setFrame] = useState(0) useEffect(() => { setFrame(0) }, [spin]) 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({ branch = 'last', color, content, dimColor, rails = [], t }: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) { return } function StreamCursor({ color, dimColor, streaming = false, visible = false }: { color: string dimColor?: boolean streaming?: boolean visible?: boolean }) { const [on, setOn] = useState(true) useEffect(() => { if (!visible || !streaming) { setOn(true) return } const id = setInterval(() => setOn(v => !v), 420) return () => clearInterval(id) }, [streaming, visible]) if (!visible) { return null } return dimColor ? ( {streaming && on ? '▍' : ' '} ) : ( {streaming && on ? '▍' : ' '} ) } function Chevron({ count, onClick, open, suffix, t, title, tone = 'dim' }: { count?: number onClick: (deep?: boolean) => 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 ( onClick(!!e?.shiftKey || !!e?.ctrlKey)}> {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} {suffix ? ( {' '} {suffix} ) : null} ) } function SubagentAccordion({ branch, expanded, item, rails = [], t }: { branch: TreeBranch expanded: boolean item: SubagentProgress rails?: TreeRails t: Theme }) { const [open, setOpen] = useState(expanded) const [deep, setDeep] = useState(expanded) const [openThinking, setOpenThinking] = useState(expanded) const [openTools, setOpenTools] = useState(expanded) const [openNotes, setOpenNotes] = useState(expanded) useEffect(() => { if (!expanded) { return } setOpen(true) setDeep(true) setOpenThinking(true) setOpenTools(true) setOpenNotes(true) }, [expanded]) const expandAll = () => { setOpen(true) setDeep(true) setOpenThinking(true) setOpenTools(true) setOpenNotes(true) } const statusTone: 'dim' | 'error' | 'warn' = item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim' const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' const goalLabel = item.goal || `Subagent ${item.index + 1}` const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) const suffix = item.status === 'running' ? 'running' : `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}` const thinkingText = item.thinking.join('\n') const hasThinking = Boolean(thinkingText) const hasTools = item.tools.length > 0 const noteRows = [...(summary ? [summary] : []), ...item.notes] const hasNotes = noteRows.length > 0 const showChildren = expanded || deep const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.dim const sections: { header: ReactNode key: string open: boolean render: (rails: boolean[]) => ReactNode }[] = [] if (hasThinking) { sections.push({ header: ( { if (shift) { expandAll() } else { setOpenThinking(v => !v) } }} open={showChildren || openThinking} t={t} title="Thinking" /> ), key: 'thinking', open: showChildren || openThinking, render: childRails => ( ) }) } if (hasTools) { sections.push({ header: ( { if (shift) { expandAll() } else { setOpenTools(v => !v) } }} open={showChildren || openTools} t={t} title="Tool calls" /> ), key: 'tools', open: showChildren || openTools, render: childRails => ( {item.tools.map((line, index) => ( {line} } key={`${item.id}-tool-${index}`} rails={childRails} t={t} /> ))} ) }) } if (hasNotes) { sections.push({ header: ( { if (shift) { expandAll() } else { setOpenNotes(v => !v) } }} open={showChildren || openNotes} t={t} title="Progress" tone={statusTone} /> ), key: 'notes', open: showChildren || openNotes, render: childRails => ( {noteRows.map((line, index) => ( ))} ) }) } return ( { if (shift) { expandAll() return } setOpen(v => { if (!v) { setDeep(false) } return !v }) }} open={open} suffix={suffix} t={t} title={title} tone={statusTone} /> } open={open} rails={rails} t={t} > {childRails => ( {sections.map((section, index) => ( {section.render} ))} )} ) } // ── Thinking ───────────────────────────────────────────────────────── export const Thinking = memo(function Thinking({ active = false, branch = 'last', mode = 'truncated', rails = [], reasoning, streaming = false, t }: { active?: boolean branch?: TreeBranch mode?: ThinkingMode rails?: TreeRails reasoning: string streaming?: boolean t: Theme }) { const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning]) const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) if (!preview && !active) { return null } return ( {preview ? ( mode === 'full' ? ( lines.map((line, index) => ( {line || ' '} {index === lines.length - 1 ? ( ) : null} )) ) : ( {preview} ) ) : ( )} ) }) // ── ToolTrail ──────────────────────────────────────────────────────── interface Group { color: string content: ReactNode details: DetailRow[] key: string label: string } export const ToolTrail = memo(function ToolTrail({ busy = false, detailsMode = 'collapsed', outcome = '', reasoningActive = false, reasoning = '', reasoningTokens, reasoningStreaming = false, subagents = [], t, tools = [], toolTokens, trail = [], activity = [] }: { busy?: boolean detailsMode?: DetailsMode outcome?: string reasoningActive?: boolean reasoning?: string reasoningTokens?: number reasoningStreaming?: boolean subagents?: SubagentProgress[] 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 [openSubagents, setOpenSubagents] = useState(false) const [deepSubagents, setDeepSubagents] = 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) setOpenSubagents(true) setOpenMeta(true) } if (detailsMode === 'hidden') { setOpenThinking(false) setOpenTools(false) setOpenSubagents(false) setOpenMeta(false) } }, [detailsMode]) const latestErrorId = useMemo( () => activity.reduce((max, i) => (i.tone === 'error' && i.id > max ? i.id : max), -1), [activity] ) useEffect(() => { if (latestErrorId >= 0) { setOpenMeta(true) } }, [latestErrorId]) const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) if ( !busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive && !outcome ) { 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}`, label: parsed.call }) 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 ')) { const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim()) groups.push({ color: t.color.cornsilk, content: label, details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], key: `tr-${i}`, label }) 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) { const label = formatToolCall(tool.name, tool.context || '') groups.push({ color: t.color.cornsilk, key: tool.id, label, details: [], content: ( <> {label} {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 hasSubagents = subagents.length > 0 const hasMeta = meta.length > 0 const hasThinking = !!cot || reasoningActive || busy const thinkingLive = reasoningActive || reasoningStreaming const tokenCount = reasoningTokens && reasoningTokens > 0 ? 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 const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : 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 } // ── Tree render fragments ────────────────────────────────────── const expandAll = () => { setOpenThinking(true) setOpenTools(true) setOpenSubagents(true) setDeepSubagents(true) setOpenMeta(true) } const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') ? 'error' : activity.some(i => i.tone === 'warn') ? 'warn' : 'dim' const renderSubagentList = (rails: boolean[]) => ( {subagents.map((item, index) => ( ))} ) const sections: { header: ReactNode key: string open: boolean render: (rails: boolean[]) => ReactNode }[] = [] if (hasThinking) { sections.push({ header: ( { if (e?.shiftKey || e?.ctrlKey) { expandAll() } else { setOpenThinking(v => !v) } }} > {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( Thinking ) : ( Thinking )} {thinkingTokensLabel ? ( {' '} {thinkingTokensLabel} ) : null} ), key: 'thinking', open: detailsMode === 'expanded' || openThinking, render: rails => ( ) }) } if (hasTools) { sections.push({ header: ( { if (shift) { expandAll() } else { setOpenTools(v => !v) } }} open={detailsMode === 'expanded' || openTools} suffix={toolTokensLabel} t={t} title="Tool calls" /> ), key: 'tools', open: detailsMode === 'expanded' || openTools, render: rails => ( {groups.map((group, index) => { const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid' const childRails = nextTreeRails(rails, branch) const hasInlineSubagents = inlineDelegateKey === group.key return ( {group.content} } rails={rails} t={t} /> {group.details.map((detail, detailIndex) => ( ))} {hasInlineSubagents ? renderSubagentList(childRails) : null} ) })} ) }) } if (hasSubagents && !inlineDelegateKey) { sections.push({ header: ( { if (shift) { expandAll() setDeepSubagents(true) } else { setOpenSubagents(v => !v) setDeepSubagents(false) } }} open={detailsMode === 'expanded' || openSubagents} t={t} title="Subagents" /> ), key: 'subagents', open: detailsMode === 'expanded' || openSubagents, render: renderSubagentList }) } if (hasMeta) { sections.push({ header: ( { if (shift) { expandAll() } else { setOpenMeta(v => !v) } }} open={detailsMode === 'expanded' || openMeta} t={t} title="Activity" tone={metaTone} /> ), key: 'meta', open: detailsMode === 'expanded' || openMeta, render: rails => ( {meta.map((row, index) => ( ))} ) }) } const topCount = sections.length + (totalTokensLabel ? 1 : 0) return ( {sections.map((section, index) => ( {section.render} ))} {totalTokensLabel ? ( Σ {totalTokensLabel} } dimColor t={t} /> ) : null} {outcome ? ( · {outcome} ) : null} ) })