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