hermes-agent/ui-tui/src/components/agentsOverlay.tsx
Brooklyn Nicholson 5b0741e986 refactor(tui): consolidate agents overlay — share duration/root helpers via lib
Pull duplicated rules into ui-tui/src/lib/subagentTree so the live overlay,
disk snapshot label, and diff pane all speak one dialect:

- export fmtDuration(seconds) — was a private helper in subagentTree;
  agentsOverlay's local secLabel/fmtDur/fmtElapsedLabel now wrap the same
  core (with UI-only empty-string policy).
- export topLevelSubagents(items) — matches buildSubagentTree's orphan
  semantics (no parent OR parent not in snapshot). Replaces three hand-
  rolled copies across createGatewayEventHandler (disk label), agentsOverlay
  DiffPane, and prior inline filters.

Also collapse agentsOverlay boilerplate:
- replace IIFE title + inner `delta` helper with straight expressions;
- introduce module-level diffMetricLine for replay-diff rows;
- tighten OverlayScrollbar (single thumbColor expression, vBar/thumbBody).

Adds unit coverage for the new exports (fmtDuration + topLevelSubagents).
No behaviour change; 221 tests pass.
2026-04-22 12:10:21 -05:00

1064 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
import {
$delegationState,
$overlaySectionsOpen,
applyDelegationStatus,
toggleOverlaySection
} from '../app/delegationStore.js'
import { patchOverlayState } from '../app/overlayStore.js'
import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js'
import { $turnState } from '../app/turnStore.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import {
buildSubagentTree,
descendantIds,
flattenTree,
fmtCost,
fmtDuration,
fmtTokens,
formatSummary,
hotnessBucket,
peakHotness,
sparkline,
topLevelSubagents,
treeTotals,
widthByDepth
} from '../lib/subagentTree.js'
import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { SubagentNode, SubagentProgress } from '../types.js'
// ── Types + lookup tables ────────────────────────────────────────────
type SortMode = 'depth-first' | 'duration-desc' | 'status' | 'tools-desc'
type FilterMode = 'all' | 'failed' | 'leaf' | 'running'
type Status = SubagentProgress['status']
const SORT_ORDER: readonly SortMode[] = ['depth-first', 'tools-desc', 'duration-desc', 'status']
const FILTER_ORDER: readonly FilterMode[] = ['all', 'running', 'failed', 'leaf']
const SORT_LABEL: Record<SortMode, string> = {
'depth-first': 'spawn order',
'duration-desc': 'slowest',
status: 'status',
'tools-desc': 'busiest'
}
const FILTER_LABEL: Record<FilterMode, string> = {
all: 'all',
failed: 'failed',
leaf: 'leaves',
running: 'running'
}
const STATUS_RANK: Record<Status, number> = {
failed: 0,
interrupted: 1,
running: 2,
queued: 3,
completed: 4
}
const SORT_COMPARATORS: Record<SortMode, (a: SubagentNode, b: SubagentNode) => number> = {
'depth-first': (a, b) => a.item.depth - b.item.depth || a.item.index - b.item.index,
'tools-desc': (a, b) => b.aggregate.totalTools - a.aggregate.totalTools,
'duration-desc': (a, b) => b.aggregate.totalDuration - a.aggregate.totalDuration,
status: (a, b) => STATUS_RANK[a.item.status] - STATUS_RANK[b.item.status]
}
const FILTER_PREDICATES: Record<FilterMode, (n: SubagentNode) => boolean> = {
all: () => true,
leaf: n => n.children.length === 0,
running: n => n.item.status === 'running' || n.item.status === 'queued',
failed: n => n.item.status === 'failed' || n.item.status === 'interrupted'
}
const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = {
running: { color: t => t.color.amber, glyph: '●' },
queued: { color: t => t.color.dim, glyph: '○' },
completed: { color: t => t.color.statusGood, glyph: '✓' },
interrupted: { color: t => t.color.warn, glyph: '■' },
failed: { color: t => t.color.error, glyph: '✗' }
}
// Heatmap palette — cold → hot, resolved against the active theme.
const heatPalette = (t: Theme) => [t.color.bronze, t.color.amber, t.color.gold, t.color.warn, t.color.error]
// ── Pure helpers ─────────────────────────────────────────────────────
const fmtDur = (seconds?: number) => (seconds == null || seconds <= 0 ? '' : fmtDuration(seconds))
const fmtElapsedLabel = (seconds: number) => (seconds < 0 ? '' : fmtDuration(seconds))
const displayElapsedSeconds = (item: SubagentProgress, nowMs: number): number | null => {
if (item.durationSeconds != null) {
return item.durationSeconds
}
if (item.startedAt != null && (item.status === 'running' || item.status === 'queued')) {
return Math.max(0, (nowMs - item.startedAt) / 1000)
}
return null
}
const indentFor = (depth: number): string => ' '.repeat(Math.max(0, depth))
const formatRowId = (n: number): string => String(n + 1).padStart(2, ' ')
const cycle = <T,>(order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]!
const statusGlyph = (item: SubagentProgress, t: Theme) => {
const g = STATUS_GLYPH[item.status]
return { color: g.color(t), glyph: g.glyph }
}
const prepareRows = (tree: SubagentNode[], sort: SortMode, filter: FilterMode): SubagentNode[] =>
tree.length === 0 ? [] : flattenTree([...tree].sort(SORT_COMPARATORS[sort])).filter(FILTER_PREDICATES[filter])
const diffMetricLine = (name: string, a: number, b: number, fmt: (n: number) => string) => {
const d = b - a
const sign = d === 0 ? '' : d > 0 ? '+' : '-'
return `${name}: ${fmt(a)}${fmt(b)} (${sign}${fmt(Math.abs(d)) || '0'})`
}
// ── Sub-components ───────────────────────────────────────────────────
/** Polled on parent `tick` so accordions can resize the thumb without a scroll event. */
function OverlayScrollbar({
scrollRef,
t,
tick
}: {
scrollRef: RefObject<null | ScrollBoxHandle>
t: Theme
tick: number
}) {
void tick // ensures re-render when the parent clock advances
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<null | number>(null)
const s = scrollRef.current
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
if (!vp) {
return <Box width={1} />
}
const total = Math.max(vp, s?.getScrollHeight() ?? vp)
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const below = Math.max(0, vp - thumbTop - thumb)
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}`
const thumbColor = grab !== null ? t.color.gold : t.color.amber
const trackColor = hover ? t.color.bronze : t.color.dim
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
return
}
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
}
return (
<Box
flexDirection="column"
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => setGrab(null)}
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{vBar(vp)}
</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(thumbTop)}
</Text>
) : null}
<Text color={thumbColor}>{thumbBody}</Text>
{below > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(below)}
</Text>
) : null}
</>
)}
</Box>
)
}
function GanttStrip({
cols,
cursor,
flatNodes,
maxRows,
now,
t
}: {
cols: number
cursor: number
flatNodes: SubagentNode[]
maxRows: number
now: number
t: Theme
}) {
const spans = flatNodes
.map((node, idx) => {
const started = node.item.startedAt ?? now
const ended =
node.item.durationSeconds != null && node.item.startedAt != null
? node.item.startedAt + node.item.durationSeconds * 1000
: now
return { endAt: ended, idx, node, startAt: started }
})
.filter(s => s.endAt >= s.startAt)
if (!spans.length) {
return null
}
const globalStart = Math.min(...spans.map(s => s.startAt))
const globalEnd = Math.max(...spans.map(s => s.endAt))
const totalSpan = Math.max(1, globalEnd - globalStart)
const totalSeconds = (globalEnd - globalStart) / 1000
// 5-col id gutter (" 12 ") so the bar doesn't press against the id.
// 10-col right reserve: pad + up to `12m 30s`-style label without
// truncate-end against a full-width bar.
const idGutter = 5
const labelReserve = 10
const barWidth = Math.max(10, cols - idGutter - labelReserve)
const startIdx = Math.max(0, Math.min(Math.max(0, spans.length - maxRows), cursor - Math.floor(maxRows / 2)))
const shown = spans.slice(startIdx, startIdx + maxRows)
const bar = (startAt: number, endAt: number) => {
const s = Math.floor(((startAt - globalStart) / totalSpan) * barWidth)
const e = Math.min(barWidth, Math.ceil(((endAt - globalStart) / totalSpan) * barWidth))
const fill = Math.max(1, e - s)
return ' '.repeat(s) + '█'.repeat(fill) + ' '.repeat(Math.max(0, barWidth - s - fill))
}
const charStep = totalSeconds < 20 && barWidth > 20 ? 5 : 10
const ruler = Array.from({ length: barWidth }, (_, i) => {
if (i > 0 && i % 10 === 0) {
return '┼'
}
if (i > 0 && i % 5 === 0) {
return '·'
}
return '─'
}).join('')
const rulerLabels = (() => {
const chars = new Array(barWidth).fill(' ')
for (let pos = 0; pos < barWidth; pos += charStep) {
const secs = (pos / barWidth) * totalSeconds
const label = pos === 0 ? '0' : secs >= 1 ? `${Math.round(secs)}s` : `${secs.toFixed(1)}s`
for (let j = 0; j < label.length && pos + j < barWidth; j++) {
chars[pos + j] = label[j]!
}
}
return chars.join('')
})()
const windowLabel =
spans.length > maxRows ? ` (${startIdx + 1}-${Math.min(spans.length, startIdx + maxRows)}/${spans.length})` : ''
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.dim}>
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
{windowLabel}
</Text>
{shown.map(({ endAt, idx, node, startAt }) => {
const active = idx === cursor
const { color } = statusGlyph(node.item, t)
const accent = active ? t.color.amber : t.color.dim
const elSec = displayElapsedSeconds(node.item, now)
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
return (
<Text key={node.item.id} wrap="truncate-end">
<Text bold={active} color={accent}>
{formatRowId(idx)}
{' '}
</Text>
<Text color={active ? t.color.amber : color}>{bar(startAt, endAt)}</Text>
{elLabel ? (
<Text color={accent}>
{' '}
{elLabel}
</Text>
) : null}
</Text>
)
})}
<Text color={t.color.dim} dim>
{' '}
{ruler}
</Text>
{totalSeconds > 0 ? (
<Text color={t.color.dim} dim>
{' '}
{rulerLabels}
</Text>
) : null}
</Box>
)
}
function OverlaySection({
children,
count,
defaultOpen = false,
title,
t
}: {
children: ReactNode
count?: number
defaultOpen?: boolean
title: string
t: Theme
}) {
const openMap = useStore($overlaySectionsOpen)
const open = title in openMap ? openMap[title]! : defaultOpen
return (
<Box flexDirection="column" marginTop={1}>
<Box onClick={() => toggleOverlaySection(title, defaultOpen)}>
<Text color={t.color.label}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}
{typeof count === 'number' ? ` (${count})` : ''}
</Text>
</Box>
{open ? <Box flexDirection="column">{children}</Box> : null}
</Box>
)
}
function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) {
return (
<Text wrap="truncate-end">
<Text color={t.color.label}>{name} · </Text>
<Text color={t.color.cornsilk}>{value}</Text>
</Text>
)
}
function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) {
const { aggregate: agg, item } = node
const { color, glyph } = statusGlyph(item, t)
const inputTokens = item.inputTokens ?? 0
const outputTokens = item.outputTokens ?? 0
const localTokens = inputTokens + outputTokens
const subtreeTokens = agg.inputTokens + agg.outputTokens - localTokens
const localCost = item.costUsd ?? 0
const subtreeCost = agg.costUsd - localCost
const filesRead = item.filesRead ?? []
const filesWritten = item.filesWritten ?? []
const outputTail = item.outputTail ?? []
// Tool calls: prefer the live stream; for archived / post-turn views
// that stream is often empty even when tool_count > 0, so fall back to
// the tool names captured in outputTail at subagent.complete time.
const toolLines = item.tools.length > 0 ? item.tools : outputTail.map(e => e.tool).filter(Boolean)
const filesOverflow = Math.max(0, filesRead.length - 8) + Math.max(0, filesWritten.length - 8)
return (
<Box flexDirection="column">
<Text bold color={t.color.cornsilk} wrap="wrap">
{id ? <Text color={t.color.amber}>#{id} </Text> : null}
<Text color={color}>{glyph}</Text> {item.goal}
</Text>
<Box flexDirection="column" marginTop={1}>
<Field name="depth" t={t} value={`${item.depth} · ${item.status}`} />
{item.model ? <Field name="model" t={t} value={item.model} /> : null}
{item.toolsets?.length ? <Field name="toolsets" t={t} value={item.toolsets.join(', ')} /> : null}
<Field name="tools" t={t} value={`${item.toolCount ?? 0} (subtree ${agg.totalTools})`} />
<Field
name="subtree"
t={t}
value={`${agg.descendantCount} agent${agg.descendantCount === 1 ? '' : 's'} · d${agg.maxDepthFromHere} · ⚡${agg.activeCount}`}
/>
{item.durationSeconds ? <Field name="elapsed" t={t} value={fmtDur(item.durationSeconds)} /> : null}
{item.iteration != null ? <Field name="iteration" t={t} value={String(item.iteration)} /> : null}
{item.apiCalls ? <Field name="api calls" t={t} value={String(item.apiCalls)} /> : null}
</Box>
{localTokens > 0 || localCost > 0 ? (
<OverlaySection defaultOpen t={t} title="Budget">
{localTokens > 0 ? (
<Field
name="tokens"
t={t}
value={
<>
{fmtTokens(inputTokens)} in · {fmtTokens(outputTokens)} out
{item.reasoningTokens ? ` · ${fmtTokens(item.reasoningTokens)} reasoning` : ''}
</>
}
/>
) : null}
{localCost > 0 ? (
<Field
name="cost"
t={t}
value={
<>
{fmtCost(localCost)}
{subtreeCost >= 0.01 ? ` · subtree +${fmtCost(subtreeCost)}` : ''}
</>
}
/>
) : null}
{subtreeTokens > 0 ? <Field name="subtree tokens" t={t} value={`+${fmtTokens(subtreeTokens)}`} /> : null}
</OverlaySection>
) : null}
{filesRead.length > 0 || filesWritten.length > 0 ? (
<OverlaySection count={filesRead.length + filesWritten.length} t={t} title="Files">
{filesWritten.slice(0, 8).map((p, i) => (
<Text color={t.color.statusGood} key={`w-${i}`} wrap="truncate-end">
+{p}
</Text>
))}
{filesRead.slice(0, 8).map((p, i) => (
<Text color={t.color.cornsilk} key={`r-${i}`} wrap="truncate-end">
<Text color={t.color.dim}>·</Text> {p}
</Text>
))}
{filesOverflow > 0 ? <Text color={t.color.dim}>+{filesOverflow} more</Text> : null}
</OverlaySection>
) : null}
{toolLines.length > 0 ? (
<OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls">
{toolLines.map((line, i) => (
<Text color={t.color.cornsilk} key={i} wrap="wrap">
<Text color={t.color.dim}>·</Text> {line}
</Text>
))}
</OverlaySection>
) : null}
{outputTail.length > 0 ? (
<OverlaySection count={outputTail.length} defaultOpen t={t} title="Output">
{outputTail.map((entry, i) => (
<Text color={entry.isError ? t.color.error : t.color.cornsilk} key={i} wrap="wrap">
<Text bold color={entry.isError ? t.color.error : t.color.amber}>
{entry.tool}
</Text>{' '}
{entry.preview}
</Text>
))}
</OverlaySection>
) : null}
{item.notes.length ? (
<OverlaySection count={item.notes.length} t={t} title="Progress">
{item.notes.slice(-6).map((line, i) => (
<Text color={t.color.cornsilk} key={i} wrap="wrap">
<Text color={t.color.label}>·</Text> {line}
</Text>
))}
</OverlaySection>
) : null}
{item.summary ? (
<OverlaySection defaultOpen t={t} title="Summary">
<Text color={t.color.cornsilk} wrap="wrap">
{item.summary}
</Text>
</OverlaySection>
) : null}
</Box>
)
}
function ListRow({
active,
index,
node,
peak,
t,
width
}: {
active: boolean
index: number
node: SubagentNode
peak: number
t: Theme
width: number
}) {
const { color, glyph } = statusGlyph(node.item, t)
const palette = heatPalette(t)
const heatIdx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
const heatMarker = heatIdx >= 2 ? palette[heatIdx]! : null
const goal = compactPreview(node.item.goal || 'subagent', width - 28 - node.item.depth * 2)
const toolsCount = node.aggregate.totalTools > 0 ? ` ·${node.aggregate.totalTools}t` : ''
const kids = node.children.length ? ` ·${node.children.length}` : ''
const line = node.item.status === 'running' ? node.item.tools.at(-1) : undefined
const paren = line ? line.indexOf('(') : -1
const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : ''
const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : ''
const fg = active ? t.color.amber : t.color.cornsilk
return (
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
{' '}
<Text color={active ? fg : t.color.dim}>{formatRowId(index)} </Text>
{indentFor(node.item.depth)}
{heatMarker ? <Text color={heatMarker}></Text> : null}
<Text color={active ? fg : color}>{glyph}</Text> {goal}
<Text color={active ? fg : t.color.dim}>
{toolsCount}
{kids}
{trailing}
</Text>
</Text>
)
}
function DiffPane({
label,
snapshot,
t,
totals,
width
}: {
label: string
snapshot: SpawnSnapshot
t: Theme
totals: ReturnType<typeof treeTotals>
width: number
}) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.cornsilk}>
{label}
</Text>
<Text color={t.color.dim} wrap="truncate-end">
{snapshot.label}
</Text>
<Box marginTop={1}>
<Text color={t.color.dim} wrap="truncate-end">
{formatSummary(totals)}
</Text>
</Box>
<Box flexDirection="column" marginTop={1}>
{topLevelSubagents(snapshot.subagents)
.slice(0, 8)
.map(s => {
const { color, glyph } = statusGlyph(s, t)
return (
<Text color={t.color.dim} key={s.id} wrap="truncate-end">
<Text color={color}>{glyph}</Text> {s.goal || 'subagent'}
</Text>
)
})}
</Box>
</Box>
)
}
function DiffView({
cols,
onClose,
pair,
t
}: {
cols: number
onClose: () => void
pair: { baseline: SpawnSnapshot; candidate: SpawnSnapshot }
t: Theme
}) {
const aTotals = useMemo(() => treeTotals(buildSubagentTree(pair.baseline.subagents)), [pair.baseline])
const bTotals = useMemo(() => treeTotals(buildSubagentTree(pair.candidate.subagents)), [pair.candidate])
const paneWidth = Math.floor((cols - 4) / 2)
useInput((ch, key) => {
if (key.escape || ch === 'q') {
onClose()
}
})
const round = (n: number) => String(Math.round(n))
const sumTokens = (x: typeof aTotals) => x.inputTokens + x.outputTokens
const dollars = (n: number) => fmtCost(n) || '$0.00'
return (
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={t.color.bronze}>
Replay diff
</Text>
<Text color={t.color.dim}>baseline vs candidate · esc/q close</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<DiffPane label="A · baseline" snapshot={pair.baseline} t={t} totals={aTotals} width={paneWidth} />
<Box width={2} />
<DiffPane label="B · candidate" snapshot={pair.candidate} t={t} totals={bTotals} width={paneWidth} />
</Box>
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}>
Δ
</Text>
<Text color={t.color.cornsilk}>
{diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)}
</Text>
<Text color={t.color.cornsilk}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
<Text color={t.color.cornsilk}>
{diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)}
</Text>
<Text color={t.color.cornsilk}>
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
</Text>
<Text color={t.color.cornsilk}>
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
</Text>
<Text color={t.color.cornsilk}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
</Box>
</Box>
)
}
// ── Main overlay ─────────────────────────────────────────────────────
export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) {
const turn = useStore($turnState)
const delegation = useStore($delegationState)
const history = useStore($spawnHistory)
const diffPair = useStore($spawnDiff)
const { stdout } = useStdout()
// historyIndex === 0: live turn. 1..N pulls the Nth-most-recent archived
// snapshot. /replay passes N on open.
const [historyIndex, setHistoryIndex] = useState(() =>
Math.max(0, Math.min(history.length, Math.floor(initialHistoryIndex)))
)
const [sort, setSort] = useState<SortMode>('depth-first')
const [filter, setFilter] = useState<FilterMode>('all')
const [cursor, setCursor] = useState(0)
const [flash, setFlash] = useState<string>('')
const [now, setNow] = useState(() => Date.now())
// cc-style view switching: list = full-width row picker, detail = full-width
// scrollable pane. Two panes side-by-side in Ink fought Yoga flex.
const [mode, setMode] = useState<'detail' | 'list'>('list')
const detailScrollRef = useRef<null | ScrollBoxHandle>(null)
const prevLiveCountRef = useRef(turn.subagents.length)
// ── Derived state ──────────────────────────────────────────────────
const activeSnapshot = historyIndex > 0 ? history[historyIndex - 1] : null
// Instant fallback to history[0] the moment the live list clears — avoids
// a one-frame "no subagents" flash while the auto-follow effect fires.
const justFinishedSnapshot = historyIndex === 0 && turn.subagents.length === 0 ? (history[0] ?? null) : null
const effectiveSnapshot = activeSnapshot ?? justFinishedSnapshot
const replayMode = effectiveSnapshot != null
const subagents = replayMode ? effectiveSnapshot.subagents : turn.subagents
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
const totals = useMemo(() => treeTotals(tree), [tree])
const widths = useMemo(() => widthByDepth(tree), [tree])
const spark = useMemo(() => sparkline(widths), [widths])
const peak = useMemo(() => peakHotness(tree), [tree])
const rows = useMemo(() => prepareRows(tree, sort, filter), [tree, sort, filter])
const selected = rows[cursor] ?? null
const cols = stdout?.columns ?? 80
const rowsH = Math.max(8, (stdout?.rows ?? 24) - 10)
const listWindowStart = Math.max(0, cursor - Math.floor(rowsH / 2))
// ── Effects ────────────────────────────────────────────────────────
useEffect(() => {
// Ticker drives both the live gantt and OverlayScrollbar content-reflow
// detection. Slower in replay (nothing's growing) but not stopped
// because accordions still expand.
const id = setInterval(() => setNow(Date.now()), replayMode ? 300 : 500)
return () => clearInterval(id)
}, [replayMode])
useEffect(() => {
// Clamp stale index when history grows/shrinks beneath us.
if (historyIndex > history.length) {
setHistoryIndex(history.length)
}
}, [history.length, historyIndex])
useEffect(() => {
// Auto-follow the just-finished turn onto history[1] so the user isn't
// dropped into an empty live view. Fires only when transitioning from
// "had live subagents" → "live empty" while in live mode.
const prev = prevLiveCountRef.current
prevLiveCountRef.current = turn.subagents.length
if (historyIndex === 0 && prev > 0 && turn.subagents.length === 0 && history.length > 0) {
setHistoryIndex(1)
setCursor(0)
setFlash('turn finished · inspect freely · q to close')
}
}, [history.length, historyIndex, turn.subagents.length])
useEffect(() => {
// Reset detail scroll on navigation so the top of the new node shows.
detailScrollRef.current?.scrollTo(0)
}, [cursor, historyIndex, mode])
useEffect(() => {
// Warm caps + paused flag on open.
gw.request<DelegationStatusResponse>('delegation.status', {})
.then(r => applyDelegationStatus(asRpcResult<DelegationStatusResponse>(r)))
.catch(() => {})
}, [gw])
useEffect(() => {
if (cursor >= rows.length) {
setCursor(Math.max(0, rows.length - 1))
}
}, [cursor, rows.length])
// ── Actions ────────────────────────────────────────────────────────
const guardLive = (action: () => void) => {
if (replayMode) {
setFlash('replay mode — controls disabled')
} else {
action()
}
}
const interrupt = (id: string) => gw.request<SubagentInterruptResponse>('subagent.interrupt', { subagent_id: id })
const killOne = (id: string) =>
guardLive(() => {
interrupt(id)
.then(raw => {
const r = asRpcResult<SubagentInterruptResponse>(raw)
setFlash(r?.found ? `killing ${id}` : `not found: ${id}`)
})
.catch(() => setFlash(`kill failed: ${id}`))
})
const killSubtree = (node: SubagentNode) =>
guardLive(() => {
const ids = [node.item.id, ...descendantIds(node)]
ids.forEach(id => interrupt(id).catch(() => {}))
setFlash(`killing subtree · ${ids.length} node${ids.length === 1 ? '' : 's'}`)
})
const togglePause = () =>
guardLive(() => {
gw.request<DelegationPauseResponse>('delegation.pause', { paused: !delegation.paused })
.then(raw => {
const r = asRpcResult<DelegationPauseResponse>(raw)
applyDelegationStatus({ paused: r?.paused })
setFlash(r?.paused ? 'spawning paused' : 'spawning resumed')
})
.catch(() => setFlash('pause failed'))
})
const stepHistory = (delta: -1 | 1) =>
setHistoryIndex(idx => {
const next = Math.max(0, Math.min(history.length, idx + delta))
if (next !== idx) {
setCursor(0)
setFlash(next === 0 ? 'live turn' : `replay · ${next}/${history.length}`)
}
return next
})
const closeWithCleanup = () => {
clearDiffPair()
onClose()
}
// ── Input ──────────────────────────────────────────────────────────
const detailPageSize = Math.max(4, rowsH - 2)
const wheelDetailDy = 3
const scrollDetail = (dy: number) => detailScrollRef.current?.scrollBy(dy)
useInput((ch, key) => {
if (ch === 'q') {
return closeWithCleanup()
}
if (key.escape) {
return mode === 'detail' ? setMode('list') : closeWithCleanup()
}
// Shared actions (both modes).
if (ch === '<' || ch === '[') {
return stepHistory(1)
}
if (ch === '>' || ch === ']') {
return stepHistory(-1)
}
if (ch === 'p') {
return togglePause()
}
if (ch === 'x' && selected) {
return killOne(selected.item.id)
}
if (ch === 'X' && selected) {
return killSubtree(selected)
}
if (mode === 'detail') {
if (key.leftArrow || ch === 'h') {
return setMode('list')
}
if (key.pageUp || (key.ctrl && ch === 'u')) {
return scrollDetail(-detailPageSize)
}
if (key.pageDown || (key.ctrl && ch === 'd')) {
return scrollDetail(detailPageSize)
}
if (key.wheelUp) {
return scrollDetail(-wheelDetailDy)
}
if (key.wheelDown) {
return scrollDetail(wheelDetailDy)
}
if (key.upArrow || ch === 'k') {
return scrollDetail(-2)
}
if (key.downArrow || ch === 'j') {
return scrollDetail(2)
}
if (ch === 'g') {
return detailScrollRef.current?.scrollTo(0)
}
if (ch === 'G') {
return detailScrollRef.current?.scrollToBottom?.()
}
return
}
// List mode.
if ((key.return || key.rightArrow || ch === 'l') && selected) {
return setMode('detail')
}
if (key.upArrow || ch === 'k' || key.wheelUp) {
return setCursor(c => Math.max(0, c - 1))
}
if (key.downArrow || ch === 'j' || key.wheelDown) {
return setCursor(c => Math.min(Math.max(0, rows.length - 1), c + 1))
}
if (ch === 'g') {
return setCursor(0)
}
if (ch === 'G') {
return setCursor(Math.max(0, rows.length - 1))
}
if (ch === 's') {
return setSort(m => cycle(SORT_ORDER, m))
}
if (ch === 'f') {
return setFilter(m => cycle(FILTER_ORDER, m))
}
})
// ── Header assembly ────────────────────────────────────────────────
const mix = Object.entries(
subagents.reduce<Record<string, number>>((acc, it) => {
const key = it.model ? it.model.split('/').pop()! : 'inherit'
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {})
)
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([k, v]) => `${k}×${v}`)
.join(' · ')
const capsLabel = delegation.maxSpawnDepth
? `caps d${delegation.maxSpawnDepth}/${delegation.maxConcurrentChildren ?? '?'}`
: ''
const title =
replayMode && effectiveSnapshot
? `${historyIndex > 0 ? `Replay ${historyIndex}/${history.length}` : 'Last turn'} · finished ${new Date(
effectiveSnapshot.finishedAt
).toLocaleTimeString()}`
: `Spawn tree${delegation.paused ? ' · ⏸ paused' : ''}`
const metaLine = [formatSummary(totals), spark, capsLabel, mix ? `· ${mix}` : ''].filter(Boolean).join(' ')
const controlsHint = replayMode
? ' · controls locked'
: ` · x kill · X subtree · p ${delegation.paused ? 'resume' : 'pause'}`
// ── Rendering ──────────────────────────────────────────────────────
if (diffPair) {
return <DiffView cols={cols} onClose={closeWithCleanup} pair={diffPair} t={t} />
}
return (
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text wrap="truncate-end">
<Text bold color={replayMode ? t.color.bronze : t.color.gold}>
{title}
</Text>
{metaLine ? (
<Text color={t.color.dim}>
{' '}
{metaLine}
</Text>
) : null}
</Text>
</Box>
{rows.length === 0 ? (
<Box flexDirection="column" flexGrow={1}>
<Text color={t.color.dim}>No subagents this turn. Trigger delegate_task to populate the tree.</Text>
</Box>
) : mode === 'list' ? (
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
<GanttStrip cols={cols} cursor={cursor} flatNodes={rows} maxRows={6} now={now} t={t} />
<Box flexDirection="column" flexGrow={0} flexShrink={0} overflow="hidden">
{rows.slice(listWindowStart, listWindowStart + rowsH).map((node, i) => (
<ListRow
active={listWindowStart + i === cursor}
index={listWindowStart + i}
key={node.item.id}
node={node}
peak={peak}
t={t}
width={cols}
/>
))}
</Box>
</Box>
) : (
<Box flexDirection="row" flexGrow={1} flexShrink={1} minHeight={0}>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={detailScrollRef}>
<Box flexDirection="column" paddingBottom={4} paddingRight={1}>
{selected ? <Detail id={formatRowId(cursor).trim()} node={selected} t={t} /> : null}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<OverlayScrollbar scrollRef={detailScrollRef} t={t} tick={now} />
</NoSelect>
</Box>
)}
<Box flexDirection="column" marginTop={1}>
{flash ? <Text color={t.color.amber}>{flash}</Text> : null}
{mode === 'list' ? (
<Text color={t.color.dim}>
/jk move · g/G top/bottom · Enter/ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter:
{FILTER_LABEL[filter]}
{history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''}
{' · q close'}
</Text>
) : (
<Text color={t.color.dim}>
/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/ back to list{controlsHint} · q close
</Text>
)}
</Box>
</Box>
)
}
interface AgentsOverlayProps {
gw: GatewayClient
initialHistoryIndex?: number
onClose: () => void
t: Theme
}
export const closeAgentsOverlay = () => patchOverlayState({ agents: false })
export const openAgentsOverlay = () => patchOverlayState({ agents: true })