@animalabs/connectome-host 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -10
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +12 -1
- package/docs/AGENT-ONBOARDING.md +1 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +2 -2
- package/scripts/audit-module-optins.ts +288 -0
- package/src/framework-strategy.ts +13 -4
- package/src/headless.ts +14 -0
- package/src/index.ts +12 -9
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/mcpl-admin-module.ts +33 -4
- package/src/modules/retrieval-module.ts +249 -51
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +55 -4
- package/src/retrieval-config.ts +39 -0
- package/src/strategies/frontdesk-strategy.ts +34 -125
- package/src/tui.ts +325 -54
- package/src/web/panel-data.ts +1187 -0
- package/src/web/protocol.ts +75 -10
- package/test/audit-module-optins.test.ts +167 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-strategy-defaults.test.ts +22 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/headless-panel-request.test.ts +201 -0
- package/test/mcpl-admin-module.test.ts +23 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/retrieval-auth-loopback.test.ts +49 -0
- package/test/retrieval-config.test.ts +74 -0
- package/test/retrieval-module.test.ts +821 -0
- package/test/tui-format.test.ts +106 -0
- package/test/web-ui-context-coverage.test.ts +1 -1
- package/test/web-ui-module.test.ts +189 -3
- package/test/web-ui-observers.test.ts +8 -5
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +159 -44
- package/web/src/Context.tsx +35 -8
- package/web/src/ContextDocument.tsx +20 -5
- package/web/src/Files.tsx +2 -8
- package/web/src/Lessons.tsx +2 -38
- package/web/src/Mcpl.tsx +80 -14
- package/web/src/Pins.tsx +5 -0
- package/web/src/Settings.tsx +5 -0
- package/web/vite.config.ts +8 -2
package/src/tui.ts
CHANGED
|
@@ -47,6 +47,64 @@ export function fmtTokens(n: number): string {
|
|
|
47
47
|
return String(n);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Format elapsed seconds for humans: 48s / 5m48s / 1h02m. */
|
|
51
|
+
export function fmtElapsed(seconds: number): string {
|
|
52
|
+
if (seconds < 60) return `${seconds}s`;
|
|
53
|
+
const min = Math.floor(seconds / 60);
|
|
54
|
+
if (min < 60) return `${min}m${String(seconds % 60).padStart(2, '0')}s`;
|
|
55
|
+
return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How close a context is to its budget. Drives status-bar color escalation:
|
|
60
|
+
* the operator's real question is not "how many tokens" but "how close to
|
|
61
|
+
* compression/trouble" — a bare number can't answer that.
|
|
62
|
+
*/
|
|
63
|
+
export function ctxSeverity(ctx: number, budget?: number): 'ok' | 'warn' | 'high' {
|
|
64
|
+
if (!budget || budget <= 0 || ctx <= 0) return 'ok';
|
|
65
|
+
const frac = ctx / budget;
|
|
66
|
+
return frac >= 0.9 ? 'high' : frac >= 0.75 ? 'warn' : 'ok';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Alert kinds that always win the status-bar label, in priority order. */
|
|
70
|
+
const ALERT_KIND_PRIORITY = ['compression-quarantine', 'inference-exhausted'];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Which alert kind the status bar should name when several are active.
|
|
74
|
+
* Priority kinds (quarantine, hard-down) win; otherwise the most recent
|
|
75
|
+
* (= last inserted, since upserts keep their original Map position but new
|
|
76
|
+
* kinds append).
|
|
77
|
+
*/
|
|
78
|
+
export function pickTopAlert(kinds: string[]): string | null {
|
|
79
|
+
if (kinds.length === 0) return null;
|
|
80
|
+
for (const p of ALERT_KIND_PRIORITY) {
|
|
81
|
+
if (kinds.includes(p)) return p;
|
|
82
|
+
}
|
|
83
|
+
return kinds[kinds.length - 1] ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Viewport slice for a cursor-driven line list: which [start, end) range of
|
|
88
|
+
* `len` lines fits in `avail` rows while keeping `cursor` visible. Callers
|
|
89
|
+
* render a `┈ N above ┈` marker when start > 0 and `┈ N below ┈` when
|
|
90
|
+
* end < len — the marker rows are budgeted for here, which is why the body
|
|
91
|
+
* grows by one at either edge (that marker doesn't render).
|
|
92
|
+
*/
|
|
93
|
+
export function sliceViewport(len: number, cursor: number, avail: number): { start: number; end: number } {
|
|
94
|
+
if (len <= avail) return { start: 0, end: len };
|
|
95
|
+
const body = Math.max(1, avail - 2);
|
|
96
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(body / 2), len - body));
|
|
97
|
+
const end = start + body;
|
|
98
|
+
if (start === 0) return { start, end: Math.min(len, Math.max(1, avail - 1)) };
|
|
99
|
+
if (end >= len) return { start: Math.max(0, len - Math.max(1, avail - 1)), end: len };
|
|
100
|
+
return { start, end };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Local wall-clock HH:MM, for event-line timestamps. */
|
|
104
|
+
function hhmm(now = new Date()): string {
|
|
105
|
+
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
50
108
|
/**
|
|
51
109
|
* What a submission at the armed /quit prompt means. Pure so the semantics
|
|
52
110
|
* are pinned by tests: the default for arbitrary input is CANCEL — the
|
|
@@ -120,6 +178,10 @@ interface OpsAlertEntry {
|
|
|
120
178
|
interface TuiState {
|
|
121
179
|
status: string;
|
|
122
180
|
tool: string | null;
|
|
181
|
+
/** When the currently-running root-agent tool started (epoch ms). Lets the
|
|
182
|
+
* status bar show a running elapsed on slow tools — the "is it stuck?"
|
|
183
|
+
* answer without switching views. */
|
|
184
|
+
toolStartedAt: number | null;
|
|
123
185
|
subagents: ActiveSubagent[];
|
|
124
186
|
/**
|
|
125
187
|
* chat — conversation + stream
|
|
@@ -223,6 +285,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
223
285
|
const state: TuiState = {
|
|
224
286
|
status: 'idle',
|
|
225
287
|
tool: null,
|
|
288
|
+
toolStartedAt: null,
|
|
226
289
|
subagents: [],
|
|
227
290
|
viewMode: 'chat',
|
|
228
291
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
@@ -245,7 +308,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
245
308
|
if (kind.endsWith('-clear')) {
|
|
246
309
|
const baseKey = `${agent}:${kind.slice(0, -'-clear'.length)}`;
|
|
247
310
|
if (opsAlerts.delete(baseKey)) {
|
|
248
|
-
|
|
311
|
+
addEvent(`✓ [${agent}] ${kind}: ${message}`, CYAN);
|
|
249
312
|
updateStatus();
|
|
250
313
|
}
|
|
251
314
|
return;
|
|
@@ -254,7 +317,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
254
317
|
const existing = opsAlerts.get(key);
|
|
255
318
|
opsAlerts.set(key, { kind, agent, message, count: (existing?.count ?? 0) + 1 });
|
|
256
319
|
const times = existing ? ` (×${existing.count + 1})` : '';
|
|
257
|
-
|
|
320
|
+
addEvent(`⚠ [${agent}] ${kind}${times}: ${message}`, RED);
|
|
258
321
|
updateStatus();
|
|
259
322
|
}
|
|
260
323
|
|
|
@@ -502,12 +565,50 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
502
565
|
pruneScrollback();
|
|
503
566
|
}
|
|
504
567
|
|
|
568
|
+
/** addLine with an HH:MM prefix — for *event* lines (alerts, tool batches,
|
|
569
|
+
* subagent results, branch switches…): scrollback read an hour later is
|
|
570
|
+
* useless without knowing WHEN things happened. Streamed prose and
|
|
571
|
+
* immediate-feedback hints stay unstamped. */
|
|
572
|
+
function addEvent(text: string, color: string = WHITE) {
|
|
573
|
+
addLine(`${hhmm()} ${text}`, color);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Live context budget for an agent. Runtime overrides (persisted in the
|
|
578
|
+
* `framework/state` slot) win over the recipe — same seam the web module's
|
|
579
|
+
* /curve endpoint uses; reading only the recipe plots the wrong gauge on
|
|
580
|
+
* any agent whose budget was ever changed at runtime.
|
|
581
|
+
*/
|
|
582
|
+
function getAgentBudget(name: string): number | undefined {
|
|
583
|
+
try {
|
|
584
|
+
const live = (app.framework as unknown as {
|
|
585
|
+
getAgentRuntimeSettings?: (n: string) => { contextBudgetTokens?: number } | undefined;
|
|
586
|
+
}).getAgentRuntimeSettings?.(name)?.contextBudgetTokens;
|
|
587
|
+
if (typeof live === 'number' && live > 0) return live;
|
|
588
|
+
} catch { /* fall through to recipe */ }
|
|
589
|
+
return name === rootAgentName ? app.recipe?.agent?.contextBudgetTokens : undefined;
|
|
590
|
+
}
|
|
591
|
+
|
|
505
592
|
function updateStatus() {
|
|
506
|
-
|
|
593
|
+
const topAlert = pickTopAlert([...opsAlerts.values()].map(a => a.kind));
|
|
594
|
+
let left = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens, opsAlerts.size, topAlert);
|
|
507
595
|
// An active ops alert repaints the whole left segment — a one-cell glyph
|
|
508
596
|
// in default gray is exactly the kind of signal that gets scrolled past.
|
|
509
597
|
statusLeft.fg = opsAlerts.size > 0 ? RED : GRAY;
|
|
510
|
-
|
|
598
|
+
|
|
599
|
+
const budget = getAgentBudget(rootAgentName);
|
|
600
|
+
const right = formatTokens(state.tokens, verboseChat, state.ctxTokens, budget) + formatMemStats(getRootCM());
|
|
601
|
+
// Same escalation as the ctx gauge itself: the right segment goes yellow
|
|
602
|
+
// at 75% of budget, red at 90% — visible even when the numbers aren't read.
|
|
603
|
+
const sev = ctxSeverity(state.ctxTokens, budget);
|
|
604
|
+
statusRight.fg = sev === 'high' ? RED : sev === 'warn' ? YELLOW : DIM_GRAY;
|
|
605
|
+
statusRight.content = right;
|
|
606
|
+
|
|
607
|
+
// Width budget: long tool names + peek target + alerts must lose to the
|
|
608
|
+
// tokens/mem segment, not shove it off the row.
|
|
609
|
+
const maxLeft = Math.max(12, renderer.terminalWidth - right.length - 4);
|
|
610
|
+
if (left.length > maxLeft) left = left.slice(0, maxLeft - 1) + '…';
|
|
611
|
+
statusLeft.content = left;
|
|
511
612
|
}
|
|
512
613
|
|
|
513
614
|
/** Best-effort handle to the root agent's ContextManager, for stats queries. */
|
|
@@ -523,6 +624,23 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
523
624
|
|
|
524
625
|
let currentStreamBlockType: StreamBlockType = 'text';
|
|
525
626
|
|
|
627
|
+
// Terse mode (Ctrl+V off) collapses live thinking into a single counting
|
|
628
|
+
// line instead of streaming the full monologue — the toggle's label always
|
|
629
|
+
// promised "showing agent thoughts" but thinking streamed regardless.
|
|
630
|
+
let thinkingCollapsed = false;
|
|
631
|
+
let thinkingCollapsedChars = 0;
|
|
632
|
+
|
|
633
|
+
/** Freeze a collapsed thinking line at its final size when the stream
|
|
634
|
+
* leaves the thinking lane (or ends). */
|
|
635
|
+
function finalizeCollapsedThinking() {
|
|
636
|
+
if (!thinkingCollapsed) return;
|
|
637
|
+
if (currentStreamText && currentStreamBlockType === 'thinking') {
|
|
638
|
+
currentStreamText.content = `${THINKING_PREFIX}(thought, ~${fmtTokens(Math.ceil(thinkingCollapsedChars / 4))} tok)`;
|
|
639
|
+
}
|
|
640
|
+
thinkingCollapsed = false;
|
|
641
|
+
thinkingCollapsedChars = 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
526
644
|
function beginStream() {
|
|
527
645
|
currentStreamBuffer = '';
|
|
528
646
|
currentStreamBlockType = 'text';
|
|
@@ -545,6 +663,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
545
663
|
*/
|
|
546
664
|
function switchStreamBlock(blockType: StreamBlockType) {
|
|
547
665
|
if (currentStreamBlockType === blockType && currentStreamText) return;
|
|
666
|
+
if (blockType !== 'thinking') finalizeCollapsedThinking();
|
|
548
667
|
// Tool blocks (tool_call / tool_result) aren't rendered as a stream lane
|
|
549
668
|
// here — they're surfaced via the tool:* trace events instead. We still
|
|
550
669
|
// need to update currentStreamBlockType so that when tokens swing back to
|
|
@@ -558,7 +677,11 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
558
677
|
currentStreamBuffer = '';
|
|
559
678
|
currentStreamBlockType = blockType;
|
|
560
679
|
if (blockType === 'thinking') {
|
|
561
|
-
|
|
680
|
+
if (!verboseChat) {
|
|
681
|
+
thinkingCollapsed = true;
|
|
682
|
+
thinkingCollapsedChars = 0;
|
|
683
|
+
}
|
|
684
|
+
currentStreamBuffer = thinkingCollapsed ? `${THINKING_PREFIX}thinking…` : THINKING_PREFIX;
|
|
562
685
|
currentStreamText = new TextRenderable(renderer, {
|
|
563
686
|
id: `stream-thinking-${++messageCounter}`,
|
|
564
687
|
content: currentStreamBuffer,
|
|
@@ -576,27 +699,42 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
576
699
|
}
|
|
577
700
|
|
|
578
701
|
function streamToken(text: string) {
|
|
579
|
-
if (currentStreamText)
|
|
580
|
-
|
|
581
|
-
|
|
702
|
+
if (!currentStreamText) return;
|
|
703
|
+
if (thinkingCollapsed && currentStreamBlockType === 'thinking') {
|
|
704
|
+
// Count instead of print: liveness without the wall of text.
|
|
705
|
+
thinkingCollapsedChars += text.length;
|
|
706
|
+
currentStreamText.content = `${THINKING_PREFIX}thinking… ~${fmtTokens(Math.ceil(thinkingCollapsedChars / 4))} tok`;
|
|
707
|
+
return;
|
|
582
708
|
}
|
|
709
|
+
currentStreamBuffer += text;
|
|
710
|
+
currentStreamText.content = currentStreamBuffer;
|
|
583
711
|
}
|
|
584
712
|
|
|
585
713
|
function endStream() {
|
|
714
|
+
finalizeCollapsedThinking();
|
|
586
715
|
streaming = false;
|
|
587
716
|
currentStreamText = null;
|
|
588
717
|
currentStreamBuffer = '';
|
|
589
718
|
currentStreamBlockType = 'text';
|
|
590
719
|
}
|
|
591
720
|
|
|
721
|
+
/** Replay at most this many messages on startup/branch-switch. A long
|
|
722
|
+
* session replayed in full floods scrollback with thousands of lines
|
|
723
|
+
* (thinking included) before the user can type; the full history is one
|
|
724
|
+
* /curve or web-UI visit away. */
|
|
725
|
+
const HISTORY_REPLAY_MAX = 50;
|
|
726
|
+
|
|
592
727
|
function loadSessionHistory() {
|
|
593
728
|
const agent = app.framework.getAgent(rootAgentName);
|
|
594
729
|
if (!agent) return;
|
|
595
730
|
const cm = agent.getContextManager();
|
|
596
|
-
const
|
|
597
|
-
if (
|
|
731
|
+
const all = cm.getAllMessages();
|
|
732
|
+
if (all.length === 0) return;
|
|
733
|
+
const messages = all.length > HISTORY_REPLAY_MAX ? all.slice(-HISTORY_REPLAY_MAX) : all;
|
|
598
734
|
|
|
599
|
-
addLine(
|
|
735
|
+
addLine(messages.length < all.length
|
|
736
|
+
? `── session history (last ${messages.length} of ${all.length} messages — full history in the web UI) ──`
|
|
737
|
+
: `── session history (${messages.length} messages) ──`, DIM_GRAY);
|
|
600
738
|
|
|
601
739
|
for (const msg of messages) {
|
|
602
740
|
const toolNames: string[] = [];
|
|
@@ -611,7 +749,10 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
611
749
|
} else if (block.type === 'thinking') {
|
|
612
750
|
const t = (block as { thinking?: string }).thinking;
|
|
613
751
|
if (t && t.trim()) {
|
|
614
|
-
|
|
752
|
+
// Replayed thinking is context, not content — one truncated line
|
|
753
|
+
// per block keeps the flavor without re-flooding scrollback.
|
|
754
|
+
const line = t.trim().replace(/\s+/g, ' ');
|
|
755
|
+
addLine(`${THINKING_PREFIX}${line.length > 120 ? line.slice(0, 117) + '…' : line}`, THINKING_DIM);
|
|
615
756
|
}
|
|
616
757
|
} else if (block.type === 'tool_use') {
|
|
617
758
|
toolNames.push((block as { name: string }).name);
|
|
@@ -637,6 +778,8 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
637
778
|
streaming = false;
|
|
638
779
|
currentStreamText = null;
|
|
639
780
|
currentStreamBuffer = '';
|
|
781
|
+
thinkingCollapsed = false;
|
|
782
|
+
thinkingCollapsedChars = 0;
|
|
640
783
|
|
|
641
784
|
// Clear conversation display (destroy frees the native text buffers)
|
|
642
785
|
const children = [...scrollBox.getChildren()];
|
|
@@ -681,6 +824,10 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
681
824
|
* child (a manual collapse afterward sticks). */
|
|
682
825
|
const seenFleetHeaders = new Set<string>();
|
|
683
826
|
let fleetCursor = 0;
|
|
827
|
+
/** Line index (within the tree-lines array) of the cursor's header row —
|
|
828
|
+
* set during renderNode, consumed by the viewport slice so the cursor can
|
|
829
|
+
* never walk below the fold into rows the terminal isn't showing. */
|
|
830
|
+
let fleetCursorLine = 0;
|
|
684
831
|
/** Ordered list of node IDs in current rendering (for cursor navigation). */
|
|
685
832
|
let visibleNodeIds: string[] = [];
|
|
686
833
|
/** Maps node ID → FleetNode for the currently-rendered tree, so keypress
|
|
@@ -919,12 +1066,12 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
919
1066
|
// Postmortem 2026-05-28 P1 #4: 'cancelled' is a third terminal state
|
|
920
1067
|
// (zombie reclaim, user cancel). Show it distinctly so the operator
|
|
921
1068
|
// can tell which subagents ended on a benign cancel vs. a fault.
|
|
922
|
-
statusTag = sa.status === 'completed' ? `done ${elapsed}
|
|
923
|
-
: sa.status === 'cancelled' ? `cancelled ${elapsed}
|
|
924
|
-
: `failed ${elapsed}
|
|
1069
|
+
statusTag = sa.status === 'completed' ? `done ${fmtElapsed(elapsed)}`
|
|
1070
|
+
: sa.status === 'cancelled' ? `cancelled ${fmtElapsed(elapsed)}`
|
|
1071
|
+
: `failed ${fmtElapsed(elapsed)}`;
|
|
925
1072
|
} else {
|
|
926
1073
|
const phase = subagentPhase.get(sa.name) ?? 'sending';
|
|
927
|
-
statusTag = `${phase} ${elapsed}
|
|
1074
|
+
statusTag = `${phase} ${fmtElapsed(elapsed)}`;
|
|
928
1075
|
}
|
|
929
1076
|
} else if (node.kind === 'fleet-child') {
|
|
930
1077
|
const fc = fleetMod?.getChildren().get(node.fleetChildName!);
|
|
@@ -935,15 +1082,15 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
935
1082
|
// any agent is busy, surface that phase instead so the header
|
|
936
1083
|
// reflects what's actually happening.
|
|
937
1084
|
const active = rollupActivePhase(treeAggregator?.getChildNodes(node.fleetChildName!) ?? []);
|
|
938
|
-
statusTag = active ? `${active} ${elapsed}
|
|
1085
|
+
statusTag = active ? `${active} ${fmtElapsed(elapsed)}` : `ready ${fmtElapsed(elapsed)}`;
|
|
939
1086
|
} else {
|
|
940
|
-
statusTag = fc ? `${fc.status} ${elapsed}
|
|
1087
|
+
statusTag = fc ? `${fc.status} ${fmtElapsed(elapsed)}` : 'unknown';
|
|
941
1088
|
}
|
|
942
1089
|
} else {
|
|
943
1090
|
// fleet-child-agent
|
|
944
1091
|
const rn = node.reducerNode!;
|
|
945
1092
|
const elapsed = rn.startedAt ? Math.floor(((rn.completedAt ?? Date.now()) - rn.startedAt) / 1000) : 0;
|
|
946
|
-
statusTag = `${rn.phase} ${elapsed}
|
|
1093
|
+
statusTag = `${rn.phase} ${fmtElapsed(elapsed)}`;
|
|
947
1094
|
}
|
|
948
1095
|
|
|
949
1096
|
// Context size: local maps for researcher/subagent, reducer node for fleet-child-agent.
|
|
@@ -954,7 +1101,15 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
954
1101
|
} else if (node.kind !== 'fleet-child') {
|
|
955
1102
|
ctxTokens = agentContextTokens.get(node.fullName) ?? agentContextTokens.get(node.name);
|
|
956
1103
|
}
|
|
957
|
-
|
|
1104
|
+
// Local agents get the gauge form (142k/180k) — their budget is readable
|
|
1105
|
+
// from this process. Fleet-child agents live in another process whose
|
|
1106
|
+
// budgets we don't see over the IPC; a bare number is the honest display.
|
|
1107
|
+
const ctxBudget = ctxTokens && node.kind !== 'fleet-child-agent' && node.kind !== 'fleet-child'
|
|
1108
|
+
? getAgentBudget(node.fullName)
|
|
1109
|
+
: undefined;
|
|
1110
|
+
const ctxStr = ctxTokens
|
|
1111
|
+
? (ctxBudget ? ` ${fmtK(ctxTokens)}/${fmtK(ctxBudget)}ctx` : ` ${fmtK(ctxTokens)}ctx`)
|
|
1112
|
+
: '';
|
|
958
1113
|
|
|
959
1114
|
// Compression stats (researcher only — we can access the strategy)
|
|
960
1115
|
let compStr = '';
|
|
@@ -977,6 +1132,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
977
1132
|
|
|
978
1133
|
// Header line (this is a navigable node)
|
|
979
1134
|
const isCursor = visibleNodeIds.length === fleetCursor;
|
|
1135
|
+
if (isCursor) fleetCursorLine = lines.length;
|
|
980
1136
|
const cursor = isCursor ? '→' : ' ';
|
|
981
1137
|
visibleNodeIds.push(node.name);
|
|
982
1138
|
visibleNodes.set(node.name, node);
|
|
@@ -1081,30 +1237,97 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1081
1237
|
if (state.viewMode === 'fleet') updateFleetView();
|
|
1082
1238
|
}, 6000);
|
|
1083
1239
|
// Also record it in scrollback so it survives after the notice fades.
|
|
1084
|
-
|
|
1240
|
+
addEvent(` ${text}`, RED);
|
|
1085
1241
|
if (state.viewMode === 'fleet') updateFleetView();
|
|
1086
1242
|
}
|
|
1087
1243
|
|
|
1244
|
+
/** One-line fleet totals: local subagent states, fleet-child processes,
|
|
1245
|
+
* session cost. The at-a-glance row the ops view opens with. */
|
|
1246
|
+
function fleetSummaryLine(): string {
|
|
1247
|
+
let running = 0, done = 0, failed = 0, cancelled = 0;
|
|
1248
|
+
for (const sa of state.subagents) {
|
|
1249
|
+
if (sa.status === 'running') running++;
|
|
1250
|
+
else if (sa.status === 'completed') done++;
|
|
1251
|
+
else if (sa.status === 'cancelled') cancelled++;
|
|
1252
|
+
else failed++;
|
|
1253
|
+
}
|
|
1254
|
+
// Fleet-child agents: count from the reducers, using the same phase
|
|
1255
|
+
// classification the tree paints with. 'idle' is neither running nor
|
|
1256
|
+
// done (a child's root agent idles between rounds), so it's not counted.
|
|
1257
|
+
if (treeAggregator && fleetMod) {
|
|
1258
|
+
for (const childName of fleetMod.getChildren().keys()) {
|
|
1259
|
+
for (const rn of treeAggregator.getChildNodes(childName)) {
|
|
1260
|
+
if (rn.status === 'failed') failed++;
|
|
1261
|
+
else if (rn.phase === 'done') done++;
|
|
1262
|
+
else if (rn.phase === 'cancelled') cancelled++;
|
|
1263
|
+
else if (PHASE_PRIORITY[rn.phase as SubagentPhase] !== undefined) running++;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const parts: string[] = [];
|
|
1268
|
+
const counts: string[] = [];
|
|
1269
|
+
if (running > 0) counts.push(`${running} running`);
|
|
1270
|
+
if (done > 0) counts.push(`${done} done`);
|
|
1271
|
+
if (failed > 0) counts.push(`${failed} failed`);
|
|
1272
|
+
if (cancelled > 0) counts.push(`${cancelled} cancelled`);
|
|
1273
|
+
parts.push(counts.length > 0 ? `agents: ${counts.join(' · ')}` : 'agents: none');
|
|
1274
|
+
if (fleetMod) {
|
|
1275
|
+
const children = [...fleetMod.getChildren().values()];
|
|
1276
|
+
if (children.length > 0) {
|
|
1277
|
+
const up = children.filter(c => c.status === 'ready' || c.status === 'starting').length;
|
|
1278
|
+
const crashed = children.filter(c => c.status === 'crashed').length;
|
|
1279
|
+
parts.push(`children: ${up}/${children.length} up${crashed > 0 ? ` (${crashed} crashed)` : ''}`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (state.tokens.cost && state.tokens.cost.total > 0) {
|
|
1283
|
+
parts.push(`Σ $${state.tokens.cost.total.toFixed(state.tokens.cost.total < 1 ? 3 : 2)}`);
|
|
1284
|
+
}
|
|
1285
|
+
return ` ${parts.join(' ')}`;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1088
1288
|
function updateFleetView() {
|
|
1089
1289
|
const tree = buildFleetTree();
|
|
1090
1290
|
visibleNodeIds = [];
|
|
1091
1291
|
visibleNodes.clear();
|
|
1292
|
+
fleetCursorLine = 0;
|
|
1092
1293
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1294
|
+
// Header block — pinned above the scrolling tree region.
|
|
1295
|
+
const header: FleetLine[] = [];
|
|
1296
|
+
header.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart Esc:chat ───', color: GRAY });
|
|
1095
1297
|
if (fleetNotice) {
|
|
1096
|
-
|
|
1298
|
+
header.push({ text: ` ⚠ ${fleetNotice}`, color: RED });
|
|
1097
1299
|
}
|
|
1098
|
-
|
|
1300
|
+
// Active ops alerts, in full — the status bar only has room for a count,
|
|
1301
|
+
// and the firing lines are somewhere back in chat scrollback. This is the
|
|
1302
|
+
// ops view; the ringing klaxons belong at the top of it.
|
|
1303
|
+
const alerts = [...opsAlerts.values()];
|
|
1304
|
+
for (const a of alerts.slice(0, 3)) {
|
|
1305
|
+
const msg = a.message.length > 70 ? a.message.slice(0, 67) + '…' : a.message;
|
|
1306
|
+
header.push({ text: ` ⚠ [${a.agent}] ${a.kind}${a.count > 1 ? ` ×${a.count}` : ''} — ${msg}`, color: RED });
|
|
1307
|
+
}
|
|
1308
|
+
if (alerts.length > 3) {
|
|
1309
|
+
header.push({ text: ` ⚠ … and ${alerts.length - 3} more`, color: RED });
|
|
1310
|
+
}
|
|
1311
|
+
header.push({ text: fleetSummaryLine(), color: GRAY });
|
|
1312
|
+
header.push({ text: '', color: GRAY });
|
|
1099
1313
|
|
|
1100
|
-
|
|
1314
|
+
const treeLines: FleetLine[] = [];
|
|
1315
|
+
renderNode(tree, 0, treeLines);
|
|
1101
1316
|
|
|
1102
1317
|
// Clamp cursor
|
|
1103
1318
|
if (fleetCursor >= visibleNodeIds.length) fleetCursor = visibleNodeIds.length - 1;
|
|
1104
1319
|
if (fleetCursor < 0) fleetCursor = 0;
|
|
1105
1320
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1321
|
+
// Viewport: the tree region gets whatever rows the header leaves of the
|
|
1322
|
+
// fleetBox (terminalHeight - 3: status bar, input row, paddingTop). Without
|
|
1323
|
+
// this, a real fleet outgrows the terminal and the cursor walks below the
|
|
1324
|
+
// fold — navigating (and Del:stopping) rows the operator cannot see.
|
|
1325
|
+
const avail = Math.max(5, renderer.terminalHeight - 3 - header.length);
|
|
1326
|
+
const { start, end } = sliceViewport(treeLines.length, fleetCursorLine, avail);
|
|
1327
|
+
const lines: FleetLine[] = [...header];
|
|
1328
|
+
if (start > 0) lines.push({ text: ` ┈ ${start} lines above ┈`, color: DIM_GRAY });
|
|
1329
|
+
lines.push(...treeLines.slice(start, end));
|
|
1330
|
+
if (end < treeLines.length) lines.push({ text: ` ┈ ${treeLines.length - end} lines below ┈`, color: DIM_GRAY });
|
|
1108
1331
|
|
|
1109
1332
|
// Rebuild fleetBox children: clear old, add new per-line renderables
|
|
1110
1333
|
clearFleetBox();
|
|
@@ -1152,7 +1375,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1152
1375
|
: rn.phase === 'idle' || rn.phase === 'done' || rn.phase === 'cancelled' ? DIM_GRAY
|
|
1153
1376
|
: PHASE_COLOR[rn.phase as SubagentPhase] ?? CYAN;
|
|
1154
1377
|
const ctx = rn.tokens.input > 0 ? ` ${fmtK(rn.tokens.input)}ctx` : '';
|
|
1155
|
-
lines.push({ text: ` ${rn.phase} ${elapsed}
|
|
1378
|
+
lines.push({ text: ` ${rn.phase} ${fmtElapsed(elapsed)} ${rn.toolCallsCount} tool calls${ctx}`, color: phaseColor });
|
|
1156
1379
|
if (rn.task) {
|
|
1157
1380
|
const task = rn.task.length > 70 ? rn.task.slice(0, 67) + '...' : rn.task;
|
|
1158
1381
|
lines.push({ text: ` task: ${task}`, color: GRAY });
|
|
@@ -1165,9 +1388,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1165
1388
|
}
|
|
1166
1389
|
} else if (child) {
|
|
1167
1390
|
const elapsed = Math.floor((Date.now() - child.startedAt) / 1000);
|
|
1168
|
-
const
|
|
1169
|
-
const sec = elapsed % 60;
|
|
1170
|
-
const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
|
|
1391
|
+
const timeStr = fmtElapsed(elapsed);
|
|
1171
1392
|
const statusColor =
|
|
1172
1393
|
child.status === 'ready' ? CYAN :
|
|
1173
1394
|
child.status === 'starting' ? YELLOW :
|
|
@@ -1400,9 +1621,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1400
1621
|
// their final runtime, not a clock that keeps counting after done.
|
|
1401
1622
|
const endTime = sa.completedAt ?? Date.now();
|
|
1402
1623
|
const elapsed = Math.floor((endTime - sa.startedAt) / 1000);
|
|
1403
|
-
const
|
|
1404
|
-
const sec = elapsed % 60;
|
|
1405
|
-
const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
|
|
1624
|
+
const timeStr = fmtElapsed(elapsed);
|
|
1406
1625
|
const statusColor = sa.status === 'running' ? CYAN : sa.status === 'failed' ? RED : DIM_GRAY;
|
|
1407
1626
|
lines.push({ text: ` ${sa.status} ${timeStr} ${sa.toolCallsCount} tool calls`, color: statusColor });
|
|
1408
1627
|
|
|
@@ -1557,12 +1776,13 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1557
1776
|
if (agent === rootAgentName) {
|
|
1558
1777
|
state.status = 'idle';
|
|
1559
1778
|
state.tool = null;
|
|
1779
|
+
state.toolStartedAt = null;
|
|
1560
1780
|
if (backgrounded) {
|
|
1561
1781
|
// Researcher returned from background — show accumulated output as a message
|
|
1562
1782
|
if (backgroundBuffer.trim()) {
|
|
1563
1783
|
addLine(backgroundBuffer, WHITE);
|
|
1564
1784
|
}
|
|
1565
|
-
|
|
1785
|
+
addEvent(' (researcher returned from background)', CYAN);
|
|
1566
1786
|
backgrounded = false;
|
|
1567
1787
|
backgroundBuffer = '';
|
|
1568
1788
|
}
|
|
@@ -1600,14 +1820,14 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1600
1820
|
backgroundBuffer = '';
|
|
1601
1821
|
}
|
|
1602
1822
|
if (streaming) endStream();
|
|
1603
|
-
|
|
1823
|
+
addEvent(`Error: ${event.error}`, RED);
|
|
1604
1824
|
updateStatus();
|
|
1605
1825
|
} else {
|
|
1606
1826
|
if (agent) {
|
|
1607
1827
|
const short = shortAgentName(agent);
|
|
1608
1828
|
subagentPhase.set(short, 'failed');
|
|
1609
1829
|
}
|
|
1610
|
-
|
|
1830
|
+
addEvent(`[${agent}] Error: ${event.error}`, DIM_GRAY);
|
|
1611
1831
|
}
|
|
1612
1832
|
break;
|
|
1613
1833
|
}
|
|
@@ -1638,7 +1858,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1638
1858
|
state.status = backgrounded ? 'background' : 'tools';
|
|
1639
1859
|
state.tool = names;
|
|
1640
1860
|
if (streaming) endStream();
|
|
1641
|
-
if (!backgrounded)
|
|
1861
|
+
if (!backgrounded) addEvent(`[tools] ${names}`, YELLOW);
|
|
1642
1862
|
} else {
|
|
1643
1863
|
const short = shortAgentName(agent ?? '');
|
|
1644
1864
|
addLine(` [${short}] ${names}`, DIM_GRAY);
|
|
@@ -1670,7 +1890,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1670
1890
|
const summary = (pe.metadata.eventSummary as string) ?? '';
|
|
1671
1891
|
const snippet = summary.length > 60 ? summary.slice(0, 57) + '...' : summary;
|
|
1672
1892
|
const label = subs.join(', ');
|
|
1673
|
-
|
|
1893
|
+
addEvent(`\u2691 wake triggered: ${label} \u2014 "${snippet}"`, YELLOW);
|
|
1674
1894
|
}
|
|
1675
1895
|
break;
|
|
1676
1896
|
}
|
|
@@ -1679,6 +1899,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1679
1899
|
const tool = event.tool as string;
|
|
1680
1900
|
if (agent === rootAgentName) {
|
|
1681
1901
|
state.tool = tool;
|
|
1902
|
+
state.toolStartedAt = Date.now();
|
|
1682
1903
|
updateStatus();
|
|
1683
1904
|
}
|
|
1684
1905
|
// Show file operations in chat
|
|
@@ -1705,17 +1926,30 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1705
1926
|
break;
|
|
1706
1927
|
}
|
|
1707
1928
|
|
|
1708
|
-
case 'tool:completed':
|
|
1929
|
+
case 'tool:completed': {
|
|
1930
|
+
// Root-agent tools used to be fire-and-forget: after "[tools] x, y"
|
|
1931
|
+
// only failures ever printed, so success and stuck looked identical.
|
|
1932
|
+
// Verbose shows every completion; terse only the slow ones (≥2s).
|
|
1933
|
+
if (agent === rootAgentName) {
|
|
1934
|
+
state.toolStartedAt = null;
|
|
1935
|
+
const tool = event.tool as string;
|
|
1936
|
+
const durMs = typeof event.durationMs === 'number' ? event.durationMs : undefined;
|
|
1937
|
+
if (verboseChat || (durMs !== undefined && durMs >= 2000)) {
|
|
1938
|
+
addLine(` ✓ ${tool}${durMs !== undefined ? ` (${(durMs / 1000).toFixed(1)}s)` : ''}`, DIM_GRAY);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1709
1941
|
break;
|
|
1942
|
+
}
|
|
1710
1943
|
|
|
1711
1944
|
case 'tool:failed': {
|
|
1712
1945
|
const tool = event.tool as string;
|
|
1713
1946
|
const error = event.error as string;
|
|
1714
1947
|
if (agent === rootAgentName) {
|
|
1715
|
-
|
|
1948
|
+
state.toolStartedAt = null;
|
|
1949
|
+
addEvent(`[tool error] ${tool}: ${error}`, RED);
|
|
1716
1950
|
} else if (agent) {
|
|
1717
1951
|
const short = shortAgentName(agent);
|
|
1718
|
-
|
|
1952
|
+
addEvent(` [${short}] tool error: ${tool}: ${error}`, RED);
|
|
1719
1953
|
}
|
|
1720
1954
|
break;
|
|
1721
1955
|
}
|
|
@@ -1727,13 +1961,15 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1727
1961
|
const source = event.source as string;
|
|
1728
1962
|
|
|
1729
1963
|
if (branchEvent === 'switched') {
|
|
1730
|
-
addLine(`Branch switched: ${previous ?? '?'} → ${branch} (via ${source})`, CYAN);
|
|
1731
1964
|
resetBranchState(app.branchState);
|
|
1965
|
+
// Announce AFTER the refresh — refreshFromStore clears the
|
|
1966
|
+
// scrollbox, so a line printed first was destroyed unread.
|
|
1732
1967
|
refreshFromStore();
|
|
1968
|
+
addEvent(`Branch switched: ${previous ?? '?'} → ${branch} (via ${source})`, CYAN);
|
|
1733
1969
|
} else if (branchEvent === 'created') {
|
|
1734
|
-
|
|
1970
|
+
addEvent(`Branch created: ${branch} (via ${source})`, CYAN);
|
|
1735
1971
|
} else if (branchEvent === 'deleted') {
|
|
1736
|
-
|
|
1972
|
+
addEvent(`Branch deleted: ${branch} (via ${source})`, CYAN);
|
|
1737
1973
|
}
|
|
1738
1974
|
updateStatus();
|
|
1739
1975
|
break;
|
|
@@ -1833,6 +2069,22 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1833
2069
|
// nicely is in peek-proc's dedicated renderer, not this formatter.
|
|
1834
2070
|
return null;
|
|
1835
2071
|
}
|
|
2072
|
+
case 'inference:content_block':
|
|
2073
|
+
case 'inference:usage':
|
|
2074
|
+
case 'usage:updated':
|
|
2075
|
+
// High-frequency bookkeeping events — a `· type` dot line per block/
|
|
2076
|
+
// round is pure noise between the lines that carry meaning.
|
|
2077
|
+
return null;
|
|
2078
|
+
case 'ops:alert': {
|
|
2079
|
+
// The single most important thing a child can say must not fall
|
|
2080
|
+
// through to the dim default-dot rendering.
|
|
2081
|
+
const kind = typeof get('kind') === 'string' ? get('kind') as string : 'unknown';
|
|
2082
|
+
const msg = typeof get('message') === 'string' ? get('message') as string : '';
|
|
2083
|
+
const who = typeof get('agentName') === 'string' ? ` [${get('agentName') as string}]` : '';
|
|
2084
|
+
return kind.endsWith('-clear')
|
|
2085
|
+
? { text: `✓${who} ${kind}: ${msg}`, color: CYAN }
|
|
2086
|
+
: { text: `⚠${who} ${kind}: ${msg}`, color: RED };
|
|
2087
|
+
}
|
|
1836
2088
|
case 'tool:started': return { text: ` ⟳ ${get('tool') as string}`, color: YELLOW };
|
|
1837
2089
|
case 'tool:completed': return { text: ` ✓ ${get('tool') as string} (${get('durationMs') ?? '?'}ms)`, color: CYAN };
|
|
1838
2090
|
case 'tool:failed': return { text: ` ✗ ${get('tool') as string}: ${get('error') as string}`, color: RED };
|
|
@@ -1940,7 +2192,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1940
2192
|
// of the summary; terse shows a shorter, dimmer line.
|
|
1941
2193
|
const limit = verboseChat ? 200 : 100;
|
|
1942
2194
|
const chatTruncated = summary.length > limit ? summary.slice(0, limit - 3) + '...' : summary;
|
|
1943
|
-
|
|
2195
|
+
addEvent(` ◀ [${name}] ${chatTruncated}`, verboseChat ? CYAN : DIM_GRAY);
|
|
1944
2196
|
break;
|
|
1945
2197
|
}
|
|
1946
2198
|
}
|
|
@@ -2001,10 +2253,12 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
2001
2253
|
for (const sa of state.subagents) {
|
|
2002
2254
|
subscribeSubagentStream(sa.name);
|
|
2003
2255
|
}
|
|
2004
|
-
updateStatus();
|
|
2005
2256
|
if (state.viewMode === 'fleet') updateFleetView();
|
|
2006
2257
|
else if (state.viewMode === 'peek') updatePeekView();
|
|
2007
2258
|
}
|
|
2259
|
+
// Unconditional: the spinner and the slow-tool elapsed readout repaint on
|
|
2260
|
+
// this tick even when no subagent module is loaded.
|
|
2261
|
+
updateStatus();
|
|
2008
2262
|
if (fleetMod) {
|
|
2009
2263
|
// Pick up fleet children that were launched after TUI init so the
|
|
2010
2264
|
// aggregator can request describe + start folding their event stream.
|
|
@@ -2395,7 +2649,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
2395
2649
|
addLine(` (unknown child: ${route.childName})`, RED);
|
|
2396
2650
|
return;
|
|
2397
2651
|
}
|
|
2398
|
-
|
|
2652
|
+
addEvent(`You → @${route.childName}: ${route.content}`, CYAN);
|
|
2399
2653
|
fleetMod.handleToolCall({
|
|
2400
2654
|
id: `tui-route-${Date.now()}`,
|
|
2401
2655
|
name: 'send',
|
|
@@ -2407,7 +2661,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
2407
2661
|
});
|
|
2408
2662
|
return;
|
|
2409
2663
|
}
|
|
2410
|
-
|
|
2664
|
+
addEvent(`You: ${raw}`, GREEN);
|
|
2411
2665
|
const agent = app.framework.getAgent(rootAgentName);
|
|
2412
2666
|
const agentBusy = agent && (agent.state.status === 'streaming' || agent.state.status === 'inferring' || agent.state.status === 'waiting_for_tools');
|
|
2413
2667
|
state.status = agentBusy ? 'queued' : 'thinking';
|
|
@@ -2465,10 +2719,16 @@ function formatStatusLeft(
|
|
|
2465
2719
|
spinnerChar?: string,
|
|
2466
2720
|
outputTokens?: number,
|
|
2467
2721
|
alertCount = 0,
|
|
2722
|
+
topAlertKind: string | null = null,
|
|
2468
2723
|
): string {
|
|
2469
2724
|
const sColor = state.status === 'idle' ? '✓' : state.status === 'error' ? '✗' : state.status === 'background' ? '↓' : state.status === 'queued' ? '⏳' : '…';
|
|
2470
2725
|
let bar = `[${sColor} ${state.status}`;
|
|
2471
|
-
if (alertCount > 0)
|
|
2726
|
+
if (alertCount > 0) {
|
|
2727
|
+
// Name the worst active alert — "⚠ 2 alerts" forces a trip back through
|
|
2728
|
+
// scrollback to learn WHICH klaxon is ringing.
|
|
2729
|
+
const kind = topAlertKind && topAlertKind.length > 26 ? topAlertKind.slice(0, 25) + '…' : topAlertKind;
|
|
2730
|
+
bar += ` | ⚠ ${alertCount}${kind ? ` · ${kind}` : ''}`;
|
|
2731
|
+
}
|
|
2472
2732
|
if (spinnerChar !== undefined && state.status !== 'idle' && state.status !== 'error' && state.status !== 'background') {
|
|
2473
2733
|
bar += ` ${spinnerChar}`;
|
|
2474
2734
|
if (state.status === 'thinking' && outputTokens !== undefined && outputTokens > 0) {
|
|
@@ -2481,6 +2741,12 @@ function formatStatusLeft(
|
|
|
2481
2741
|
// right status segment (tokens/cost/mem) clean off the row.
|
|
2482
2742
|
const tool = state.tool.length > 40 ? state.tool.slice(0, 37) + '…' : state.tool;
|
|
2483
2743
|
bar += ` | ${tool}`;
|
|
2744
|
+
// Slow tool running: show elapsed so "still executing" and "stuck" stop
|
|
2745
|
+
// looking identical. Repainted by the 500ms poll tick.
|
|
2746
|
+
if (state.toolStartedAt) {
|
|
2747
|
+
const secs = Math.floor((Date.now() - state.toolStartedAt) / 1000);
|
|
2748
|
+
if (secs >= 5) bar += ` ${fmtElapsed(secs)}`;
|
|
2749
|
+
}
|
|
2484
2750
|
}
|
|
2485
2751
|
const running = state.subagents.filter(s => s.status === 'running').length;
|
|
2486
2752
|
if (running > 0) {
|
|
@@ -2501,12 +2767,17 @@ function formatStatusLeft(
|
|
|
2501
2767
|
return bar;
|
|
2502
2768
|
}
|
|
2503
2769
|
|
|
2504
|
-
function formatTokens(tokens: TokenUsage, verbose: boolean, ctxTokens = 0): string {
|
|
2770
|
+
function formatTokens(tokens: TokenUsage, verbose: boolean, ctxTokens = 0, ctxBudget?: number): string {
|
|
2505
2771
|
const parts: string[] = [];
|
|
2506
2772
|
|
|
2507
2773
|
// Current context size first, session totals (Σ) after — two different
|
|
2508
|
-
// quantities, two labels.
|
|
2509
|
-
|
|
2774
|
+
// quantities, two labels. With a known budget the readout is a gauge
|
|
2775
|
+
// (142k/180k), not a trivia number.
|
|
2776
|
+
if (ctxTokens > 0) {
|
|
2777
|
+
parts.push(ctxBudget && ctxBudget > 0
|
|
2778
|
+
? `ctx:${fmtTokens(ctxTokens)}/${fmtTokens(ctxBudget)}`
|
|
2779
|
+
: `ctx:${fmtTokens(ctxTokens)}`);
|
|
2780
|
+
}
|
|
2510
2781
|
|
|
2511
2782
|
const total = tokens.input + tokens.output;
|
|
2512
2783
|
if (total > 0) {
|