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 = { 'depth-first': 'spawn order', 'duration-desc': 'slowest', status: 'status', 'tools-desc': 'busiest' } const FILTER_LABEL: Record = { all: 'all', failed: 'failed', leaf: 'leaves', running: 'running' } const STATUS_RANK: Record = { failed: 0, interrupted: 1, running: 2, queued: 3, completed: 4 } const SORT_COMPARATORS: Record 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 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 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 = (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 t: Theme tick: number }) { void tick // ensures re-render when the parent clock advances const [hover, setHover] = useState(false) const [grab, setGrab] = useState(null) const s = scrollRef.current const vp = Math.max(0, s?.getViewportHeight() ?? 0) if (!vp) { return } 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 ( { 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 ? ( {vBar(vp)} ) : ( <> {thumbTop > 0 ? ( {vBar(thumbTop)} ) : null} {thumbBody} {below > 0 ? ( {vBar(below)} ) : null} )} ) } 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 ( Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))} {windowLabel} {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 ( {formatRowId(idx)} {' '} {bar(startAt, endAt)} {elLabel ? ( {' '} {elLabel} ) : null} ) })} {' '} {ruler} {totalSeconds > 0 ? ( {' '} {rulerLabels} ) : null} ) } 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 ( toggleOverlaySection(title, defaultOpen)}> {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} {open ? {children} : null} ) } function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) { return ( {name} · {value} ) } 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 ( {id ? #{id} : null} {glyph} {item.goal} {item.model ? : null} {item.toolsets?.length ? : null} {item.durationSeconds ? : null} {item.iteration != null ? : null} {item.apiCalls ? : null} {localTokens > 0 || localCost > 0 ? ( {localTokens > 0 ? ( {fmtTokens(inputTokens)} in · {fmtTokens(outputTokens)} out {item.reasoningTokens ? ` · ${fmtTokens(item.reasoningTokens)} reasoning` : ''} } /> ) : null} {localCost > 0 ? ( {fmtCost(localCost)} {subtreeCost >= 0.01 ? ` · subtree +${fmtCost(subtreeCost)}` : ''} } /> ) : null} {subtreeTokens > 0 ? : null} ) : null} {filesRead.length > 0 || filesWritten.length > 0 ? ( {filesWritten.slice(0, 8).map((p, i) => ( +{p} ))} {filesRead.slice(0, 8).map((p, i) => ( · {p} ))} {filesOverflow > 0 ? …+{filesOverflow} more : null} ) : null} {toolLines.length > 0 ? ( {toolLines.map((line, i) => ( · {line} ))} ) : null} {outputTail.length > 0 ? ( {outputTail.map((entry, i) => ( {entry.tool} {' '} {entry.preview} ))} ) : null} {item.notes.length ? ( {item.notes.slice(-6).map((line, i) => ( · {line} ))} ) : null} {item.summary ? ( {item.summary} ) : null} ) } 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 ( {' '} {formatRowId(index)} {indentFor(node.item.depth)} {heatMarker ? : null} {glyph} {goal} {toolsCount} {kids} {trailing} ) } function DiffPane({ label, snapshot, t, totals, width }: { label: string snapshot: SpawnSnapshot t: Theme totals: ReturnType width: number }) { return ( {label} {snapshot.label} {formatSummary(totals)} {topLevelSubagents(snapshot.subagents) .slice(0, 8) .map(s => { const { color, glyph } = statusGlyph(s, t) return ( {glyph} {s.goal || 'subagent'} ) })} ) } 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 ( Replay diff baseline vs candidate · esc/q close Δ {diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)} {diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)} {diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)} {diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)} {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} {diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)} ) } // ── 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('depth-first') const [filter, setFilter] = useState('all') const [cursor, setCursor] = useState(0) const [flash, setFlash] = useState('') 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) 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('delegation.status', {}) .then(r => applyDelegationStatus(asRpcResult(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('subagent.interrupt', { subagent_id: id }) const killOne = (id: string) => guardLive(() => { interrupt(id) .then(raw => { const r = asRpcResult(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('delegation.pause', { paused: !delegation.paused }) .then(raw => { const r = asRpcResult(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>((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 } return ( {title} {metaLine ? ( {' '} {metaLine} ) : null} {rows.length === 0 ? ( No subagents this turn. Trigger delegate_task to populate the tree. ) : mode === 'list' ? ( {rows.slice(listWindowStart, listWindowStart + rowsH).map((node, i) => ( ))} ) : ( {selected ? : null} )} {flash ? {flash} : null} {mode === 'list' ? ( ↑↓/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'} ) : ( ↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close )} ) } interface AgentsOverlayProps { gw: GatewayClient initialHistoryIndex?: number onClose: () => void t: Theme } export const closeAgentsOverlay = () => patchOverlayState({ agents: false }) export const openAgentsOverlay = () => patchOverlayState({ agents: true })