mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Copilot review on #14968 caught that the early returns gated on the global `detailsMode === 'hidden'` short-circuited every render path before sectionMode() got a chance to apply per-section overrides — so `details_mode: hidden` + `sections.tools: expanded` was silently a no-op. Three call sites had the same bug shape; all now key off the resolved section modes: - ToolTrail: replace the `detailsMode === 'hidden'` early return with an `allHidden = every section resolved to hidden` check. When that's true, fall back to the floating-alert backstop (errors/warnings) so quiet-mode users aren't blind to ambient failures, and update the comment block to match the actual condition. - messageLine.tsx: drop the same `detailsMode === 'hidden'` pre-check on `msg.kind === 'trail'`; only skip rendering the wrapper when every section resolves to hidden (`SECTION_NAMES.some(...) !== 'hidden'`). - useMainApp.ts: rebuild `showProgressArea` around `anyPanelVisible` instead of branching on the global mode. This also fixes the suppressed Copilot concern about an empty wrapper Box rendering above the streaming area when ToolTrail returns null. Regression test in details.test.ts pins the override-escapes-hidden behaviour for tools/thinking/activity. 271/271 vitest, lints clean.
1163 lines
31 KiB
TypeScript
1163 lines
31 KiB
TypeScript
import { Box, NoSelect, Text } from '@hermes/ink'
|
||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||
|
||
import { THINKING_COT_MAX } from '../config/limits.js'
|
||
import { sectionMode } from '../domain/details.js'
|
||
import {
|
||
buildSubagentTree,
|
||
fmtCost,
|
||
fmtTokens,
|
||
formatSummary as formatSpawnSummary,
|
||
hotnessBucket,
|
||
peakHotness,
|
||
sparkline,
|
||
treeTotals,
|
||
widthByDepth
|
||
} from '../lib/subagentTree.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,
|
||
SectionVisibility,
|
||
SubagentNode,
|
||
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 (
|
||
<Box>
|
||
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
|
||
<Text color={stemColor ?? t.color.dim} dim={stemDim}>
|
||
{lead}
|
||
</Text>
|
||
</NoSelect>
|
||
<Box flexDirection="column" flexGrow={1}>
|
||
{children}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
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 ? (
|
||
<Text color={color} dim wrap={wrap}>
|
||
{content}
|
||
</Text>
|
||
) : (
|
||
<Text color={color} wrap={wrap}>
|
||
{content}
|
||
</Text>
|
||
)
|
||
|
||
return (
|
||
<TreeRow branch={branch} rails={rails} t={t}>
|
||
{text}
|
||
</TreeRow>
|
||
)
|
||
}
|
||
|
||
function TreeNode({
|
||
branch,
|
||
children,
|
||
header,
|
||
open,
|
||
rails = [],
|
||
stemColor,
|
||
stemDim,
|
||
t
|
||
}: {
|
||
branch: TreeBranch
|
||
children?: (rails: boolean[]) => ReactNode
|
||
header: ReactNode
|
||
open: boolean
|
||
rails?: TreeRails
|
||
stemColor?: string
|
||
stemDim?: boolean
|
||
t: Theme
|
||
}) {
|
||
return (
|
||
<Box flexDirection="column">
|
||
<TreeRow branch={branch} rails={rails} stemColor={stemColor} stemDim={stemDim} t={t}>
|
||
{header}
|
||
</TreeRow>
|
||
{open ? children?.(nextTreeRails(rails, branch)) : null}
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
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 <Text color={color}>{spin.frames[frame]}</Text>
|
||
}
|
||
|
||
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 <TreeTextRow branch={branch} color={color} content={content} dimColor={dimColor} rails={rails} t={t} />
|
||
}
|
||
|
||
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 ? (
|
||
<Text color={color} dim>
|
||
{streaming && on ? '▍' : ' '}
|
||
</Text>
|
||
) : (
|
||
<Text color={color}>{streaming && on ? '▍' : ' '}</Text>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
||
<Text color={color} dim={tone === 'dim'}>
|
||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||
{title}
|
||
{typeof count === 'number' ? ` (${count})` : ''}
|
||
{suffix ? (
|
||
<Text color={t.color.statusFg} dim>
|
||
{' '}
|
||
{suffix}
|
||
</Text>
|
||
) : null}
|
||
</Text>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
|
||
const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error]
|
||
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
|
||
|
||
// Below the median bucket we keep the default dim stem so cool branches
|
||
// fade into the chrome — only "hot" branches draw the eye.
|
||
if (idx < 2) {
|
||
return undefined
|
||
}
|
||
|
||
return palette[idx]
|
||
}
|
||
|
||
function SubagentAccordion({
|
||
branch,
|
||
expanded,
|
||
node,
|
||
peak,
|
||
rails = [],
|
||
t
|
||
}: {
|
||
branch: TreeBranch
|
||
expanded: boolean
|
||
node: SubagentNode
|
||
peak: number
|
||
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)
|
||
const [openKids, setOpenKids] = useState(expanded)
|
||
|
||
useEffect(() => {
|
||
if (!expanded) {
|
||
return
|
||
}
|
||
|
||
setOpen(true)
|
||
setDeep(true)
|
||
setOpenThinking(true)
|
||
setOpenTools(true)
|
||
setOpenNotes(true)
|
||
setOpenKids(true)
|
||
}, [expanded])
|
||
|
||
const expandAll = () => {
|
||
setOpen(true)
|
||
setDeep(true)
|
||
setOpenThinking(true)
|
||
setOpenTools(true)
|
||
setOpenNotes(true)
|
||
setOpenKids(true)
|
||
}
|
||
|
||
const item = node.item
|
||
const children = node.children
|
||
const aggregate = node.aggregate
|
||
|
||
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)
|
||
|
||
// Suffix packs branch rollup: status · elapsed · per-branch tool/agent/token/cost.
|
||
// Emphasises the numbers the user can't easily eyeball from a flat list.
|
||
const statusLabel = item.status === 'queued' ? 'queued' : item.status === 'running' ? 'running' : String(item.status)
|
||
|
||
const rollupBits: string[] = [statusLabel]
|
||
|
||
if (item.durationSeconds) {
|
||
rollupBits.push(fmtElapsed(item.durationSeconds * 1000))
|
||
}
|
||
|
||
const localTools = item.toolCount ?? 0
|
||
const subtreeTools = aggregate.totalTools - localTools
|
||
|
||
if (localTools > 0) {
|
||
rollupBits.push(`${localTools} tool${localTools === 1 ? '' : 's'}`)
|
||
}
|
||
|
||
const localTokens = (item.inputTokens ?? 0) + (item.outputTokens ?? 0)
|
||
|
||
if (localTokens > 0) {
|
||
rollupBits.push(`${fmtTokens(localTokens)} tok`)
|
||
}
|
||
|
||
const localCost = item.costUsd ?? 0
|
||
|
||
if (localCost > 0) {
|
||
rollupBits.push(fmtCost(localCost))
|
||
}
|
||
|
||
const filesLocal = (item.filesWritten?.length ?? 0) + (item.filesRead?.length ?? 0)
|
||
|
||
if (filesLocal > 0) {
|
||
rollupBits.push(`⎘${filesLocal}`)
|
||
}
|
||
|
||
if (children.length > 0) {
|
||
rollupBits.push(`${aggregate.descendantCount}↓`)
|
||
|
||
if (subtreeTools > 0) {
|
||
rollupBits.push(`+${subtreeTools}t sub`)
|
||
}
|
||
|
||
const subCost = aggregate.costUsd - localCost
|
||
|
||
if (subCost >= 0.01) {
|
||
rollupBits.push(`+${fmtCost(subCost)} sub`)
|
||
}
|
||
|
||
if (aggregate.activeCount > 0 && item.status !== 'running') {
|
||
rollupBits.push(`⚡${aggregate.activeCount}`)
|
||
}
|
||
}
|
||
|
||
const suffix = rollupBits.join(' · ')
|
||
|
||
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: (
|
||
<Chevron
|
||
count={item.thinking.length}
|
||
onClick={shift => {
|
||
if (shift) {
|
||
expandAll()
|
||
} else {
|
||
setOpenThinking(v => !v)
|
||
}
|
||
}}
|
||
open={showChildren || openThinking}
|
||
t={t}
|
||
title="Thinking"
|
||
/>
|
||
),
|
||
key: 'thinking',
|
||
open: showChildren || openThinking,
|
||
render: childRails => (
|
||
<Thinking
|
||
active={item.status === 'running'}
|
||
branch="last"
|
||
mode="full"
|
||
rails={childRails}
|
||
reasoning={thinkingText}
|
||
streaming={item.status === 'running'}
|
||
t={t}
|
||
/>
|
||
)
|
||
})
|
||
}
|
||
|
||
if (hasTools) {
|
||
sections.push({
|
||
header: (
|
||
<Chevron
|
||
count={item.tools.length}
|
||
onClick={shift => {
|
||
if (shift) {
|
||
expandAll()
|
||
} else {
|
||
setOpenTools(v => !v)
|
||
}
|
||
}}
|
||
open={showChildren || openTools}
|
||
t={t}
|
||
title="Tool calls"
|
||
/>
|
||
),
|
||
key: 'tools',
|
||
open: showChildren || openTools,
|
||
render: childRails => (
|
||
<Box flexDirection="column">
|
||
{item.tools.map((line, index) => (
|
||
<TreeTextRow
|
||
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
|
||
color={t.color.cornsilk}
|
||
content={
|
||
<>
|
||
<Text color={t.color.amber}>● </Text>
|
||
{line}
|
||
</>
|
||
}
|
||
key={`${item.id}-tool-${index}`}
|
||
rails={childRails}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</Box>
|
||
)
|
||
})
|
||
}
|
||
|
||
if (hasNotes) {
|
||
sections.push({
|
||
header: (
|
||
<Chevron
|
||
count={noteRows.length}
|
||
onClick={shift => {
|
||
if (shift) {
|
||
expandAll()
|
||
} else {
|
||
setOpenNotes(v => !v)
|
||
}
|
||
}}
|
||
open={showChildren || openNotes}
|
||
t={t}
|
||
title="Progress"
|
||
tone={statusTone}
|
||
/>
|
||
),
|
||
key: 'notes',
|
||
open: showChildren || openNotes,
|
||
render: childRails => (
|
||
<Box flexDirection="column">
|
||
{noteRows.map((line, index) => (
|
||
<TreeTextRow
|
||
branch={index === noteRows.length - 1 ? 'last' : 'mid'}
|
||
color={noteColor}
|
||
content={line}
|
||
dimColor={statusTone === 'dim'}
|
||
key={`${item.id}-note-${index}`}
|
||
rails={childRails}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</Box>
|
||
)
|
||
})
|
||
}
|
||
|
||
if (children.length > 0) {
|
||
// Nested grandchildren — rendered recursively via SubagentAccordion,
|
||
// sharing the same keybindings / expand semantics as top-level nodes.
|
||
sections.push({
|
||
header: (
|
||
<Chevron
|
||
count={children.length}
|
||
onClick={shift => {
|
||
if (shift) {
|
||
expandAll()
|
||
} else {
|
||
setOpenKids(v => !v)
|
||
}
|
||
}}
|
||
open={showChildren || openKids}
|
||
suffix={`d${item.depth + 1} · ${aggregate.descendantCount} total`}
|
||
t={t}
|
||
title="Spawned"
|
||
/>
|
||
),
|
||
key: 'subagents',
|
||
open: showChildren || openKids,
|
||
render: childRails => (
|
||
<Box flexDirection="column">
|
||
{children.map((child, i) => (
|
||
<SubagentAccordion
|
||
branch={i === children.length - 1 ? 'last' : 'mid'}
|
||
expanded={expanded || deep}
|
||
key={child.item.id}
|
||
node={child}
|
||
peak={peak}
|
||
rails={childRails}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</Box>
|
||
)
|
||
})
|
||
}
|
||
|
||
// Heatmap: amber→error gradient on the stem when this branch is "hot"
|
||
// (high tools/sec) relative to the whole tree's peak.
|
||
const stem = heatColor(node, peak, t)
|
||
|
||
return (
|
||
<TreeNode
|
||
branch={branch}
|
||
header={
|
||
<Chevron
|
||
onClick={shift => {
|
||
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}
|
||
stemColor={stem}
|
||
stemDim={stem == null}
|
||
t={t}
|
||
>
|
||
{childRails => (
|
||
<Box flexDirection="column">
|
||
{sections.map((section, index) => (
|
||
<TreeNode
|
||
branch={index === sections.length - 1 ? 'last' : 'mid'}
|
||
header={section.header}
|
||
key={`${item.id}-${section.key}`}
|
||
open={section.open}
|
||
rails={childRails}
|
||
t={t}
|
||
>
|
||
{section.render}
|
||
</TreeNode>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</TreeNode>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<TreeRow branch={branch} rails={rails} t={t}>
|
||
<Box flexDirection="column" flexGrow={1}>
|
||
{preview ? (
|
||
mode === 'full' ? (
|
||
lines.map((line, index) => (
|
||
<Text color={t.color.dim} dim key={index} wrap="wrap-trim">
|
||
{line || ' '}
|
||
{index === lines.length - 1 ? (
|
||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||
) : null}
|
||
</Text>
|
||
))
|
||
) : (
|
||
<Text color={t.color.dim} dim wrap="truncate-end">
|
||
{preview}
|
||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||
</Text>
|
||
)
|
||
) : (
|
||
<Text color={t.color.dim} dim>
|
||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
</TreeRow>
|
||
)
|
||
})
|
||
|
||
// ── 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,
|
||
sections,
|
||
subagents = [],
|
||
t,
|
||
tools = [],
|
||
toolTokens,
|
||
trail = [],
|
||
activity = []
|
||
}: {
|
||
busy?: boolean
|
||
detailsMode?: DetailsMode
|
||
outcome?: string
|
||
reasoningActive?: boolean
|
||
reasoning?: string
|
||
reasoningTokens?: number
|
||
reasoningStreaming?: boolean
|
||
sections?: SectionVisibility
|
||
subagents?: SubagentProgress[]
|
||
t: Theme
|
||
tools?: ActiveTool[]
|
||
toolTokens?: number
|
||
trail?: string[]
|
||
activity?: ActivityItem[]
|
||
}) {
|
||
const visible = useMemo(
|
||
() => ({
|
||
thinking: sectionMode('thinking', detailsMode, sections),
|
||
tools: sectionMode('tools', detailsMode, sections),
|
||
subagents: sectionMode('subagents', detailsMode, sections),
|
||
activity: sectionMode('activity', detailsMode, sections)
|
||
}),
|
||
[detailsMode, sections]
|
||
)
|
||
|
||
const [now, setNow] = useState(() => Date.now())
|
||
const [openThinking, setOpenThinking] = useState(visible.thinking === 'expanded')
|
||
const [openTools, setOpenTools] = useState(visible.tools === 'expanded')
|
||
const [openSubagents, setOpenSubagents] = useState(visible.subagents === 'expanded')
|
||
const [deepSubagents, setDeepSubagents] = useState(visible.subagents === 'expanded')
|
||
const [openMeta, setOpenMeta] = useState(visible.activity === 'expanded')
|
||
|
||
useEffect(() => {
|
||
if (!tools.length || (visible.tools !== 'expanded' && !openTools)) {
|
||
return
|
||
}
|
||
|
||
const id = setInterval(() => setNow(Date.now()), 500)
|
||
|
||
return () => clearInterval(id)
|
||
}, [openTools, tools.length, visible.tools])
|
||
|
||
useEffect(() => {
|
||
setOpenThinking(visible.thinking === 'expanded')
|
||
setOpenTools(visible.tools === 'expanded')
|
||
setOpenSubagents(visible.subagents === 'expanded')
|
||
setOpenMeta(visible.activity === 'expanded')
|
||
}, [visible])
|
||
|
||
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
||
|
||
// Spawn-tree derivations must live above any early return so React's
|
||
// rules-of-hooks sees a stable call order. Cheap O(N) builds memoised
|
||
// by subagent-list identity.
|
||
const spawnTree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
||
const spawnPeak = useMemo(() => peakHotness(spawnTree), [spawnTree])
|
||
const spawnTotals = useMemo(() => treeTotals(spawnTree), [spawnTree])
|
||
const spawnWidths = useMemo(() => widthByDepth(spawnTree), [spawnTree])
|
||
const spawnSpark = useMemo(() => sparkline(spawnWidths), [spawnWidths])
|
||
const spawnSummaryLabel = useMemo(() => formatSpawnSummary(spawnTotals), [spawnTotals])
|
||
|
||
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 ? (
|
||
<>
|
||
<Spinner color={t.color.amber} variant="think" /> {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: (
|
||
<>
|
||
<Spinner color={t.color.amber} variant="tool" /> {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
|
||
|
||
// ── Backstop: floating alerts when every panel is hidden ─────────
|
||
//
|
||
// Per-section overrides win over the global details_mode (they're computed
|
||
// by sectionMode), so we only collapse to nothing when EVERY section is
|
||
// resolved to hidden — that way `details_mode: hidden` + `sections.tools:
|
||
// expanded` still renders the tools panel. When all panels are hidden
|
||
// AND ambient errors/warnings exist, surface them as a compact inline
|
||
// backstop so quiet-mode users aren't blind to failures.
|
||
|
||
const allHidden =
|
||
visible.thinking === 'hidden' &&
|
||
visible.tools === 'hidden' &&
|
||
visible.subagents === 'hidden' &&
|
||
visible.activity === 'hidden'
|
||
|
||
if (allHidden) {
|
||
const alerts = activity.filter(i => i.tone !== 'info').slice(-2)
|
||
|
||
return alerts.length ? (
|
||
<Box flexDirection="column">
|
||
{alerts.map(i => (
|
||
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}>
|
||
{i.tone === 'error' ? '✗' : '!'} {i.text}
|
||
</Text>
|
||
))}
|
||
</Box>
|
||
) : null
|
||
}
|
||
|
||
// ── Tree render fragments ──────────────────────────────────────
|
||
//
|
||
// Shift+click on any chevron expands every NON-hidden section at once —
|
||
// hidden sections stay hidden so the override is honoured.
|
||
|
||
const expandAll = () => {
|
||
if (visible.thinking !== 'hidden') setOpenThinking(true)
|
||
if (visible.tools !== 'hidden') setOpenTools(true)
|
||
if (visible.subagents !== 'hidden') {
|
||
setOpenSubagents(true)
|
||
setDeepSubagents(true)
|
||
}
|
||
if (visible.activity !== 'hidden') 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[]) => (
|
||
<Box flexDirection="column">
|
||
{spawnTree.map((node, index) => (
|
||
<SubagentAccordion
|
||
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
|
||
expanded={visible.subagents === 'expanded' || deepSubagents}
|
||
key={node.item.id}
|
||
node={node}
|
||
peak={spawnPeak}
|
||
rails={rails}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</Box>
|
||
)
|
||
|
||
const panels: {
|
||
header: ReactNode
|
||
key: string
|
||
open: boolean
|
||
render: (rails: boolean[]) => ReactNode
|
||
}[] = []
|
||
|
||
if (hasThinking && visible.thinking !== 'hidden') {
|
||
panels.push({
|
||
header: (
|
||
<Box
|
||
onClick={(e: any) => {
|
||
if (e?.shiftKey || e?.ctrlKey) {
|
||
expandAll()
|
||
} else {
|
||
setOpenThinking(v => !v)
|
||
}
|
||
}}
|
||
>
|
||
<Text color={t.color.dim} dim={!thinkingLive}>
|
||
<Text color={t.color.amber}>{visible.thinking === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
|
||
{thinkingLive ? (
|
||
<Text bold color={t.color.cornsilk}>
|
||
Thinking
|
||
</Text>
|
||
) : (
|
||
<Text color={t.color.dim} dim>
|
||
Thinking
|
||
</Text>
|
||
)}
|
||
{thinkingTokensLabel ? (
|
||
<Text color={t.color.statusFg} dim>
|
||
{' '}
|
||
{thinkingTokensLabel}
|
||
</Text>
|
||
) : null}
|
||
</Text>
|
||
</Box>
|
||
),
|
||
key: 'thinking',
|
||
open: visible.thinking === 'expanded' || openThinking,
|
||
render: rails => (
|
||
<Thinking
|
||
active={reasoningActive}
|
||
branch="last"
|
||
mode="full"
|
||
rails={rails}
|
||
reasoning={busy ? reasoning : cot}
|
||
streaming={busy && reasoningStreaming}
|
||
t={t}
|
||
/>
|
||
)
|
||
})
|
||
}
|
||
|
||
if (hasTools && visible.tools !== 'hidden') {
|
||
panels.push({
|
||
header: (
|
||
<Chevron
|
||
count={groups.length}
|
||
onClick={shift => {
|
||
if (shift) {
|
||
expandAll()
|
||
} else {
|
||
setOpenTools(v => !v)
|
||
}
|
||
}}
|
||
open={visible.tools === 'expanded' || openTools}
|
||
suffix={toolTokensLabel}
|
||
t={t}
|
||
title="Tool calls"
|
||
/>
|
||
),
|
||
key: 'tools',
|
||
open: visible.tools === 'expanded' || openTools,
|
||
render: rails => (
|
||
<Box flexDirection="column">
|
||
{groups.map((group, index) => {
|
||
const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid'
|
||
const childRails = nextTreeRails(rails, branch)
|
||
const hasInlineSubagents = inlineDelegateKey === group.key
|
||
|
||
return (
|
||
<Box flexDirection="column" key={group.key}>
|
||
<TreeTextRow
|
||
branch={branch}
|
||
color={group.color}
|
||
content={
|
||
<>
|
||
<Text color={t.color.amber}>● </Text>
|
||
{group.content}
|
||
</>
|
||
}
|
||
rails={rails}
|
||
t={t}
|
||
/>
|
||
{group.details.map((detail, detailIndex) => (
|
||
<Detail
|
||
{...detail}
|
||
branch={detailIndex === group.details.length - 1 && !hasInlineSubagents ? 'last' : 'mid'}
|
||
key={detail.key}
|
||
rails={childRails}
|
||
t={t}
|
||
/>
|
||
))}
|
||
{hasInlineSubagents ? renderSubagentList(childRails) : null}
|
||
</Box>
|
||
)
|
||
})}
|
||
</Box>
|
||
)
|
||
})
|
||
}
|
||
|
||
if (hasSubagents && !inlineDelegateKey && visible.subagents !== 'hidden') {
|
||
// Spark + summary give a one-line read on the branch shape before
|
||
// opening the subtree. `/agents` opens the full-screen audit overlay.
|
||
const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)`
|
||
|
||
panels.push({
|
||
header: (
|
||
<Chevron
|
||
count={spawnTotals.descendantCount}
|
||
onClick={shift => {
|
||
if (shift) {
|
||
expandAll()
|
||
setDeepSubagents(true)
|
||
} else {
|
||
setOpenSubagents(v => !v)
|
||
setDeepSubagents(false)
|
||
}
|
||
}}
|
||
open={visible.subagents === 'expanded' || openSubagents}
|
||
suffix={suffix}
|
||
t={t}
|
||
title="Spawn tree"
|
||
/>
|
||
),
|
||
key: 'subagents',
|
||
open: visible.subagents === 'expanded' || openSubagents,
|
||
render: renderSubagentList
|
||
})
|
||
}
|
||
|
||
if (hasMeta && visible.activity !== 'hidden') {
|
||
panels.push({
|
||
header: (
|
||
<Chevron
|
||
count={meta.length}
|
||
onClick={shift => {
|
||
if (shift) {
|
||
expandAll()
|
||
} else {
|
||
setOpenMeta(v => !v)
|
||
}
|
||
}}
|
||
open={visible.activity === 'expanded' || openMeta}
|
||
t={t}
|
||
title="Activity"
|
||
tone={metaTone}
|
||
/>
|
||
),
|
||
key: 'meta',
|
||
open: visible.activity === 'expanded' || openMeta,
|
||
render: rails => (
|
||
<Box flexDirection="column">
|
||
{meta.map((row, index) => (
|
||
<TreeTextRow
|
||
branch={index === meta.length - 1 ? 'last' : 'mid'}
|
||
color={row.color}
|
||
content={row.content}
|
||
dimColor={row.dimColor}
|
||
key={row.key}
|
||
rails={rails}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</Box>
|
||
)
|
||
})
|
||
}
|
||
|
||
const topCount = panels.length + (totalTokensLabel ? 1 : 0)
|
||
|
||
return (
|
||
<Box flexDirection="column">
|
||
{panels.map((panel, index) => (
|
||
<TreeNode
|
||
branch={index === topCount - 1 ? 'last' : 'mid'}
|
||
header={panel.header}
|
||
key={panel.key}
|
||
open={panel.open}
|
||
t={t}
|
||
>
|
||
{panel.render}
|
||
</TreeNode>
|
||
))}
|
||
{totalTokensLabel ? (
|
||
<TreeTextRow
|
||
branch="last"
|
||
color={t.color.statusFg}
|
||
content={
|
||
<>
|
||
<Text color={t.color.amber}>Σ </Text>
|
||
{totalTokensLabel}
|
||
</>
|
||
}
|
||
dimColor
|
||
t={t}
|
||
/>
|
||
) : null}
|
||
{outcome ? (
|
||
<Box marginTop={1}>
|
||
<Text color={t.color.dim} dim>
|
||
· {outcome}
|
||
</Text>
|
||
</Box>
|
||
) : null}
|
||
</Box>
|
||
)
|
||
})
|