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/README.md +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
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
|
+
}
|