pi-better-subagents 0.1.0

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/navigator.mjs ADDED
@@ -0,0 +1,1188 @@
1
+ /**
2
+ * Pure seams for the TUI subagent navigator (issues #45 list, #46 detail,
3
+ * #47 two-press close, #69 health display).
4
+ *
5
+ * Kept free of pi / pi-tui imports (mirrors widget.mjs) so unit tests pin the
6
+ * behavior contracts without a live TUI. index.ts injects the pi-specific
7
+ * pieces (matchesKey, truncateToWidth, CustomEditor, ctx, getDetail, closeRun)
8
+ * at the boundary.
9
+ *
10
+ * The navigator is a HUMAN organization surface: it reads the #44 registry
11
+ * visibility seams (`navigatorVisibleRuns` / `navigatorVisibleCount`). List and
12
+ * detail views are read-only; the #47 Close action is the sole mutator, and it
13
+ * goes through the shared #44 stop/dismiss seams (never a second persistence
14
+ * model). The passive live widget is untouched by this module.
15
+ *
16
+ * Detail view (#46) refreshes via an injected interval (default 1s) and must
17
+ * dispose that timer on every exit path (back, Escape, overlay close, teardown).
18
+ * Close arm timers (#47) dispose on the same paths plus selection change and
19
+ * list↔detail transitions.
20
+ *
21
+ * Health (#69) reuses #66/#67 observation helpers: navigator rows keep the
22
+ * scan order name/id · model[+effort] · elapsed · tool · spend, then append
23
+ * durable status + ≤2 compact health facts. Status is colorized semantically
24
+ * with visible-width-aware truncation.
25
+ */
26
+
27
+ import {
28
+ formatNavigatorHealthFacts,
29
+ statusThemeColor,
30
+ truncateToVisibleWidth,
31
+ visibleWidth,
32
+ } from "./health-surface.mjs";
33
+
34
+ /** Detail-view refresh cadence (ms). Mirrors the live widget tick. */
35
+ export const DETAIL_TICK_MS = 1000;
36
+
37
+ /** Two-press Close confirmation window (ms). Exclusive at the boundary. */
38
+ export const CLOSE_ARM_MS = 3000;
39
+
40
+ /** Footer status key for the `← subagents · N` hint (default footer mechanism). */
41
+ export const NAVIGATOR_STATUS_KEY = "subagents-nav";
42
+
43
+ /** Footer status key for the Close confirmation hint (coexists with count hint). */
44
+ export const CLOSE_CONFIRM_STATUS_KEY = "subagents-close";
45
+
46
+ /**
47
+ * Footer hint text: `← subagents · N` while at least one visible
48
+ * current-parent run exists, null when none (caller clears the status).
49
+ */
50
+ export function navigatorFooterHint(count) {
51
+ return count > 0 ? `← subagents · ${count}` : null;
52
+ }
53
+
54
+ /**
55
+ * Publish the footer hint through pi's default footer status mechanism.
56
+ * Sets `← subagents · N` for count ≥ 1, clears the status at 0. Returns the
57
+ * hint (or null) so callers can dirty-check. NEVER touches the footer itself.
58
+ */
59
+ export function applyNavigatorFooter(ui, count) {
60
+ const hint = navigatorFooterHint(count);
61
+ ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
62
+ return hint;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Close confirmation (issue #47)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Footer confirmation hint while Close is armed.
71
+ * Running/orphaned → `x again to stop <name>`; terminal → `x again to dismiss <name>`.
72
+ * Name falls back to id when missing/null. Orphaned uses the stop wording because
73
+ * Close runs the shared #68 cleanup path (kill related work or finalize).
74
+ */
75
+ export function closeConfirmHint(row) {
76
+ if (!row) return null;
77
+ const label = (row.name != null && String(row.name).length > 0) ? String(row.name) : String(row.id);
78
+ const stoppable = row.status === "running" || row.status === "orphaned";
79
+ return stoppable ? `x again to stop ${label}` : `x again to dismiss ${label}`;
80
+ }
81
+
82
+ /**
83
+ * Publish/clear the Close confirmation hint through pi's default footer status
84
+ * mechanism (a dedicated key so it coexists with `← subagents · N`).
85
+ * Pass null/undefined to clear.
86
+ */
87
+ export function applyCloseConfirmFooter(ui, hint) {
88
+ ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
89
+ return hint ?? null;
90
+ }
91
+
92
+ /** Mutable Close-arm record. `id` null means disarmed. */
93
+ export function createCloseArm(id, armedAt) {
94
+ return { id: id ?? null, armedAt: armedAt ?? 0 };
95
+ }
96
+
97
+ /** True when `arm` is live for `id` at `now` (window is [armedAt, armedAt+CLOSE_ARM_MS)). */
98
+ export function isCloseArmed(arm, id, now, windowMs = CLOSE_ARM_MS) {
99
+ if (!arm || arm.id == null || id == null) return false;
100
+ if (arm.id !== id) return false;
101
+ if (typeof now !== "number" || typeof arm.armedAt !== "number") return false;
102
+ return now >= arm.armedAt && now < arm.armedAt + windowMs;
103
+ }
104
+
105
+ /** Clear an arm record in place. */
106
+ export function disarmClose(arm) {
107
+ if (!arm) return arm;
108
+ arm.id = null;
109
+ arm.armedAt = 0;
110
+ return arm;
111
+ }
112
+
113
+ /**
114
+ * Perform Close on a run id: reread meta + effective status, then either
115
+ * stop+dismiss (running / orphaned) or dismiss-only (terminal / finished-during-arm).
116
+ *
117
+ * Reuses #44/#68 `stopRun` / `dismissRun` — never invents a second kill path.
118
+ * For orphaned runs, dismiss happens only AFTER `stopRun` writes a terminal
119
+ * status (killed / completed / failed / lost).
120
+ * Unknown ids return `{ action: "missing" }` without throwing.
121
+ *
122
+ * @param {string} id
123
+ * @param {object} deps
124
+ * @param {(id: string) => object|undefined} deps.readMeta
125
+ * @param {(m: object) => string} deps.effectiveStatus
126
+ * @param {(id: string) => { action: string, id: string, status?: string }} deps.stopRun
127
+ * @param {(id: string, at?: number) => object|undefined} deps.dismissRun
128
+ * @param {() => number} [deps.now]
129
+ */
130
+ export function executeNavigatorClose(id, deps) {
131
+ const meta = deps.readMeta(id);
132
+ if (!meta) return { action: "missing", id };
133
+ const status = deps.effectiveStatus(meta);
134
+ const at = typeof deps.now === "function" ? deps.now() : Date.now();
135
+ // running + orphaned share the stop path (#68). stopRun itself rereads;
136
+ // we still branch on our fresh effective status so a finish-during-arm
137
+ // never reaches the kill path from a stale row.
138
+ if (status === "running" || status === "orphaned") {
139
+ const outcome = deps.stopRun(id);
140
+ // Dismiss only after a terminal write. stopRun always finalizes
141
+ // orphaned runs (killed / completed / failed / lost) when accepted.
142
+ const terminalStatus = outcome?.action === "stopped"
143
+ ? "killed"
144
+ : outcome?.action === "finalized"
145
+ ? outcome.status
146
+ : undefined;
147
+ if (!terminalStatus) {
148
+ // Defensive: stop declined (race to another terminal). Leave
149
+ // non-terminal orphaned undismissed so Close can be retried.
150
+ const after = deps.readMeta(id);
151
+ const afterStatus = after ? deps.effectiveStatus(after) : status;
152
+ if (afterStatus === "orphaned" || afterStatus === "running") {
153
+ return { action: "not-closed", id, status: afterStatus };
154
+ }
155
+ deps.dismissRun(id, at);
156
+ return { action: "dismissed", id, status: afterStatus };
157
+ }
158
+ deps.dismissRun(id, at);
159
+ return { action: "stopped-and-dismissed", id, status: terminalStatus };
160
+ }
161
+ deps.dismissRun(id, at);
162
+ return { action: "dismissed", id, status };
163
+ }
164
+
165
+ /**
166
+ * True only in an interactive TUI session with a UI present. Every navigator
167
+ * entry point is gated on this so print/RPC sessions stay untouched.
168
+ *
169
+ * Pi exposes `hasUI: true` (and a `ui` object) in BOTH TUI and RPC modes
170
+ * (extensions.md: `hasUI` guards dialog/notification methods that work in
171
+ * both). The navigator uses terminal-only features — the `custom()` overlay
172
+ * and the editor component factory — so the guard must require an explicit
173
+ * `ctx.mode === "tui"` on top of UI availability; `hasUI` alone would leak
174
+ * `setStatus`/`setEditorComponent`/`custom` calls into RPC sessions.
175
+ */
176
+ export function isNavigatorUiAvailable(ctx) {
177
+ return Boolean(ctx && ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
178
+ }
179
+
180
+ /**
181
+ * Display rows for the navigator list. Input order is preserved — callers pass
182
+ * `navigatorVisibleRuns(listMetas())`, which is newest first and already
183
+ * excludes dismissed and foreign-parent runs (running AND terminal included).
184
+ *
185
+ * @param {Array<object>} metas - visible RunMeta records (newest first)
186
+ * @param {object} deps
187
+ * @param {(m: object) => string} deps.effectiveStatus - registry effectiveStatus
188
+ * @param {(model?: string) => string} deps.shortModel - widget shortModel
189
+ * @param {(ms: number) => string} deps.fmtElapsed - widget fmtElapsed
190
+ * @param {(m: object) => string} deps.spendFor - spend summary or "" (e.g. fmtSpend(parseRun(id).usage))
191
+ * @param {(m: object) => string} [deps.toolFor] - current/used tool label or ""
192
+ * @param {(m: object) => string|undefined} [deps.effortFor] - model effort when available
193
+ * @param {(m: object) => object|undefined} [deps.healthFor] - #66 HealthObservation
194
+ * @param {number} [deps.now]
195
+ */
196
+ export function buildNavigatorRows(metas, deps) {
197
+ const now = deps.now ?? Date.now();
198
+ return metas.map((m) => {
199
+ const status = deps.effectiveStatus(m);
200
+ const health = typeof deps.healthFor === "function" ? deps.healthFor(m) : undefined;
201
+ const effort = typeof deps.effortFor === "function" ? deps.effortFor(m) : (m.effort ?? m.modelEffort);
202
+ const tool = typeof deps.toolFor === "function" ? (deps.toolFor(m) || "") : "";
203
+ return {
204
+ id: m.id,
205
+ name: m.name,
206
+ status,
207
+ model: deps.shortModel(m.model),
208
+ // Optional effort stays adjacent to model when the host exposes it.
209
+ effort: effort ? String(effort) : undefined,
210
+ // Terminal runs freeze elapsed at endedAt; running runs tick to now.
211
+ elapsed: deps.fmtElapsed((m.endedAt ?? now) - m.startedAt),
212
+ tool,
213
+ spend: deps.spendFor(m) || "",
214
+ // At most two compact facts; empty for healthy/quiet (#67/#69).
215
+ healthFacts: formatNavigatorHealthFacts(health),
216
+ };
217
+ });
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Selection state
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /** Navigator list state: the rows plus the selected index (starts at top). */
225
+ export function createNavigatorState(rows) {
226
+ const state = { rows: rows ?? [], selected: 0 };
227
+ clampSelection(state);
228
+ return state;
229
+ }
230
+
231
+ /** Keep the selection inside the current row list. */
232
+ export function clampSelection(state) {
233
+ if (state.rows.length === 0) state.selected = 0;
234
+ else if (state.selected > state.rows.length - 1) state.selected = state.rows.length - 1;
235
+ else if (state.selected < 0) state.selected = 0;
236
+ return state.selected;
237
+ }
238
+
239
+ /**
240
+ * Move the selection by delta (−1 up, +1 down), clamped at both ends.
241
+ * Returns true when the selection actually changed (callers repaint then).
242
+ */
243
+ export function moveSelection(state, delta) {
244
+ const before = state.selected;
245
+ state.selected = Math.min(
246
+ Math.max(state.selected + delta, 0),
247
+ Math.max(0, state.rows.length - 1),
248
+ );
249
+ return state.selected !== before;
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // List rendering
254
+ // ---------------------------------------------------------------------------
255
+
256
+ const SHEET_RULE = "━";
257
+ const SECTION_RULE = "─";
258
+ const LIST_ROW_START = 3;
259
+
260
+ function repeatRule(glyph, width) {
261
+ const n = Math.max(0, Math.floor(width));
262
+ return glyph.repeat(n);
263
+ }
264
+
265
+ function commandSheetRule(label, width) {
266
+ const w = Math.max(0, Math.floor(width));
267
+ if (w <= 0) return "";
268
+ const text = label != null && String(label).length > 0 ? String(label) : "";
269
+ if (!text) return repeatRule(SHEET_RULE, w);
270
+ const prefix = `${SHEET_RULE}${SHEET_RULE} ${text} `;
271
+ if (visibleWidth(prefix) >= w) return truncateToVisibleWidth(prefix, w);
272
+ return prefix + repeatRule(SHEET_RULE, w - visibleWidth(prefix));
273
+ }
274
+
275
+ function sectionRule(label, width) {
276
+ const w = Math.max(0, Math.floor(width));
277
+ if (w <= 0) return "";
278
+ const prefix = `${SECTION_RULE}${SECTION_RULE} ${label} `;
279
+ if (visibleWidth(prefix) >= w) return truncateToVisibleWidth(prefix, w);
280
+ return prefix + repeatRule(SECTION_RULE, w - visibleWidth(prefix));
281
+ }
282
+
283
+ function stripSectionRule(line) {
284
+ const plain = String(line ?? "");
285
+ const match = plain.match(/^──\s+(.+?)\s+─*$/u);
286
+ return match ? match[1] : null;
287
+ }
288
+
289
+ export function navigatorSectionLabel(line) {
290
+ return stripSectionRule(line);
291
+ }
292
+
293
+ /**
294
+ * Format one navigator row in scan order (#60/#69):
295
+ * name/id · model [· effort] · elapsed [· tool] [· spend] · status [· fact · fact]
296
+ * Status is colorized when `opts.colorizeStatus` is true (overlay path).
297
+ *
298
+ * @param {object} r - buildNavigatorRows entry
299
+ * @param {object} [opts]
300
+ * @param {boolean} [opts.colorizeStatus]
301
+ * @param {(color: string, s: string) => string} [opts.fg]
302
+ */
303
+ export function formatNavigatorRowText(r, opts = {}) {
304
+ const name = r.name ?? r.id ?? "?";
305
+ const model = r.model ?? "?";
306
+ const modelPart = r.effort ? `${model} ${r.effort}` : model;
307
+ const parts = [name, modelPart, r.elapsed ?? "?"];
308
+ if (r.tool) parts.push(r.tool);
309
+ if (r.spend) parts.push(r.spend);
310
+
311
+ const statusRaw = String(r.status ?? "?");
312
+ const statusText = opts.colorizeStatus && typeof opts.fg === "function"
313
+ ? opts.fg(statusThemeColor(statusRaw), statusRaw)
314
+ : statusRaw;
315
+ parts.push(statusText);
316
+
317
+ const facts = Array.isArray(r.healthFacts) ? r.healthFacts.filter(Boolean).slice(0, 2) : [];
318
+ for (const f of facts) parts.push(f);
319
+ return parts.join(" · ");
320
+ }
321
+
322
+ /**
323
+ * Plain-text navigator lines: title, one `> `/` `-prefixed line per row, and
324
+ * a help line. Every line is passed through a visible-width-aware truncate so
325
+ * the TUI width contract holds even when status is ANSI-colorized. index.ts
326
+ * injects pi-tui's truncateToWidth; when colorization is applied first we fall
327
+ * back to `truncateToVisibleWidth` if the injected truncator is width-naive.
328
+ *
329
+ * @param {object} state
330
+ * @param {object} [opts]
331
+ * @param {number} [opts.width]
332
+ * @param {(s: string, w: number) => string} [opts.truncate]
333
+ * @param {boolean} [opts.colorizeStatus] - colorize durable status (overlay)
334
+ * @param {(color: string, s: string) => string} [opts.fg]
335
+ */
336
+ export function buildNavigatorLines(state, opts = {}) {
337
+ const width = opts.width ?? 80;
338
+ const truncate = opts.truncate ?? truncateToVisibleWidth;
339
+ const rows = state.rows ?? [];
340
+ const lines = [commandSheetRule(`Subagents · ${rows.length}`, width)];
341
+ const selected = rows.length > 0 ? rows[state.selected] : null;
342
+ const stoppable = selected?.status === "running" || selected?.status === "orphaned";
343
+ const action = stoppable ? "x stop" : (selected ? "x dismiss" : null);
344
+ lines.push(` ${["↑↓ select", "Enter view", action, "Esc close"].filter(Boolean).join(" · ")}`);
345
+ lines.push("");
346
+ if (rows.length === 0) {
347
+ lines.push(" (no visible subagent runs)");
348
+ }
349
+ const colorize = opts.colorizeStatus === true;
350
+ const fg = opts.fg;
351
+ for (let i = 0; i < rows.length; i++) {
352
+ const r = rows[i];
353
+ const prefix = i === state.selected ? "› " : " ";
354
+ const body = formatNavigatorRowText(r, { colorizeStatus: colorize, fg });
355
+ lines.push(prefix + body);
356
+ }
357
+ lines.push("");
358
+ lines.push(commandSheetRule("", width));
359
+ return lines.map((l) => {
360
+ const cut = truncate(l, width);
361
+ // Guarantee visible width even if host truncator counts ANSI bytes.
362
+ return visibleWidth(cut) > width ? truncateToVisibleWidth(cut, width) : cut;
363
+ });
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Detail view (issue #46)
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /**
371
+ * Assemble a detail snapshot for one run. All I/O lives behind deps so the
372
+ * seam stays unit-testable; index.ts wires readMeta/parseRun/fmt*.
373
+ *
374
+ * @param {string} id
375
+ * @param {object} deps
376
+ * @param {(id: string) => object|undefined} deps.readMeta
377
+ * @param {(m: object) => string} deps.effectiveStatus
378
+ * @param {(id: string) => { finalText: string, lastActivity: string, toolCalls: string[], usage: object }} deps.parseRun
379
+ * @param {(model?: string) => string} deps.shortModel
380
+ * @param {(ms: number) => string} deps.fmtElapsed
381
+ * @param {(u: object) => string} deps.fmtSpend
382
+ * @param {(meta: object) => object|undefined} [deps.healthFor] - #66 HealthObservation
383
+ * @param {(meta: object) => string|undefined} [deps.effortFor]
384
+ * @param {number} [deps.now]
385
+ * @returns {object|null} detail fields, or null when the run is unknown
386
+ */
387
+ export function buildNavigatorDetail(id, deps) {
388
+ const meta = deps.readMeta(id);
389
+ if (!meta) return null;
390
+ const now = deps.now ?? Date.now();
391
+ const parsed = deps.parseRun(id);
392
+ const toolCalls = parsed.toolCalls ?? [];
393
+ const status = deps.effectiveStatus(meta);
394
+ const running = status === "running" || status === "orphaned";
395
+ const health = typeof deps.healthFor === "function" ? deps.healthFor(meta) : undefined;
396
+ const effortRaw = typeof deps.effortFor === "function"
397
+ ? deps.effortFor(meta)
398
+ : (meta.effort ?? meta.modelEffort);
399
+ return {
400
+ id: meta.id,
401
+ name: meta.name,
402
+ status,
403
+ model: deps.shortModel(meta.model),
404
+ effort: effortRaw ? String(effortRaw) : undefined,
405
+ elapsed: deps.fmtElapsed((meta.endedAt ?? now) - meta.startedAt),
406
+ tools: toolCalls.length ? toolCalls.join(", ") : "",
407
+ // Only a live run has a "current" tool (the latest invocation).
408
+ currentTool: running && toolCalls.length ? toolCalls[toolCalls.length - 1] : undefined,
409
+ spend: deps.fmtSpend(parsed.usage) || "",
410
+ // Terminal prefers the final answer; running shows the live activity.
411
+ output: (running ? (parsed.lastActivity || parsed.finalText) : (parsed.finalText || parsed.lastActivity)) || "",
412
+ // Process identity from durable metadata (#63).
413
+ pid: meta.pid,
414
+ pgid: meta.pgid,
415
+ pidStartTime: meta.pidStartTime,
416
+ orphanedAt: meta.orphanedAt,
417
+ lostAt: meta.lostAt,
418
+ orphanedCallbackSentAt: meta.orphanedCallbackSentAt,
419
+ lostCallbackSentAt: meta.lostCallbackSentAt,
420
+ // Full #66 observation for sectioned detail rendering (#69).
421
+ health: health ?? null,
422
+ now,
423
+ };
424
+ }
425
+
426
+ /** Format a ms age as a short label, or "—" when unknown. */
427
+ function fmtDetailAge(ms) {
428
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "—";
429
+ const s = Math.floor(ms / 1000);
430
+ if (s < 60) return `${s}s`;
431
+ const m = Math.floor(s / 60);
432
+ if (m < 60) return `${m}m`;
433
+ const h = Math.floor(m / 60);
434
+ return `${h}h${m % 60}m`;
435
+ }
436
+
437
+ /** Format an absolute ms timestamp relative to `now`, or absolute ISO-ish. */
438
+ function fmtDetailTs(ts, now) {
439
+ if (typeof ts !== "number" || !Number.isFinite(ts)) return "—";
440
+ if (typeof now === "number" && Number.isFinite(now)) {
441
+ return `${fmtDetailAge(Math.max(0, now - ts))} ago`;
442
+ }
443
+ return String(ts);
444
+ }
445
+
446
+ /**
447
+ * Plain-text detail lines with sectioned health (#69). Truncated with
448
+ * visible-width awareness so ANSI status colorization stays width-safe.
449
+ *
450
+ * Sections (when data is present):
451
+ * header (title, status, model/effort, elapsed, spend, tools summary)
452
+ * process / liveness
453
+ * activity
454
+ * compaction (separate from tool + model)
455
+ * active tool
456
+ * model call / error
457
+ * last meaningful activity / last log write
458
+ * thresholds
459
+ * callbacks
460
+ * output
461
+ */
462
+ export function buildDetailLines(detail, opts = {}) {
463
+ const width = opts.width ?? 80;
464
+ const truncate = opts.truncate ?? truncateToVisibleWidth;
465
+ const fg = typeof opts.fg === "function" ? opts.fg : null;
466
+ const colorize = opts.colorizeStatus === true && fg;
467
+ if (!detail) {
468
+ return [
469
+ commandSheetRule("Run unavailable", width),
470
+ " ← back · Esc close",
471
+ "",
472
+ commandSheetRule("", width),
473
+ ].map((l) => truncate(l, width));
474
+ }
475
+ const title = detail.name || detail.id || "?";
476
+ const lines = [];
477
+ lines.push(commandSheetRule(title, width));
478
+
479
+ const statusRaw = String(detail.status ?? "?");
480
+ const statusText = colorize ? fg(statusThemeColor(statusRaw), statusRaw) : statusRaw;
481
+
482
+ const stoppable = detail.status === "running" || detail.status === "orphaned";
483
+ const action = stoppable ? "x stop" : "x dismiss";
484
+ lines.push(` ← back · ${action} · Esc close`);
485
+ lines.push("");
486
+ lines.push(` status ${statusText}`);
487
+
488
+ const model = detail.model ?? "?";
489
+ lines.push(detail.effort ? ` model ${model} · effort ${detail.effort}` : ` model ${model}`);
490
+ lines.push(` elapsed ${detail.elapsed ?? "?"}`);
491
+
492
+ // Legacy tools summary (still useful as a used-tools list).
493
+ const toolsLabel = detail.currentTool
494
+ ? `tools current ${detail.currentTool}`
495
+ : (detail.tools ? `tools ${detail.tools}` : "tools (none)");
496
+ lines.push(` ${toolsLabel}`);
497
+ lines.push(detail.spend ? ` spend ${detail.spend}` : " spend (none)");
498
+
499
+ const h = detail.health;
500
+ const now = detail.now;
501
+
502
+ // Process / liveness.
503
+ lines.push("");
504
+ lines.push(sectionRule("process", width));
505
+ const pid = detail.pid != null ? String(detail.pid) : "—";
506
+ const pgid = detail.pgid != null ? String(detail.pgid) : "—";
507
+ const startTok = detail.pidStartTime != null ? String(detail.pidStartTime) : "—";
508
+ lines.push(` pid ${pid} · pgid ${pgid}`);
509
+ lines.push(` start-token ${startTok}`);
510
+ // Prefer observation liveness, but never claim "supervised" when the
511
+ // detail header already shows a non-running effective status (legacy
512
+ // dead-running metadata → effective "exited" must read as terminal).
513
+ let live = h?.process?.liveness;
514
+ if (!live || (detail.status !== "running" && live === "supervised")) {
515
+ live = detail.status === "orphaned" ? "orphaned"
516
+ : detail.status === "lost" ? "lost"
517
+ : detail.status === "running" ? "supervised"
518
+ : "terminal";
519
+ }
520
+ lines.push(` liveness ${live}`);
521
+ if (detail.orphanedAt != null) lines.push(` orphaned ${fmtDetailTs(detail.orphanedAt, now)}`);
522
+ if (detail.lostAt != null) lines.push(` lost ${fmtDetailTs(detail.lostAt, now)}`);
523
+
524
+ // Activity.
525
+ lines.push(sectionRule("activity", width));
526
+ lines.push(` ${h?.activity ?? "—"}`);
527
+ if (h?.meaningfulAgeMs != null) {
528
+ lines.push(` last meaningful ${fmtDetailAge(h.meaningfulAgeMs)} ago`);
529
+ } else if (h?.lastMeaningfulAt != null) {
530
+ lines.push(` last meaningful ${fmtDetailTs(h.lastMeaningfulAt, now)}`);
531
+ }
532
+
533
+ // Compaction, separate from tool and model state.
534
+ lines.push(sectionRule("compaction", width));
535
+ if (h?.compaction) {
536
+ const c = h.compaction;
537
+ const age = c.ageMs != null ? ` · ${fmtDetailAge(c.ageMs)}` : "";
538
+ lines.push(` ${c.state}${age}`);
539
+ if (c.last) {
540
+ const bits = [];
541
+ if (c.last.reason) bits.push(c.last.reason);
542
+ if (c.last.aborted) bits.push("aborted");
543
+ if (c.last.errorMessage) bits.push(c.last.errorMessage);
544
+ if (bits.length) lines.push(` last ${bits.join(" · ")}`);
545
+ }
546
+ } else {
547
+ lines.push(" —");
548
+ }
549
+
550
+ // Active tool, separate from compaction and model state.
551
+ lines.push(sectionRule("active tool", width));
552
+ if (h?.tool) {
553
+ const t = h.tool;
554
+ if (t.state === "idle" || !t.active) {
555
+ lines.push(" idle");
556
+ } else {
557
+ const age = t.ageMs != null ? ` · ${fmtDetailAge(t.ageMs)}` : "";
558
+ lines.push(` ${t.state} · ${t.active.toolName}${age}`);
559
+ }
560
+ } else if (detail.currentTool) {
561
+ lines.push(` ${detail.currentTool}`);
562
+ } else {
563
+ lines.push(" idle");
564
+ }
565
+
566
+ // Model call / error, separate from tool state.
567
+ lines.push(sectionRule("model", width));
568
+ if (h?.model) {
569
+ const m = h.model;
570
+ lines.push(` state ${m.state}`);
571
+ if (m.listWarning) lines.push(` warning ${m.listWarning}`);
572
+ if (m.retry) {
573
+ const a = m.retry.attempt != null ? String(m.retry.attempt) : "?";
574
+ const max = m.retry.maxAttempts != null ? String(m.retry.maxAttempts) : "?";
575
+ lines.push(` retry ${a}/${max}`);
576
+ }
577
+ if (m.lastError) {
578
+ const age = m.lastError.at != null ? ` · ${fmtDetailTs(m.lastError.at, now)}` : "";
579
+ lines.push(` last error ${m.lastError.message}${age}`);
580
+ }
581
+ if (Array.isArray(m.errorHistory) && m.errorHistory.length > 0) {
582
+ const recent = m.errorHistory.slice(-3);
583
+ for (const e of recent) {
584
+ const age = e.at != null ? ` · ${fmtDetailTs(e.at, now)}` : "";
585
+ lines.push(` history ${e.message}${age}`);
586
+ }
587
+ }
588
+ if (m.longModelCall) {
589
+ const age = m.longModelCall.ageMs != null ? ` · ${fmtDetailAge(m.longModelCall.ageMs)}` : "";
590
+ lines.push(` long call${age}`);
591
+ }
592
+ } else {
593
+ lines.push(" —");
594
+ }
595
+
596
+ // Last log write.
597
+ lines.push(sectionRule("log", width));
598
+ if (h?.rawLog && (h.rawLog.mtimeMs != null || h.rawLog.sizeBytes != null || h.rawLog.error)) {
599
+ if (h.rawLog.mtimeMs != null) lines.push(` last write ${fmtDetailTs(h.rawLog.mtimeMs, now)}`);
600
+ if (h.rawLog.sizeBytes != null) lines.push(` size ${h.rawLog.sizeBytes}B`);
601
+ if (h.rawLog.error) lines.push(` error ${h.rawLog.error}`);
602
+ } else {
603
+ lines.push(" —");
604
+ }
605
+
606
+ // Thresholds.
607
+ lines.push(sectionRule("thresholds", width));
608
+ if (h?.thresholds) {
609
+ const t = h.thresholds;
610
+ lines.push(` quiet ${fmtDetailAge(t.quietMs)} · stale ${fmtDetailAge(t.staleMs)}`);
611
+ lines.push(` long-tool ${fmtDetailAge(t.longToolMs)} · long-compact ${fmtDetailAge(t.longCompactionMs)}`);
612
+ } else {
613
+ lines.push(" —");
614
+ }
615
+
616
+ // Callback notification timestamps.
617
+ lines.push(sectionRule("callbacks", width));
618
+ const cbOrphan = detail.orphanedCallbackSentAt;
619
+ const cbLost = detail.lostCallbackSentAt;
620
+ if (cbOrphan != null || cbLost != null) {
621
+ if (cbOrphan != null) lines.push(` orphaned notified ${fmtDetailTs(cbOrphan, now)}`);
622
+ if (cbLost != null) lines.push(` lost notified ${fmtDetailTs(cbLost, now)}`);
623
+ } else {
624
+ lines.push(" (none)");
625
+ }
626
+
627
+ lines.push(sectionRule("output", width));
628
+ const body = detail.output && String(detail.output).trim() ? String(detail.output) : "(no output yet)";
629
+ for (const raw of body.split(/\r?\n/)) {
630
+ lines.push(raw.length ? ` ${raw}` : " ");
631
+ }
632
+ lines.push("");
633
+ lines.push(` ← back · ${action} · Esc close`);
634
+ lines.push(commandSheetRule("", width));
635
+ return lines.map((l) => {
636
+ const cut = truncate(l, width);
637
+ return visibleWidth(cut) > width ? truncateToVisibleWidth(cut, width) : cut;
638
+ });
639
+ }
640
+
641
+ /** Keep selection on `id` when still present; otherwise clamp in place. */
642
+ export function selectById(state, id) {
643
+ if (id == null) {
644
+ clampSelection(state);
645
+ return state.selected;
646
+ }
647
+ const idx = (state.rows ?? []).findIndex((r) => r && r.id === id);
648
+ // Missing id: leave the index alone and clamp so a neighbor (or 0) is
649
+ // selected — never leave selected out of range after a dismiss/disappear.
650
+ state.selected = idx >= 0 ? idx : state.selected;
651
+ return clampSelection(state);
652
+ }
653
+
654
+ /**
655
+ * Replace the visible row list while keeping selection stable by run id.
656
+ *
657
+ * Status refreshes and dismissals rebuild the visible set (reorder, status
658
+ * text change, or a run disappearing). Selection must follow the previously
659
+ * selected id when it is still present; otherwise clamp safely. Returns the
660
+ * resulting selected index.
661
+ */
662
+ export function applyNavigatorRows(state, nextRows) {
663
+ const prevId =
664
+ state && state.rows && state.rows.length > 0 && state.selected >= 0
665
+ ? state.rows[state.selected]?.id
666
+ : null;
667
+ state.rows = Array.isArray(nextRows) ? nextRows : [];
668
+ return selectById(state, prevId);
669
+ }
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Overlay component (opened via ctx.ui.custom(..., { overlay: true }))
673
+ // ---------------------------------------------------------------------------
674
+
675
+ /**
676
+ * Build the custom component pi renders as the focused navigator overlay.
677
+ *
678
+ * @param {Array<object>} rows - buildNavigatorRows output (newest first)
679
+ * @param {object} deps
680
+ * @param {(data: string, keyId: string) => boolean} deps.matchKey - pi-tui matchesKey
681
+ * @param {(s: string, w: number) => string} deps.truncate - pi-tui truncateToWidth
682
+ * @param {object} tui - pi TUI (requestRender)
683
+ * @param {object} [theme] - pi theme (fg); optional in tests
684
+ * @param {(v: null) => void} done - pi close callback
685
+ */
686
+ export function createNavigatorOverlayComponent(rows, deps, tui, theme, done) {
687
+ const state = createNavigatorState(rows);
688
+ /** @type {'list' | 'detail'} */
689
+ let mode = "list";
690
+ /** @type {object|null} */
691
+ let detail = null;
692
+ /** @type {string|null} id of the run currently shown in detail */
693
+ let detailId = null;
694
+ /** @type {ReturnType<typeof setInterval>|null} */
695
+ let detailTimer = null;
696
+ /** @type {ReturnType<typeof setTimeout>|null} */
697
+ let closeArmTimer = null;
698
+ const closeArm = createCloseArm(null, 0);
699
+ let closed = false;
700
+
701
+ const setIntervalFn = deps.setInterval ?? globalThis.setInterval.bind(globalThis);
702
+ const clearIntervalFn = deps.clearInterval ?? globalThis.clearInterval.bind(globalThis);
703
+ const setTimeoutFn = deps.setTimeout ?? globalThis.setTimeout.bind(globalThis);
704
+ const clearTimeoutFn = deps.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
705
+ const nowFn = deps.now ?? (() => Date.now());
706
+ const tickMs = deps.tickMs ?? DETAIL_TICK_MS;
707
+
708
+ const fg = (color, s) =>
709
+ theme && typeof theme.fg === "function" ? theme.fg(color, s) : s;
710
+
711
+ function publishCloseHint(hint) {
712
+ if (typeof deps.onCloseConfirmHint === "function") {
713
+ try { deps.onCloseConfirmHint(hint); } catch { /* ignore */ }
714
+ }
715
+ }
716
+
717
+ function stopCloseArmTimer() {
718
+ if (closeArmTimer != null) {
719
+ try { clearTimeoutFn(closeArmTimer); } catch { /* ignore */ }
720
+ closeArmTimer = null;
721
+ }
722
+ }
723
+
724
+ function clearCloseArm() {
725
+ const wasArmed = closeArm.id != null;
726
+ stopCloseArmTimer();
727
+ disarmClose(closeArm);
728
+ if (wasArmed) publishCloseHint(null);
729
+ }
730
+
731
+ function armCloseFor(row) {
732
+ if (!row || row.id == null) return;
733
+ stopCloseArmTimer();
734
+ const at = nowFn();
735
+ closeArm.id = row.id;
736
+ closeArm.armedAt = at;
737
+ const hint = closeConfirmHint(row);
738
+ publishCloseHint(hint);
739
+ // Auto-disarm exactly at the window boundary.
740
+ closeArmTimer = setTimeoutFn(() => {
741
+ closeArmTimer = null;
742
+ // Only clear if this arm is still the live one.
743
+ if (closeArm.id === row.id && closeArm.armedAt === at) {
744
+ disarmClose(closeArm);
745
+ publishCloseHint(null);
746
+ try { tui.requestRender(); } catch { /* ignore */ }
747
+ }
748
+ }, CLOSE_ARM_MS);
749
+ try { tui.requestRender(); } catch { /* ignore */ }
750
+ }
751
+
752
+ function stopDetailTimer() {
753
+ if (detailTimer != null) {
754
+ try { clearIntervalFn(detailTimer); } catch { /* ignore */ }
755
+ detailTimer = null;
756
+ }
757
+ }
758
+
759
+ function loadDetail(id) {
760
+ if (typeof deps.getDetail !== "function") return null;
761
+ try {
762
+ return deps.getDetail(id) ?? null;
763
+ } catch {
764
+ return null;
765
+ }
766
+ }
767
+
768
+ function refreshRows() {
769
+ if (typeof deps.getRows !== "function") return;
770
+ try {
771
+ const next = deps.getRows();
772
+ // Keep selection by id across status refreshes / dismissals so the
773
+ // highlight does not jump when the visible set reorders or shrinks.
774
+ if (Array.isArray(next)) applyNavigatorRows(state, next);
775
+ } catch { /* keep prior rows */ }
776
+ }
777
+
778
+ function currentTargetRow() {
779
+ if (mode === "detail") {
780
+ if (detailId == null) return null;
781
+ // Prefer live detail snapshot (status may have changed while armed).
782
+ if (detail && detail.id === detailId) {
783
+ return { id: detail.id, name: detail.name, status: detail.status };
784
+ }
785
+ const fromList = (state.rows ?? []).find((r) => r && r.id === detailId);
786
+ return fromList ?? { id: detailId, name: detailId, status: "completed" };
787
+ }
788
+ if (state.rows.length === 0) return null;
789
+ return state.rows[state.selected] ?? null;
790
+ }
791
+
792
+ function handleCloseKey() {
793
+ const row = currentTargetRow();
794
+ if (!row || row.id == null) return;
795
+ const wasDetail = mode === "detail";
796
+ const now = nowFn();
797
+ if (isCloseArmed(closeArm, row.id, now)) {
798
+ // Second press within the window on the same run — act.
799
+ const armedId = closeArm.id;
800
+ clearCloseArm();
801
+ let outcome = { action: "missing", id: armedId };
802
+ if (typeof deps.closeRun === "function") {
803
+ try {
804
+ outcome = deps.closeRun(armedId) ?? outcome;
805
+ } catch {
806
+ outcome = { action: "missing", id: armedId };
807
+ }
808
+ }
809
+ if (typeof deps.onClosed === "function") {
810
+ try { deps.onClosed(outcome); } catch { /* ignore */ }
811
+ }
812
+ // Leave detail (if any) and refresh the visible list.
813
+ if (mode === "detail") {
814
+ stopDetailTimer();
815
+ mode = "list";
816
+ detail = null;
817
+ detailId = null;
818
+ }
819
+ refreshRows();
820
+ if (wasDetail && deps.closeDetailToMainPage === true) {
821
+ close();
822
+ return;
823
+ }
824
+ // Prefer keeping selection near the closed run's former neighbors.
825
+ selectById(state, armedId);
826
+ clampSelection(state);
827
+ try { tui.requestRender(); } catch { /* ignore */ }
828
+ return;
829
+ }
830
+ // First press (or expired / different selection): arm only — never mutate.
831
+ armCloseFor(row);
832
+ }
833
+
834
+ function enterDetail() {
835
+ if (state.rows.length === 0) return;
836
+ const row = state.rows[state.selected];
837
+ if (!row) return;
838
+ clearCloseArm();
839
+ detailId = row.id;
840
+ detail = loadDetail(detailId) ?? {
841
+ id: detailId,
842
+ name: row.name,
843
+ status: row.status,
844
+ model: row.model,
845
+ elapsed: row.elapsed,
846
+ spend: row.spend,
847
+ tools: "",
848
+ output: "",
849
+ };
850
+ mode = "detail";
851
+ stopDetailTimer();
852
+ // Refresh once per second while the detail view is open.
853
+ detailTimer = setIntervalFn(() => {
854
+ if (mode !== "detail" || detailId == null) return;
855
+ detail = loadDetail(detailId) ?? detail;
856
+ try { tui.requestRender(); } catch { /* ignore */ }
857
+ }, tickMs);
858
+ try { tui.requestRender(); } catch { /* ignore */ }
859
+ }
860
+
861
+ function leaveDetail() {
862
+ if (mode !== "detail") return;
863
+ if (deps.closeDetailToMainPage === true) {
864
+ close();
865
+ return;
866
+ }
867
+ const viewedId = detailId;
868
+ clearCloseArm();
869
+ stopDetailTimer();
870
+ mode = "list";
871
+ detail = null;
872
+ detailId = null;
873
+ // Optional live list refresh so a disappeared run can clamp cleanly.
874
+ refreshRows();
875
+ selectById(state, viewedId);
876
+ try { tui.requestRender(); } catch { /* ignore */ }
877
+ }
878
+
879
+ function close() {
880
+ if (closed) return;
881
+ closed = true;
882
+ clearCloseArm();
883
+ stopDetailTimer();
884
+ mode = "list";
885
+ detail = null;
886
+ detailId = null;
887
+ try { done(null); } catch { /* ignore */ }
888
+ }
889
+
890
+ function dispose() {
891
+ // Session teardown / host overlay dismiss — always safe, idempotent.
892
+ clearCloseArm();
893
+ stopDetailTimer();
894
+ mode = "list";
895
+ detail = null;
896
+ detailId = null;
897
+ }
898
+
899
+ /** True when input is the Close key (`x` / `X`). */
900
+ function isCloseKey(data) {
901
+ if (deps.matchKey(data, "x") || deps.matchKey(data, "X")) return true;
902
+ // Literal fallback for tests / hosts that don't map a Key.x id.
903
+ return data === "x" || data === "X";
904
+ }
905
+
906
+ if (deps.initialDetailId != null) {
907
+ const idx = (state.rows ?? []).findIndex((r) => r && r.id === deps.initialDetailId);
908
+ if (idx >= 0) {
909
+ state.selected = idx;
910
+ enterDetail();
911
+ }
912
+ }
913
+
914
+ return {
915
+ render(width) {
916
+ // Colorize durable status inside the line (visible-width-aware),
917
+ // then apply row-level accent/dim only to non-status chrome so the
918
+ // semantic status color is not overwritten by whole-line theming.
919
+ if (mode === "detail") {
920
+ const lines = buildDetailLines(detail, {
921
+ width,
922
+ truncate: deps.truncate,
923
+ colorizeStatus: true,
924
+ fg,
925
+ });
926
+ return lines.map((line, i) => {
927
+ if (i === 0) return fg("accent", line);
928
+ if (i === 1) return fg("dim", line);
929
+ if (i === lines.length - 1) return fg("dim", line);
930
+ if (stripSectionRule(line)) return fg("dim", line);
931
+ if (line.trimStart().startsWith("← back")) return fg("dim", line);
932
+ return line;
933
+ });
934
+ }
935
+ const lines = buildNavigatorLines(state, {
936
+ width,
937
+ truncate: deps.truncate,
938
+ colorizeStatus: true,
939
+ fg,
940
+ });
941
+ return lines.map((line, i) => {
942
+ if (i === 0) return fg("accent", line);
943
+ if (i === 1 || i === lines.length - 1) return fg("dim", line);
944
+ // Selected row: keep the line readable without recoloring the
945
+ // already-semantic status token (status is colored in-body).
946
+ if (state.rows.length > 0 && i === LIST_ROW_START + state.selected) {
947
+ return line.startsWith("› ")
948
+ ? fg("accent", "› ") + line.slice(3)
949
+ : fg("accent", line);
950
+ }
951
+ return line;
952
+ });
953
+ },
954
+ handleInput(data) {
955
+ if (closed) return;
956
+ if (mode === "detail") {
957
+ if (isCloseKey(data)) {
958
+ handleCloseKey();
959
+ } else if (deps.matchKey(data, "left")) {
960
+ leaveDetail();
961
+ } else if (deps.matchKey(data, "escape")) {
962
+ close();
963
+ }
964
+ return;
965
+ }
966
+ if (isCloseKey(data)) {
967
+ handleCloseKey();
968
+ } else if (deps.matchKey(data, "up")) {
969
+ if (moveSelection(state, -1)) {
970
+ clearCloseArm();
971
+ tui.requestRender();
972
+ }
973
+ } else if (deps.matchKey(data, "down")) {
974
+ if (moveSelection(state, 1)) {
975
+ clearCloseArm();
976
+ tui.requestRender();
977
+ }
978
+ } else if (deps.matchKey(data, "enter")) {
979
+ enterDetail();
980
+ } else if (deps.matchKey(data, "escape")) {
981
+ close();
982
+ }
983
+ },
984
+ invalidate() {
985
+ // Stateless render — nothing cached to clear on theme changes.
986
+ },
987
+ /** Clear detail + close-arm timers. Safe to call multiple times / after close. */
988
+ dispose,
989
+ };
990
+ }
991
+
992
+ /** Factory in the shape ctx.ui.custom expects: (tui, theme, keybindings, done). */
993
+ export function createNavigatorOverlayFactory(rows, deps) {
994
+ return (tui, theme, _keybindings, done) =>
995
+ createNavigatorOverlayComponent(rows, deps, tui, theme, done);
996
+ }
997
+
998
+ /**
999
+ * Open the navigator as a focused overlay. `ui.custom(factory, { overlay: true })`
1000
+ * makes pi render it on top of existing content and focus it on show.
1001
+ *
1002
+ * Pi's `custom()` resolves with the value passed to `done()` (typically `null`),
1003
+ * NOT the component instance. Callers that need the component (e.g. to capture
1004
+ * `dispose` for session_shutdown) must use `deps.onComponent`, which is invoked
1005
+ * synchronously inside the factory when pi constructs the overlay.
1006
+ *
1007
+ * @param {object} ui
1008
+ * @param {Array<object>} rows
1009
+ * @param {object} [deps]
1010
+ * @param {(component: object) => void} [deps.onComponent] - sync capture seam
1011
+ */
1012
+ export function showNavigator(ui, rows, deps = {}) {
1013
+ const baseFactory = createNavigatorOverlayFactory(rows, deps);
1014
+ return ui.custom((tui, theme, keybindings, done) => {
1015
+ const component = baseFactory(tui, theme, keybindings, done);
1016
+ if (typeof deps.onComponent === "function") {
1017
+ try { deps.onComponent(component); } catch { /* never break overlay open */ }
1018
+ }
1019
+ return component;
1020
+ }, { overlay: true });
1021
+ }
1022
+
1023
+ /**
1024
+ * Open the navigator and track its dispose hook for session teardown.
1025
+ *
1026
+ * Pi's `ui.custom()` promise resolves to `done()`'s value (`null`), not the
1027
+ * component — so dispose MUST be captured synchronously via `onComponent`.
1028
+ * The active dispose reference is cleared when the overlay promise settles
1029
+ * (fulfill OR reject) and is safe to invoke from session_shutdown while open.
1030
+ *
1031
+ * Settlement cleanup uses `.then(clear, clear)` rather than `.finally(...)`:
1032
+ * `finally` rethrows into a second promise, and `void` does not consume that
1033
+ * rejection — Pi rejects `custom()` when overlay factory/show setup fails, which
1034
+ * would emit `unhandledRejection` (Node 22 exits 1). Both branches are handled
1035
+ * and `clear` never rethrows. Callers (index `openNavigator`) may discard the
1036
+ * returned promise; attach their own handler only if they need the settle value.
1037
+ *
1038
+ * @param {object} ui - pi UI with `custom()`
1039
+ * @param {Array<object>} rows
1040
+ * @param {object} deps - showNavigator deps (matchKey, truncate, getDetail, …)
1041
+ * @param {{ get: () => (undefined|(() => void)), set: (fn: undefined|(() => void)) => void }} disposeSlot
1042
+ * @returns {Promise<unknown>} pi custom() promise (done value on fulfill; rejects if custom() rejects).
1043
+ * Safe to discard: internal handlers consume both settle paths (no unhandledRejection).
1044
+ */
1045
+ export function openTrackedNavigator(ui, rows, deps, disposeSlot) {
1046
+ // Drop any prior overlay's timers before opening a new one (defensive;
1047
+ // pi normally only allows one focused custom overlay at a time).
1048
+ try { disposeSlot.get()?.(); } catch { /* ignore */ }
1049
+ disposeSlot.set(undefined);
1050
+
1051
+ let disposeToken;
1052
+ const opened = showNavigator(ui, rows, {
1053
+ ...deps,
1054
+ onComponent: (component) => {
1055
+ if (typeof deps?.onComponent === "function") {
1056
+ try { deps.onComponent(component); } catch { /* ignore */ }
1057
+ }
1058
+ if (component && typeof component.dispose === "function") {
1059
+ disposeToken = () => {
1060
+ try { component.dispose(); } catch { /* ignore */ }
1061
+ };
1062
+ disposeSlot.set(disposeToken);
1063
+ }
1064
+ },
1065
+ });
1066
+ // Token-guarded slot cleanup on BOTH settle paths. `.then(clear, clear)`
1067
+ // (not `.finally`) so a rejected custom() does not create a second promise
1068
+ // that rethrows into an unhandledRejection when callers discard the return.
1069
+ const clear = () => {
1070
+ if (disposeSlot.get() === disposeToken) {
1071
+ disposeSlot.set(undefined);
1072
+ }
1073
+ };
1074
+ void Promise.resolve(opened).then(clear, clear);
1075
+ return opened;
1076
+ }
1077
+
1078
+ /** Invoke and clear a tracked navigator dispose slot (session_shutdown path). */
1079
+ export function disposeTrackedNavigator(disposeSlot) {
1080
+ try { disposeSlot.get()?.(); } catch { /* ignore */ }
1081
+ disposeSlot.set(undefined);
1082
+ }
1083
+
1084
+ // ---------------------------------------------------------------------------
1085
+ // Editor wrapper (empty-editor ← interception by composition)
1086
+ // ---------------------------------------------------------------------------
1087
+
1088
+ /**
1089
+ * Wrap any editor component so bare ← on an EMPTY editor (with visible runs)
1090
+ * enters subagent navigation, and everything else delegates unchanged.
1091
+ *
1092
+ * Composition, not replacement: a delegating Proxy forwards every property get
1093
+ * (methods bound to the inner editor) and every set to the wrapped component,
1094
+ * so the duck-typing pi applies to whatever `setEditorComponent` produces
1095
+ * (onSubmit/onChange assignment, setText, borderColor, setPaddingX,
1096
+ * setAutocompleteProvider, the `actionHandlers` app-keybinding wiring) keeps
1097
+ * working against the inner editor.
1098
+ *
1099
+ * @param {object} inner - the wrapped editor component
1100
+ * @param {object} deps
1101
+ * @param {(data: string) => boolean} deps.isOpenTrigger - bare ← match (pi-tui matchesKey(data, Key.left))
1102
+ * @param {() => boolean} deps.canOpen - visible current-parent runs exist
1103
+ * @param {() => boolean} [deps.isNavigating] - true while main-window navigation owns empty-editor keys
1104
+ * @param {(data: string) => boolean} [deps.isReturnTrigger] - Down returns to the input line while navigating
1105
+ * @param {(data: string) => boolean} [deps.isPreviousTrigger] - Up moves to the previous main-window row while navigating
1106
+ * @param {(data: string) => boolean} [deps.isViewTrigger] - Enter opens selected detail while navigating
1107
+ * @param {(data: string) => boolean} [deps.isStopTrigger] - x stops selected run while navigating
1108
+ * @param {() => void} [deps.onReturn]
1109
+ * @param {() => void} [deps.onPrevious]
1110
+ * @param {() => void} [deps.onView]
1111
+ * @param {() => void} [deps.onStop]
1112
+ * @param {() => void} [deps.onCancel]
1113
+ * @param {() => void} deps.onOpen - enter main-window navigation
1114
+ */
1115
+ export function wrapEditor(inner, deps) {
1116
+ return new Proxy(inner, {
1117
+ get(target, prop) {
1118
+ if (prop === "handleInput") {
1119
+ return (data) => {
1120
+ if (target.getText() === "") {
1121
+ if (typeof deps.isNavigating === "function" && deps.isNavigating()) {
1122
+ if (deps.isReturnTrigger?.(data)) { deps.onReturn?.(); return; }
1123
+ if (deps.isPreviousTrigger?.(data)) { deps.onPrevious?.(); return; }
1124
+ if (deps.isViewTrigger?.(data)) { deps.onView?.(); return; }
1125
+ if (deps.isStopTrigger?.(data)) { deps.onStop?.(); return; }
1126
+ deps.onCancel?.();
1127
+ target.handleInput(data);
1128
+ return;
1129
+ }
1130
+ if (deps.isOpenTrigger(data) && deps.canOpen()) {
1131
+ deps.onOpen();
1132
+ return;
1133
+ }
1134
+ }
1135
+ target.handleInput(data);
1136
+ };
1137
+ }
1138
+ const v = Reflect.get(target, prop);
1139
+ return typeof v === "function" ? v.bind(target) : v;
1140
+ },
1141
+ set(target, prop, value) {
1142
+ return Reflect.set(target, prop, value);
1143
+ },
1144
+ });
1145
+ }
1146
+
1147
+ /** String marks (not Symbols) so a RELOADED module instance recognizes a
1148
+ * factory installed by its previous incarnation. */
1149
+ export const NAVIGATOR_FACTORY_MARK = "__piBetterSubagentsNavigatorFactory";
1150
+ const NAVIGATOR_FACTORY_REFRESH = "__piBetterSubagentsNavigatorRefresh";
1151
+
1152
+ /**
1153
+ * Install the navigator editor factory exactly once per UI.
1154
+ *
1155
+ * - No factory configured: wrap a default editor built by deps.createDefaultEditor.
1156
+ * - A factory from ANOTHER extension is configured: wrap its product (compose).
1157
+ * - Our own marked factory is already installed (repeat session_start, or a
1158
+ * reloaded module seeing the previous incarnation's factory): refresh its
1159
+ * deps and keep it — never stack a second wrapper.
1160
+ *
1161
+ * @param {object} ui - pi ctx.ui (getEditorComponent / setEditorComponent)
1162
+ * @param {object} deps - wrapEditor deps + createDefaultEditor(tui, theme, keybindings)
1163
+ * @returns the installed (or refreshed) factory
1164
+ */
1165
+ export function installNavigatorEditor(ui, deps) {
1166
+ const prev = typeof ui.getEditorComponent === "function" ? ui.getEditorComponent() : undefined;
1167
+ if (prev && prev[NAVIGATOR_FACTORY_MARK] === true) {
1168
+ prev[NAVIGATOR_FACTORY_REFRESH](deps);
1169
+ return prev;
1170
+ }
1171
+ let currentDeps = deps;
1172
+ const base = prev;
1173
+ const factory = (tui, theme, keybindings) => {
1174
+ const inner = base
1175
+ ? base(tui, theme, keybindings)
1176
+ : currentDeps.createDefaultEditor(tui, theme, keybindings);
1177
+ return wrapEditor(inner, currentDeps);
1178
+ };
1179
+ factory[NAVIGATOR_FACTORY_MARK] = true;
1180
+ factory[NAVIGATOR_FACTORY_REFRESH] = (next) => {
1181
+ // Mutate in place: wrappers already built from this factory captured the
1182
+ // deps OBJECT, so refreshed callbacks must reach them too (a /reload
1183
+ // keeps the live editor instance alive).
1184
+ Object.assign(currentDeps, next);
1185
+ };
1186
+ ui.setEditorComponent(factory);
1187
+ return factory;
1188
+ }