From eda400d8a58c8d6261dd752010cc0e0e90663f44 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 11:32:17 -0500 Subject: [PATCH] chore: uptick --- tui_gateway/server.py | 56 +++++--- ui-tui/src/app/createGatewayEventHandler.ts | 55 +++++--- ui-tui/src/app/turnController.ts | 14 +- ui-tui/src/components/agentsOverlay.tsx | 136 +++++++++++++------- ui-tui/src/components/textInput.tsx | 9 +- 5 files changed, 176 insertions(+), 94 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 50cfa966a..5dd33814d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -868,7 +868,12 @@ def _on_tool_progress( if _kwargs.get("toolsets"): payload["toolsets"] = [str(t) for t in _kwargs["toolsets"]] # Per-branch rollups emitted on subagent.complete (features 1+2+4). - for int_key in ("input_tokens", "output_tokens", "reasoning_tokens", "api_calls"): + for int_key in ( + "input_tokens", + "output_tokens", + "reasoning_tokens", + "api_calls", + ): val = _kwargs.get(int_key) if val is not None: try: @@ -1738,16 +1743,20 @@ def _(rid, params: dict) -> dict: # Layout: $HERMES_HOME/spawn-trees//.json # Each file contains { session_id, started_at, finished_at, subagents: [...] }. + def _spawn_trees_root(): from pathlib import Path as _P from hermes_constants import get_hermes_home + root = get_hermes_home() / "spawn-trees" root.mkdir(parents=True, exist_ok=True) return root def _spawn_tree_session_dir(session_id: str): - safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown" + safe = ( + "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown" + ) d = _spawn_trees_root() / safe d.mkdir(parents=True, exist_ok=True) return d @@ -1797,6 +1806,7 @@ def _(rid, params: dict) -> dict: return _err(rid, 4000, "subagents list required") from datetime import datetime + started_at = params.get("started_at") finished_at = params.get("finished_at") or time.time() label = str(params.get("label") or "") @@ -1816,14 +1826,17 @@ def _(rid, params: dict) -> dict: except OSError as exc: return _err(rid, 5000, f"spawn_tree.save failed: {exc}") - _append_spawn_tree_index(d, { - "path": str(path), - "session_id": session_id, - "started_at": payload["started_at"], - "finished_at": payload["finished_at"], - "label": label, - "count": len(subagents), - }) + _append_spawn_tree_index( + d, + { + "path": str(path), + "session_id": session_id, + "started_at": payload["started_at"], + "finished_at": payload["finished_at"], + "label": label, + "count": len(subagents), + }, + ) return _ok(rid, {"path": str(path), "session_id": session_id}) @@ -1845,7 +1858,9 @@ def _(rid, params: dict) -> dict: indexed = _read_spawn_tree_index(d) if indexed: # Skip index entries whose snapshot file was manually deleted. - entries.extend(e for e in indexed if (p := e.get("path")) and Path(p).exists()) + entries.extend( + e for e in indexed if (p := e.get("path")) and Path(p).exists() + ) continue # Fallback for legacy (pre-index) sessions: full scan. O(N) reads @@ -1860,14 +1875,16 @@ def _(rid, params: dict) -> dict: except Exception: raw = {} subagents = raw.get("subagents") or [] - entries.append({ - "path": str(p), - "session_id": raw.get("session_id") or d.name, - "finished_at": raw.get("finished_at") or stat.st_mtime, - "started_at": raw.get("started_at"), - "label": raw.get("label") or "", - "count": len(subagents) if isinstance(subagents, list) else 0, - }) + entries.append( + { + "path": str(p), + "session_id": raw.get("session_id") or d.name, + "finished_at": raw.get("finished_at") or stat.st_mtime, + "started_at": raw.get("started_at"), + "label": raw.get("label") or "", + "count": len(subagents) if isinstance(subagents, list) else 0, + } + ) except OSError: continue @@ -1878,6 +1895,7 @@ def _(rid, params: dict) -> dict: @method("spawn_tree.load") def _(rid, params: dict) -> dict: from pathlib import Path + raw_path = str(params.get("path") or "").strip() if not raw_path: return _err(rid, 4000, "path required") diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index cb9cd74b6..b3ef0d650 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -141,8 +141,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // Terminal statuses are never overwritten by late-arriving live events — // otherwise a stale `subagent.start` / `spawn_requested` can clobber a // `failed` or `interrupted` terminal state (Copilot review #14045). - const isTerminalStatus = (s: SubagentProgress['status']) => - s === 'completed' || s === 'failed' || s === 'interrupted' + const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted' const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running') @@ -410,10 +409,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - turnController.upsertSubagent(ev.payload, c => ({ - status: keepTerminalElseRunning(c.status), - thinking: pushThinking(c.thinking, text) - })) + // Update-only: never resurrect subagents whose spawn_requested/start + // we missed or that already flushed via message.complete. + turnController.upsertSubagent( + ev.payload, + c => ({ + status: keepTerminalElseRunning(c.status), + thinking: pushThinking(c.thinking, text) + }), + { createIfMissing: false } + ) return } @@ -424,10 +429,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ev.payload.tool_preview ?? ev.payload.text ?? '' ) - turnController.upsertSubagent(ev.payload, c => ({ - status: keepTerminalElseRunning(c.status), - tools: pushTool(c.tools, line) - })) + turnController.upsertSubagent( + ev.payload, + c => ({ + status: keepTerminalElseRunning(c.status), + tools: pushTool(c.tools, line) + }), + { createIfMissing: false } + ) return } @@ -439,20 +448,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - turnController.upsertSubagent(ev.payload, c => ({ - notes: pushNote(c.notes, text), - status: keepTerminalElseRunning(c.status) - })) + turnController.upsertSubagent( + ev.payload, + c => ({ + notes: pushNote(c.notes, text), + status: keepTerminalElseRunning(c.status) + }), + { createIfMissing: false } + ) return } case 'subagent.complete': - turnController.upsertSubagent(ev.payload, c => ({ - durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, - status: ev.payload.status ?? 'completed', - summary: ev.payload.summary || ev.payload.text || c.summary - })) + turnController.upsertSubagent( + ev.payload, + c => ({ + durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, + status: ev.payload.status ?? 'completed', + summary: ev.payload.summary || ev.payload.text || c.summary + }), + { createIfMissing: false } + ) return diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 07da79019..804394bb1 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -460,7 +460,11 @@ class TurnController { patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) } - upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial) { + upsertSubagent( + p: SubagentEventPayload, + patch: (current: SubagentProgress) => Partial, + opts: { createIfMissing?: boolean } = { createIfMissing: true } + ) { // Stable id: prefer the server-issued subagent_id (survives nested // grandchildren + cross-tree joins). Fall back to the composite key // for older gateways that omit the field — those produce a flat list. @@ -469,6 +473,14 @@ class TurnController { patchTurnState(state => { const existing = state.subagents.find(item => item.id === id) + // Late events (subagent.complete/tool/progress arriving after message.complete + // has already fired idle()) would otherwise resurrect a finished + // subagent into turn.subagents and block the "finished" title on the + // /agents overlay. When `createIfMissing` is false we drop silently. + if (!existing && !opts.createIfMissing) { + return state + } + const base: SubagentProgress = existing ?? { depth: p.depth ?? 0, goal: p.goal, diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index c9136fae5..c91ca460b 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -104,6 +104,35 @@ const fmtDur = (seconds?: number): string => { return s === 0 ? `${m}m` : `${m}m ${s}s` } +/** Server duration if present; else live edge from `startedAt` (running / queued). */ +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 +} + +/** Like fmtDur but allows 0s for just-started / still-running rows. */ +const fmtElapsedLabel = (seconds: number): string => { + if (seconds < 0) { + return '' + } + + if (seconds < 60) { + return `${Math.max(0, Math.round(seconds))}s` + } + + const m = Math.floor(seconds / 60) + const s = Math.round(seconds - m * 60) + + return s === 0 ? `${m}m` : `${m}m ${s}s` +} + 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]! @@ -229,8 +258,11 @@ function GanttStrip({ 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 barWidth = Math.max(10, cols - idGutter - 2) + 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) @@ -242,15 +274,26 @@ function GanttStrip({ return ' '.repeat(s) + '█'.repeat(fill) + ' '.repeat(Math.max(0, barWidth - s - fill)) } - // Tick ruler + second labels. Fixed-length char array guarantees - // `.length === barWidth` (an earlier padEnd+skip loop wrapped to a - // second row which looked like garbled duplicated labels). - const ruler = Array.from({ length: barWidth }, (_, i) => (i > 0 && i % 10 === 0 ? '┼' : '─')).join('') + // Wall-clock axis: more ticks on short windows so the scale visibly + // “counts up” with `now` instead of a single 0/10s pair. + 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 += 10) { + 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` @@ -268,7 +311,7 @@ function GanttStrip({ return ( - Timeline · {fmtDur(totalSeconds)} + Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))} {windowLabel} @@ -277,21 +320,24 @@ function GanttStrip({ const { color } = statusGlyph(node.item, t) const accent = active ? t.color.amber : t.color.dim - const durLabel = node.item.durationSeconds - ? fmtDur(node.item.durationSeconds) - : node.item.status === 'running' - ? 'running' - : '' + const elSec = displayElapsedSeconds(node.item, now) + const elLabel = elSec != null ? fmtElapsedLabel(elSec) : '' return ( - {formatRowId(idx)}{' '} + {formatRowId(idx)} + {' '} {bar(startAt, endAt)} - {durLabel ? {durLabel} : null} + {elLabel ? ( + + {' '} + {elLabel} + + ) : null} ) })} @@ -301,7 +347,7 @@ function GanttStrip({ {ruler} - {totalSeconds >= 2 ? ( + {totalSeconds > 0 ? ( {' '} {rulerLabels} @@ -504,6 +550,7 @@ function ListRow({ active, index, node, + now, peak, t, width @@ -511,6 +558,7 @@ function ListRow({ active: boolean index: number node: SubagentNode + now: number peak: number t: Theme width: number @@ -523,35 +571,24 @@ function ListRow({ const goal = compactPreview(node.item.goal || 'subagent', width - 24 - node.item.depth * 2) const tools = node.aggregate.totalTools > 0 ? ` ·${node.aggregate.totalTools}t` : '' const kids = node.children.length ? ` ·${node.children.length}↓` : '' - const dur = fmtDur(node.item.durationSeconds) + const elSec = displayElapsedSeconds(node.item, now) + const elapsed = elSec != null ? fmtElapsedLabel(elSec) : '' // Selection pattern mirrors sessionPicker: inverse + amber for contrast // across any theme, body stays cornsilk, stats dim. const fg = active ? t.color.amber : t.color.cornsilk - // Heat marker + glyph occupy a fixed 3-char gutter so the goal text - // aligns across hot and cool rows. One space on each side of the glyph - // gives the status dot breathing room — otherwise it reads glued to the - // heat bar and the goal text. - const prefix = heatMarker ? ( - - ) : ( - {' '} - ) - return ( - {active ? '▸ ' : ' '} - {formatRowId(index)} + {' '} + {formatRowId(index)} {indentFor(node.item.depth)} - {prefix} - {glyph} - {' '} - {goal} + {heatMarker ? : null} + {glyph} {goal} {tools} {kids} - {dur ? ` · ${dur}` : ''} + {elapsed ? ` · ${elapsed}` : ''} ) @@ -943,20 +980,23 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent ? `caps d${delegation.maxSpawnDepth}/${delegation.maxConcurrentChildren ?? '?'}` : '' - // One-line title. An earlier version had a separate "subtitle" with the - // full snapshot label; narrow terminals wrapped it instead of truncating, - // which looked like the header was double-rendered. + // Single header line — title · metrics. An earlier "title + subtitle" + // variant wrapped on narrow terminals which looked like the header was + // rendering twice, and a one-line header makes it obvious at a glance + // whether the turn is live or finished. const title = (() => { if (!replayMode || !effectiveSnapshot) { return `Spawn tree${delegation.paused ? ' · ⏸ paused' : ''}` } const at = new Date(effectiveSnapshot.finishedAt).toLocaleTimeString() - const position = historyIndex > 0 ? `Replay · ${historyIndex}/${history.length}` : 'Last turn' + const position = historyIndex > 0 ? `Replay ${historyIndex}/${history.length}` : 'Last turn' - return `${position} · finished ${at}` + return `${position} · finished ${at}` })() + 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'}` @@ -970,15 +1010,16 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent return ( - - {title} - - - - {formatSummary(totals)} - {spark ? ` ${spark}` : ''} - {capsLabel ? ` ${capsLabel}` : ''} - {mix ? ` · ${mix}` : ''} + + + {title} + + {metaLine ? ( + + {' '} + {metaLine} + + ) : null} @@ -997,6 +1038,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent index={listWindowStart + i} key={node.item.id} node={node} + now={now} peak={peak} t={t} width={cols} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index d5380faa2..12b228c1f 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -615,14 +615,7 @@ export function TextInput({ return } - if ( - (k.ctrl && inp === 'c') || - k.tab || - (k.shift && k.tab) || - k.pageUp || - k.pageDown || - k.escape - ) { + if ((k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) { return }