pi-better-subagents 0.1.1 → 0.1.2
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/index.ts +1 -1
- package/package.json +1 -1
- package/shared-navigator.ts +1151 -0
package/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type BackgroundWorkDetail,
|
|
29
29
|
type BackgroundWorkProvider,
|
|
30
30
|
type BackgroundWorkRow,
|
|
31
|
-
} from "
|
|
31
|
+
} from "./shared-navigator.ts";
|
|
32
32
|
import { spawnDetached, type SpawnResult } from "./spawn.ts";
|
|
33
33
|
import { parseRun, type Usage } from "./parse.ts";
|
|
34
34
|
import { finalizeRun as finalizeRunCore } from "./finalization.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Claude Code-style async subagents for pi, built on detached background pi -p processes. Launching is the deliverable; the foreground never blocks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export default function navigatorExtension(): void {
|
|
5
|
+
// Internal shared package. Pi may scan its symlink in the extension directory
|
|
6
|
+
// for sibling relative imports; loading it directly should be harmless.
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type BackgroundWorkStatusTone = "running" | "success" | "failed" | "warning" | "muted";
|
|
10
|
+
|
|
11
|
+
export type BackgroundWorkRow = {
|
|
12
|
+
providerId: string;
|
|
13
|
+
id: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
effort?: string;
|
|
17
|
+
tool?: string;
|
|
18
|
+
tokens?: string;
|
|
19
|
+
command?: string;
|
|
20
|
+
status: string;
|
|
21
|
+
statusTone: BackgroundWorkStatusTone;
|
|
22
|
+
kind: string;
|
|
23
|
+
elapsed: string;
|
|
24
|
+
primary: string;
|
|
25
|
+
secondary?: string;
|
|
26
|
+
facts?: string[];
|
|
27
|
+
sortStartedAt: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type BackgroundWorkDetail = {
|
|
31
|
+
providerId: string;
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
status: string;
|
|
35
|
+
statusTone: BackgroundWorkStatusTone;
|
|
36
|
+
subtitle?: string;
|
|
37
|
+
metadata: Array<{ label: string; value: string }>;
|
|
38
|
+
foldedSections?: Array<{ id: string; label: string; text: string; collapsedText?: string }>;
|
|
39
|
+
evidence: { label: string; text: string };
|
|
40
|
+
footerActions?: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type BackgroundWorkCloseOutcome = {
|
|
44
|
+
action: string;
|
|
45
|
+
providerId: string;
|
|
46
|
+
id: string;
|
|
47
|
+
status?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type BackgroundWorkProvider = {
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
priority: number;
|
|
54
|
+
visibleCount(): number;
|
|
55
|
+
listRows(now: number): BackgroundWorkRow[];
|
|
56
|
+
detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
|
|
57
|
+
armCloseLabel(row: BackgroundWorkRow): string;
|
|
58
|
+
close(id: string): BackgroundWorkCloseOutcome;
|
|
59
|
+
onVisibleChanged?(notify: () => void): () => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type HostDeps = {
|
|
63
|
+
createDefaultEditor: (tui: unknown, theme: unknown, keybindings: unknown) => unknown;
|
|
64
|
+
isOpenTrigger: (data: string) => boolean;
|
|
65
|
+
matchKey: (data: string, keyId: string) => boolean;
|
|
66
|
+
truncate: (s: string, width: number) => string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type NavigatorState = {
|
|
70
|
+
providers: Map<string, BackgroundWorkProvider>;
|
|
71
|
+
unsubscribers: Map<string, () => void>;
|
|
72
|
+
uiCtx?: ExtensionContext;
|
|
73
|
+
deps?: HostDeps;
|
|
74
|
+
lastHint?: string | null;
|
|
75
|
+
lastMainListLines?: string[];
|
|
76
|
+
mainListWidgetInstalled?: boolean;
|
|
77
|
+
mainListRequestRender?: () => void;
|
|
78
|
+
mainListSelectedId?: string;
|
|
79
|
+
mainListFocused?: boolean;
|
|
80
|
+
mainListCloseArm?: { id: string; armedAt: number };
|
|
81
|
+
mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
|
|
82
|
+
mainListTimer?: ReturnType<typeof setInterval>;
|
|
83
|
+
detailOverlayRows?: number;
|
|
84
|
+
dispose?: () => void;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
|
|
88
|
+
const FACTORY_MARK = "__piBetterHarnessNavigatorFactory";
|
|
89
|
+
const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
|
|
90
|
+
|
|
91
|
+
export const NAVIGATOR_STATUS_KEY = "background-work-nav";
|
|
92
|
+
export const CLOSE_CONFIRM_STATUS_KEY = "background-work-close";
|
|
93
|
+
export const MAIN_LIST_WIDGET_KEY = "background-work-list";
|
|
94
|
+
export const DETAIL_TICK_MS = 1000;
|
|
95
|
+
export const CLOSE_ARM_MS = 3000;
|
|
96
|
+
export const DEFAULT_LOG_TAIL_ROWS = 10;
|
|
97
|
+
export const LOG_TAIL_ROW_CHOICES = [10, 25, 50, 100] as const;
|
|
98
|
+
const MAIN_LIST_TICK_MS = 250;
|
|
99
|
+
const MAIN_LIST_FALLBACK_WIDTH = 100;
|
|
100
|
+
const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
|
|
101
|
+
const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
|
|
102
|
+
const EVIDENCE_SECTION_ID = "__evidence__";
|
|
103
|
+
const RUNNING_DOT_GLYPH = "●";
|
|
104
|
+
const RUNNING_DOT_FRAMES = ["dim", "accent", "accent", "dim"] as const;
|
|
105
|
+
|
|
106
|
+
function state(): NavigatorState {
|
|
107
|
+
const g = globalThis as typeof globalThis & { [GLOBAL_KEY]?: NavigatorState };
|
|
108
|
+
if (!g[GLOBAL_KEY]) {
|
|
109
|
+
g[GLOBAL_KEY] = { providers: new Map(), unsubscribers: new Map() };
|
|
110
|
+
}
|
|
111
|
+
return g[GLOBAL_KEY]!;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
115
|
+
const s = state();
|
|
116
|
+
const previousUnsub = s.unsubscribers.get(provider.id);
|
|
117
|
+
if (previousUnsub) {
|
|
118
|
+
try { previousUnsub(); } catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
s.providers.set(provider.id, provider);
|
|
121
|
+
if (typeof provider.onVisibleChanged === "function") {
|
|
122
|
+
try {
|
|
123
|
+
s.unsubscribers.set(provider.id, provider.onVisibleChanged(() => refreshBackgroundWorkNavigator()));
|
|
124
|
+
} catch {
|
|
125
|
+
s.unsubscribers.delete(provider.id);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
s.unsubscribers.delete(provider.id);
|
|
129
|
+
}
|
|
130
|
+
refreshBackgroundWorkNavigator();
|
|
131
|
+
return () => unregisterBackgroundWorkProvider(provider.id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function unregisterBackgroundWorkProvider(id: string): void {
|
|
135
|
+
const s = state();
|
|
136
|
+
const unsub = s.unsubscribers.get(id);
|
|
137
|
+
if (unsub) {
|
|
138
|
+
try { unsub(); } catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
s.unsubscribers.delete(id);
|
|
141
|
+
s.providers.delete(id);
|
|
142
|
+
refreshBackgroundWorkNavigator();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boolean {
|
|
146
|
+
return Boolean(ctx && ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function navigatorFooterHint(count: number): string | null {
|
|
150
|
+
return count > 0 ? `← navigate · ${count}` : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
|
|
154
|
+
const hint = navigatorFooterHint(count);
|
|
155
|
+
ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
|
|
156
|
+
return hint;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: string | undefined): void }, hint: string | null | undefined): string | null {
|
|
160
|
+
ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
|
|
161
|
+
return hint ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostDeps): void {
|
|
165
|
+
if (!isNavigatorUiAvailable(ctx)) return;
|
|
166
|
+
const s = state();
|
|
167
|
+
s.uiCtx = ctx;
|
|
168
|
+
s.deps = deps;
|
|
169
|
+
try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
170
|
+
s.mainListWidgetInstalled = false;
|
|
171
|
+
s.mainListRequestRender = undefined;
|
|
172
|
+
installNavigatorEditor(ctx.ui as any, deps);
|
|
173
|
+
s.lastHint = undefined;
|
|
174
|
+
startMainListWidget(ctx);
|
|
175
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
176
|
+
refreshMainListWidget();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
180
|
+
const s = state();
|
|
181
|
+
try { s.dispose?.(); } catch { /* ignore */ }
|
|
182
|
+
s.dispose = undefined;
|
|
183
|
+
stopMainListWidget();
|
|
184
|
+
const ui = (ctx ?? s.uiCtx)?.ui;
|
|
185
|
+
if (ui && isNavigatorUiAvailable(ctx ?? s.uiCtx)) {
|
|
186
|
+
try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
|
|
187
|
+
try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
|
|
188
|
+
try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
189
|
+
}
|
|
190
|
+
s.lastHint = undefined;
|
|
191
|
+
s.lastMainListLines = undefined;
|
|
192
|
+
s.mainListWidgetInstalled = false;
|
|
193
|
+
s.mainListRequestRender = undefined;
|
|
194
|
+
s.detailOverlayRows = undefined;
|
|
195
|
+
s.mainListSelectedId = undefined;
|
|
196
|
+
s.mainListFocused = false;
|
|
197
|
+
if (ctx && s.uiCtx === ctx) s.uiCtx = undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
201
|
+
const s = state();
|
|
202
|
+
const activeCtx = isNavigatorUiAvailable(ctx) ? ctx : s.uiCtx;
|
|
203
|
+
if (!isNavigatorUiAvailable(activeCtx)) return;
|
|
204
|
+
try {
|
|
205
|
+
const count = visibleCount();
|
|
206
|
+
const hint = navigatorFooterHint(count);
|
|
207
|
+
if (hint !== s.lastHint) {
|
|
208
|
+
applyNavigatorFooter(activeCtx!.ui as any, count);
|
|
209
|
+
s.lastHint = hint;
|
|
210
|
+
}
|
|
211
|
+
refreshMainListWidget();
|
|
212
|
+
} catch { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function providers(): BackgroundWorkProvider[] {
|
|
216
|
+
return [...state().providers.values()].sort((a, b) => a.priority - b.priority || a.label.localeCompare(b.label));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function visibleCount(): number {
|
|
220
|
+
return providers().reduce((sum, provider) => {
|
|
221
|
+
try { return sum + Math.max(0, provider.visibleCount()); } catch { return sum; }
|
|
222
|
+
}, 0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function startMainListWidget(ctx: ExtensionContext): void {
|
|
226
|
+
const s = state();
|
|
227
|
+
if (s.mainListTimer || !isNavigatorUiAvailable(ctx)) return;
|
|
228
|
+
s.mainListTimer = setInterval(() => refreshMainListWidget(), MAIN_LIST_TICK_MS);
|
|
229
|
+
s.mainListTimer.unref?.();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stopMainListWidget(): void {
|
|
233
|
+
const s = state();
|
|
234
|
+
if (s.mainListTimer) clearInterval(s.mainListTimer);
|
|
235
|
+
s.mainListTimer = undefined;
|
|
236
|
+
clearMainListCloseArm();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function clearMainListCloseArm(): void {
|
|
240
|
+
const s = state();
|
|
241
|
+
if (s.mainListCloseArmTimer) clearTimeout(s.mainListCloseArmTimer);
|
|
242
|
+
s.mainListCloseArmTimer = undefined;
|
|
243
|
+
if (!s.mainListCloseArm) return;
|
|
244
|
+
s.mainListCloseArm = undefined;
|
|
245
|
+
try { applyCloseConfirmFooter((s.uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function refreshMainListWidget(): void {
|
|
249
|
+
const s = state();
|
|
250
|
+
const ctx = s.uiCtx;
|
|
251
|
+
const deps = s.deps;
|
|
252
|
+
if (!ctx || !deps || !isNavigatorUiAvailable(ctx)) return;
|
|
253
|
+
const rows = listRows();
|
|
254
|
+
syncMainListSelection(rows);
|
|
255
|
+
s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
|
|
256
|
+
selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
|
|
257
|
+
focused: s.mainListFocused === true,
|
|
258
|
+
}) : undefined;
|
|
259
|
+
if (!rows.length) {
|
|
260
|
+
if (!s.mainListWidgetInstalled) return;
|
|
261
|
+
try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
262
|
+
s.mainListWidgetInstalled = false;
|
|
263
|
+
s.mainListRequestRender = undefined;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!s.mainListWidgetInstalled) {
|
|
267
|
+
try {
|
|
268
|
+
(ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, (tui: { requestRender?(): void }, theme: unknown) => createMainListWidget(tui, theme, deps), { placement: "aboveEditor" });
|
|
269
|
+
s.mainListWidgetInstalled = true;
|
|
270
|
+
} catch { /* ignore */ }
|
|
271
|
+
}
|
|
272
|
+
try { s.mainListRequestRender?.(); } catch { /* ignore */ }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function themeFg(ctx: ExtensionContext): (color: string, value: string) => string {
|
|
276
|
+
return themeFgFromTheme((ctx.ui as any).theme);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function themeFgFromTheme(theme: unknown): (color: string, value: string) => string {
|
|
280
|
+
const maybeTheme = theme as { fg?: (color: string, value: string) => string } | undefined;
|
|
281
|
+
return (color, value) => maybeTheme?.fg ? maybeTheme.fg(color, value) : value;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, deps: HostDeps): Component & { dispose?(): void } {
|
|
285
|
+
const requestRender = () => tui.requestRender?.();
|
|
286
|
+
state().mainListRequestRender = requestRender;
|
|
287
|
+
return {
|
|
288
|
+
render(width: number): string[] {
|
|
289
|
+
const rows = listRows();
|
|
290
|
+
syncMainListSelection(rows);
|
|
291
|
+
if (!rows.length) {
|
|
292
|
+
state().lastMainListLines = undefined;
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const lines = buildMainListLines(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
|
|
296
|
+
selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
|
|
297
|
+
focused: state().mainListFocused === true,
|
|
298
|
+
});
|
|
299
|
+
state().lastMainListLines = lines;
|
|
300
|
+
return lines;
|
|
301
|
+
},
|
|
302
|
+
invalidate() { state().lastMainListLines = undefined; },
|
|
303
|
+
dispose() {
|
|
304
|
+
const s = state();
|
|
305
|
+
if (s.mainListRequestRender === requestRender) s.mainListRequestRender = undefined;
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderWidth(width: number): number {
|
|
311
|
+
return Number.isFinite(width) && width > 0 ? Math.floor(width) : MAIN_LIST_FALLBACK_WIDTH;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string };
|
|
315
|
+
|
|
316
|
+
function rowKey(providerId: string, id: string): string {
|
|
317
|
+
return `${providerId}:${id}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function splitRowKey(key: string): { providerId: string; id: string } {
|
|
321
|
+
const idx = key.indexOf(":");
|
|
322
|
+
if (idx < 0) return { providerId: "", id: key };
|
|
323
|
+
return { providerId: key.slice(0, idx), id: key.slice(idx + 1) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function listRows(now = Date.now()): InternalRow[] {
|
|
327
|
+
const rows: InternalRow[] = [];
|
|
328
|
+
for (const provider of providers()) {
|
|
329
|
+
let providerRows: BackgroundWorkRow[] = [];
|
|
330
|
+
try { providerRows = provider.listRows(now) ?? []; } catch { providerRows = []; }
|
|
331
|
+
const orderedProviderRows = [...providerRows].sort((a, b) => b.sortStartedAt - a.sortStartedAt || rowDisplayName(a).localeCompare(rowDisplayName(b)));
|
|
332
|
+
for (const row of orderedProviderRows) {
|
|
333
|
+
rows.push({ ...row, navigatorId: rowKey(provider.id, row.id), providerLabel: provider.label });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return rows;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function rowDisplayName(row: Pick<BackgroundWorkRow, "name" | "id">): string {
|
|
340
|
+
return singleLine(row.name || row.id);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function syncMainListSelection(rows: InternalRow[]): void {
|
|
344
|
+
const s = state();
|
|
345
|
+
if (rows.length === 0) {
|
|
346
|
+
s.mainListSelectedId = undefined;
|
|
347
|
+
s.mainListFocused = false;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!s.mainListSelectedId || !rows.some((row) => row.navigatorId === s.mainListSelectedId)) {
|
|
351
|
+
s.mainListSelectedId = rows[0]!.navigatorId;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function selectedMainListRow(): InternalRow | undefined {
|
|
356
|
+
const rows = listRows();
|
|
357
|
+
syncMainListSelection(rows);
|
|
358
|
+
return rows.find((row) => row.navigatorId === state().mainListSelectedId) ?? rows[0];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function moveMainListSelection(delta: number): boolean {
|
|
362
|
+
const rows = listRows();
|
|
363
|
+
syncMainListSelection(rows);
|
|
364
|
+
const s = state();
|
|
365
|
+
const idx = rows.findIndex((row) => row.navigatorId === s.mainListSelectedId);
|
|
366
|
+
const next = Math.min(Math.max((idx >= 0 ? idx : 0) + delta, 0), Math.max(0, rows.length - 1));
|
|
367
|
+
const nextId = rows[next]?.navigatorId;
|
|
368
|
+
if (!nextId || nextId === s.mainListSelectedId) return false;
|
|
369
|
+
s.mainListSelectedId = nextId;
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function focusMainList(): void {
|
|
374
|
+
const rows = listRows();
|
|
375
|
+
if (rows.length === 0) return;
|
|
376
|
+
const s = state();
|
|
377
|
+
syncMainListSelection(rows);
|
|
378
|
+
s.mainListFocused = true;
|
|
379
|
+
refreshMainListWidget();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function unfocusMainList(): void {
|
|
383
|
+
const s = state();
|
|
384
|
+
if (!s.mainListFocused) return;
|
|
385
|
+
s.mainListFocused = false;
|
|
386
|
+
clearMainListCloseArm();
|
|
387
|
+
refreshMainListWidget();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildMainListLines(
|
|
391
|
+
rows: InternalRow[],
|
|
392
|
+
width: number,
|
|
393
|
+
truncate: (s: string, width: number) => string,
|
|
394
|
+
fg: (color: string, value: string) => string,
|
|
395
|
+
options: { selectedId?: string; focused?: boolean } = {},
|
|
396
|
+
): string[] {
|
|
397
|
+
const lines: string[] = [];
|
|
398
|
+
const grouped = new Map<string, InternalRow[]>();
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
const existing = grouped.get(row.providerLabel) ?? [];
|
|
401
|
+
existing.push(row);
|
|
402
|
+
grouped.set(row.providerLabel, existing);
|
|
403
|
+
}
|
|
404
|
+
const orderedLabels = providers().map((provider) => provider.label).filter((label) => grouped.has(label));
|
|
405
|
+
for (const label of grouped.keys()) if (!orderedLabels.includes(label)) orderedLabels.push(label);
|
|
406
|
+
for (let i = 0; i < orderedLabels.length; i += 1) {
|
|
407
|
+
const label = orderedLabels[i]!;
|
|
408
|
+
const group = grouped.get(label)!;
|
|
409
|
+
if (i > 0) lines.push("");
|
|
410
|
+
lines.push(providerGroupLabel(label, fg));
|
|
411
|
+
for (const row of group) {
|
|
412
|
+
const selected = options.focused && row.navigatorId === options.selectedId;
|
|
413
|
+
lines.push(formatMainListRow(row, selected === true, fg, width));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
lines.push("");
|
|
417
|
+
lines.push(shortcutsLine(options.focused === true, fg));
|
|
418
|
+
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
422
|
+
const keys = focused ? "↑↓ select · Enter detail · x stop · Esc unfocus" : "← to navigate";
|
|
423
|
+
return dim(keys, fg);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function providerGroupLabel(label: string, fg: (color: string, value: string) => string): string {
|
|
427
|
+
const normalized = singleLine(label).toLowerCase();
|
|
428
|
+
return `${dim("▸", fg)} ${fg("warning", normalized)}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function formatMainListRow(row: InternalRow, selected: boolean, fg: (color: string, value: string) => string, width: number): string {
|
|
432
|
+
const prefix = selected ? fg("accent", "› ") : " ";
|
|
433
|
+
const name = row.name || row.id;
|
|
434
|
+
const indicator = statusIndicator(row);
|
|
435
|
+
const status = fg(indicator.color, indicator.glyph);
|
|
436
|
+
const elapsed = singleLine(row.elapsed || "-");
|
|
437
|
+
const available = Math.max(24, width || MAIN_LIST_FALLBACK_WIDTH);
|
|
438
|
+
const elapsedWidth = Math.min(Math.max(visibleWidth(elapsed), 4), 12);
|
|
439
|
+
const statusWidth = 2;
|
|
440
|
+
const leftPrefixWidth = visibleWidth(prefix) + statusWidth + 1;
|
|
441
|
+
const maxNameWidth = Math.max(8, Math.floor(available * 0.36));
|
|
442
|
+
const nameWidth = Math.max(8, Math.min(44, maxNameWidth, available - leftPrefixWidth - elapsedWidth - 10));
|
|
443
|
+
const rightWidth = Math.max(8, available - leftPrefixWidth - nameWidth - 1);
|
|
444
|
+
const summaryWidth = Math.max(1, rightWidth - elapsedWidth - 1);
|
|
445
|
+
const left = `${prefix}${fit(status, statusWidth)} ${fit(name, nameWidth)}`;
|
|
446
|
+
const right = `${dim(fitRight(rowSummary(row), summaryWidth), fg)} ${fitRight(elapsed, elapsedWidth)}`;
|
|
447
|
+
const gap = Math.max(1, available - visibleWidth(left) - visibleWidth(right));
|
|
448
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function statusGlyph(row: InternalRow): string {
|
|
452
|
+
if (row.statusTone === "success") return "✓";
|
|
453
|
+
if (row.statusTone === "failed") return "✕";
|
|
454
|
+
if (row.statusTone === "warning") return "◇";
|
|
455
|
+
if (row.statusTone === "running") return RUNNING_DOT_GLYPH;
|
|
456
|
+
return "·";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; color: string } {
|
|
460
|
+
if (row.statusTone !== "running") return { glyph: statusGlyph(row), color: toneColor(row.statusTone, row.status) };
|
|
461
|
+
const frame = Math.floor(now / MAIN_LIST_TICK_MS) % RUNNING_DOT_FRAMES.length;
|
|
462
|
+
return { glyph: RUNNING_DOT_GLYPH, color: RUNNING_DOT_FRAMES[frame]! };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function rowSummary(row: InternalRow): string {
|
|
466
|
+
if (row.providerId === "subagents") {
|
|
467
|
+
const parts: string[] = [];
|
|
468
|
+
if (row.model) parts.push(row.effort ? `${singleLine(row.model)} ${singleLine(row.effort)}` : singleLine(row.model));
|
|
469
|
+
if (row.tool) parts.push(`tool ${singleLine(row.tool)}`);
|
|
470
|
+
if (row.tokens) parts.push(singleLine(row.tokens));
|
|
471
|
+
else if (row.primary) parts.push(singleLine(row.primary));
|
|
472
|
+
if (parts.length) return parts.join(" · ");
|
|
473
|
+
}
|
|
474
|
+
const facts = row.facts?.map(singleLine).filter(Boolean).join(" · ");
|
|
475
|
+
if (facts) return facts;
|
|
476
|
+
if (row.providerId === "background-tasks") {
|
|
477
|
+
if (row.statusTone === "running") return row.kind === "watch" ? "watching condition" : "process running";
|
|
478
|
+
if (row.statusTone === "success") return "completed";
|
|
479
|
+
if (row.statusTone === "failed") return "failed, inspect log";
|
|
480
|
+
if (row.statusTone === "warning") return "needs attention";
|
|
481
|
+
return row.kind || "background task";
|
|
482
|
+
}
|
|
483
|
+
if (row.tokens) return row.tokens;
|
|
484
|
+
if (row.primary) return row.primary;
|
|
485
|
+
if (row.tool) return row.tool;
|
|
486
|
+
if (row.secondary) return row.secondary;
|
|
487
|
+
return row.kind || "work item";
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function fit(value: string, width: number): string {
|
|
491
|
+
const str = singleLine(value);
|
|
492
|
+
const visible = visibleWidth(str);
|
|
493
|
+
if (visible >= width) return truncateVisible(str, width);
|
|
494
|
+
return str + " ".repeat(width - visible);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function fitRight(value: string, width: number): string {
|
|
498
|
+
const str = singleLine(value);
|
|
499
|
+
const visible = visibleWidth(str);
|
|
500
|
+
if (visible >= width) return truncateVisible(str, width);
|
|
501
|
+
return " ".repeat(width - visible) + str;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function singleLine(value: unknown): string {
|
|
505
|
+
return String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ").trim();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function detailFor(navigatorId: string, now = Date.now(), options?: { logTailLines?: number }): BackgroundWorkDetail | null {
|
|
509
|
+
const { providerId, id } = splitRowKey(navigatorId);
|
|
510
|
+
const provider = state().providers.get(providerId);
|
|
511
|
+
if (!provider) return null;
|
|
512
|
+
try { return provider.detail(id, now, options); } catch { return null; }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function closeFor(row: InternalRow): BackgroundWorkCloseOutcome {
|
|
516
|
+
const provider = state().providers.get(row.providerId);
|
|
517
|
+
if (!provider) return { action: "missing", providerId: row.providerId, id: row.id };
|
|
518
|
+
try { return provider.close(row.id); } catch { return { action: "missing", providerId: row.providerId, id: row.id }; }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function closeHintFor(row: InternalRow | undefined): string | null {
|
|
522
|
+
if (!row) return null;
|
|
523
|
+
const provider = state().providers.get(row.providerId);
|
|
524
|
+
if (!provider) return null;
|
|
525
|
+
try { return `${provider.armCloseLabel(row)} ${row.name || row.id}`; } catch { return null; }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
529
|
+
const prev = typeof ui.getEditorComponent === "function" ? ui.getEditorComponent() : undefined;
|
|
530
|
+
if (prev && prev[FACTORY_MARK] === true) {
|
|
531
|
+
prev[FACTORY_REFRESH](deps);
|
|
532
|
+
return prev;
|
|
533
|
+
}
|
|
534
|
+
let currentDeps = deps;
|
|
535
|
+
const base = prev;
|
|
536
|
+
const factory = ((tui: unknown, theme: unknown, keybindings: unknown) => {
|
|
537
|
+
const inner = base ? base(tui, theme, keybindings) : currentDeps.createDefaultEditor(tui, theme, keybindings);
|
|
538
|
+
return wrapEditor(inner as any, currentDeps);
|
|
539
|
+
}) as any;
|
|
540
|
+
factory[FACTORY_MARK] = true;
|
|
541
|
+
factory[FACTORY_REFRESH] = (next: HostDeps) => { currentDeps = next; };
|
|
542
|
+
ui.setEditorComponent(factory);
|
|
543
|
+
return factory;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
547
|
+
return new Proxy(inner, {
|
|
548
|
+
get(target, prop) {
|
|
549
|
+
if (prop === "handleInput") {
|
|
550
|
+
return (data: string) => {
|
|
551
|
+
if (target.getText?.() === "" && handleMainListInput(data, deps)) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
target.handleInput(data);
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const value = Reflect.get(target, prop);
|
|
558
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
559
|
+
},
|
|
560
|
+
set(target, prop, value) {
|
|
561
|
+
return Reflect.set(target, prop, value);
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
567
|
+
const s = state();
|
|
568
|
+
const rows = listRows();
|
|
569
|
+
if (rows.length === 0) return false;
|
|
570
|
+
if (!s.mainListFocused) {
|
|
571
|
+
if (!deps.isOpenTrigger(data)) return false;
|
|
572
|
+
focusMainList();
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
if (deps.matchKey(data, "up")) {
|
|
576
|
+
clearMainListCloseArm();
|
|
577
|
+
if (moveMainListSelection(-1)) refreshMainListWidget();
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
if (deps.matchKey(data, "down")) {
|
|
581
|
+
clearMainListCloseArm();
|
|
582
|
+
if (moveMainListSelection(1)) refreshMainListWidget();
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
if (deps.matchKey(data, "enter")) {
|
|
586
|
+
openNavigator();
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
|
|
590
|
+
handleMainListCloseKey();
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
if (deps.matchKey(data, "escape") || deps.isOpenTrigger(data)) {
|
|
594
|
+
unfocusMainList();
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function handleMainListCloseKey(): void {
|
|
601
|
+
const row = selectedMainListRow();
|
|
602
|
+
if (!row) return;
|
|
603
|
+
const s = state();
|
|
604
|
+
const now = Date.now();
|
|
605
|
+
const arm = s.mainListCloseArm;
|
|
606
|
+
if (arm?.id === row.navigatorId && now >= arm.armedAt && now < arm.armedAt + CLOSE_ARM_MS) {
|
|
607
|
+
clearMainListCloseArm();
|
|
608
|
+
closeFor(row);
|
|
609
|
+
s.mainListSelectedId = undefined;
|
|
610
|
+
refreshBackgroundWorkNavigator(s.uiCtx);
|
|
611
|
+
refreshMainListWidget();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
clearMainListCloseArm();
|
|
615
|
+
s.mainListCloseArm = { id: row.navigatorId, armedAt: now };
|
|
616
|
+
try { applyCloseConfirmFooter((s.uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
|
|
617
|
+
s.mainListCloseArmTimer = setTimeout(() => {
|
|
618
|
+
clearMainListCloseArm();
|
|
619
|
+
refreshMainListWidget();
|
|
620
|
+
}, CLOSE_ARM_MS);
|
|
621
|
+
s.mainListCloseArmTimer.unref?.();
|
|
622
|
+
refreshMainListWidget();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function openNavigator(): void {
|
|
626
|
+
const s = state();
|
|
627
|
+
const ctx = s.uiCtx;
|
|
628
|
+
const deps = s.deps;
|
|
629
|
+
if (!ctx || !deps || !isNavigatorUiAvailable(ctx)) return;
|
|
630
|
+
const rows = listRows();
|
|
631
|
+
if (rows.length === 0) return;
|
|
632
|
+
const selectedId = selectedMainListRow()?.navigatorId ?? rows[0]!.navigatorId;
|
|
633
|
+
try {
|
|
634
|
+
try { s.dispose?.(); } catch { /* ignore */ }
|
|
635
|
+
s.dispose = undefined;
|
|
636
|
+
let disposeToken: (() => void) | undefined;
|
|
637
|
+
const opened = (ctx.ui as any).custom((tui: any, theme: any, _keybindings: any, done: (v: null) => void) => {
|
|
638
|
+
const component = createOverlayComponent(rows, deps, tui, theme, done, () => {
|
|
639
|
+
s.lastHint = undefined;
|
|
640
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
641
|
+
}, selectedId);
|
|
642
|
+
disposeToken = () => component.dispose();
|
|
643
|
+
s.dispose = disposeToken;
|
|
644
|
+
return component;
|
|
645
|
+
}, { overlay: true, overlayOptions: detailOverlayOptions });
|
|
646
|
+
const clear = () => {
|
|
647
|
+
if (s.dispose === disposeToken) s.dispose = undefined;
|
|
648
|
+
};
|
|
649
|
+
void Promise.resolve(opened).then(clear, clear);
|
|
650
|
+
} catch { /* keep foreground usable */ }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
type OverlayState = { rows: InternalRow[]; selected: number };
|
|
654
|
+
|
|
655
|
+
function createOverlayComponent(
|
|
656
|
+
initialRows: InternalRow[],
|
|
657
|
+
deps: HostDeps,
|
|
658
|
+
tui: { requestRender(): void },
|
|
659
|
+
theme: { fg?(color: string, value: string): string } | undefined,
|
|
660
|
+
done: (v: null) => void,
|
|
661
|
+
onClosed: () => void,
|
|
662
|
+
initialDetailId?: string,
|
|
663
|
+
) {
|
|
664
|
+
const overlayState: OverlayState = { rows: initialRows, selected: 0 };
|
|
665
|
+
let mode: "list" | "detail" = initialDetailId ? "detail" : "list";
|
|
666
|
+
let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
|
|
667
|
+
const expandedSections = new Set<string>();
|
|
668
|
+
let detailId: string | null = initialDetailId ?? null;
|
|
669
|
+
let detail: BackgroundWorkDetail | null = detailId ? (detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null) : null;
|
|
670
|
+
if (detailId) {
|
|
671
|
+
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
672
|
+
if (idx >= 0) overlayState.selected = idx;
|
|
673
|
+
}
|
|
674
|
+
let detailTimer: ReturnType<typeof setInterval> | undefined;
|
|
675
|
+
let closeArm: { id: string; armedAt: number } | undefined;
|
|
676
|
+
let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
|
|
677
|
+
let closed = false;
|
|
678
|
+
|
|
679
|
+
const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
|
|
680
|
+
|
|
681
|
+
function requestRender(): void {
|
|
682
|
+
try { tui.requestRender(); } catch { /* ignore */ }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function refreshRows(): void {
|
|
686
|
+
const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
|
|
687
|
+
overlayState.rows = listRows();
|
|
688
|
+
const nextIdx = selectedId ? overlayState.rows.findIndex((row) => row.navigatorId === selectedId) : -1;
|
|
689
|
+
overlayState.selected = nextIdx >= 0 ? nextIdx : Math.min(overlayState.selected, Math.max(0, overlayState.rows.length - 1));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function clearCloseArm(): void {
|
|
693
|
+
if (closeArmTimer) clearTimeout(closeArmTimer);
|
|
694
|
+
closeArmTimer = undefined;
|
|
695
|
+
if (closeArm) {
|
|
696
|
+
closeArm = undefined;
|
|
697
|
+
try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function stopDetailTimer(): void {
|
|
702
|
+
if (detailTimer) clearInterval(detailTimer);
|
|
703
|
+
detailTimer = undefined;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function startDetailTimer(): void {
|
|
707
|
+
stopDetailTimer();
|
|
708
|
+
detailTimer = setInterval(() => {
|
|
709
|
+
if (!detailId || mode !== "detail") return;
|
|
710
|
+
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
711
|
+
requestRender();
|
|
712
|
+
}, DETAIL_TICK_MS);
|
|
713
|
+
detailTimer.unref?.();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function selectedRow(): InternalRow | undefined {
|
|
717
|
+
if (mode === "detail" && detailId) return overlayState.rows.find((row) => row.navigatorId === detailId);
|
|
718
|
+
return overlayState.rows[overlayState.selected];
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function openDetail(): void {
|
|
722
|
+
const row = selectedRow();
|
|
723
|
+
if (!row) return;
|
|
724
|
+
clearCloseArm();
|
|
725
|
+
detailId = row.navigatorId;
|
|
726
|
+
expandedSections.clear();
|
|
727
|
+
detail = detailFor(row.navigatorId, Date.now(), { logTailLines: logTailRows }) ?? fallbackDetail(row);
|
|
728
|
+
mode = "detail";
|
|
729
|
+
startDetailTimer();
|
|
730
|
+
requestRender();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (detailId) startDetailTimer();
|
|
734
|
+
|
|
735
|
+
function leaveDetail(): void {
|
|
736
|
+
if (mode !== "detail") return;
|
|
737
|
+
const viewedId = detailId;
|
|
738
|
+
clearCloseArm();
|
|
739
|
+
stopDetailTimer();
|
|
740
|
+
mode = "list";
|
|
741
|
+
detail = null;
|
|
742
|
+
detailId = null;
|
|
743
|
+
expandedSections.clear();
|
|
744
|
+
refreshRows();
|
|
745
|
+
if (viewedId) {
|
|
746
|
+
const idx = overlayState.rows.findIndex((row) => row.navigatorId === viewedId);
|
|
747
|
+
if (idx >= 0) overlayState.selected = idx;
|
|
748
|
+
}
|
|
749
|
+
requestRender();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function close(): void {
|
|
753
|
+
if (closed) return;
|
|
754
|
+
closed = true;
|
|
755
|
+
clearCloseArm();
|
|
756
|
+
stopDetailTimer();
|
|
757
|
+
try { done(null); } catch { /* ignore */ }
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function handleCloseKey(): void {
|
|
761
|
+
const row = selectedRow();
|
|
762
|
+
if (!row) return;
|
|
763
|
+
const now = Date.now();
|
|
764
|
+
if (closeArm?.id === row.navigatorId && now >= closeArm.armedAt && now < closeArm.armedAt + CLOSE_ARM_MS) {
|
|
765
|
+
clearCloseArm();
|
|
766
|
+
closeFor(row);
|
|
767
|
+
if (mode === "detail") {
|
|
768
|
+
stopDetailTimer();
|
|
769
|
+
mode = "list";
|
|
770
|
+
detail = null;
|
|
771
|
+
detailId = null;
|
|
772
|
+
}
|
|
773
|
+
refreshRows();
|
|
774
|
+
onClosed();
|
|
775
|
+
requestRender();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
clearCloseArm();
|
|
779
|
+
closeArm = { id: row.navigatorId, armedAt: now };
|
|
780
|
+
try { applyCloseConfirmFooter((state().uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
|
|
781
|
+
closeArmTimer = setTimeout(() => {
|
|
782
|
+
closeArmTimer = undefined;
|
|
783
|
+
closeArm = undefined;
|
|
784
|
+
try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
785
|
+
requestRender();
|
|
786
|
+
}, CLOSE_ARM_MS);
|
|
787
|
+
closeArmTimer.unref?.();
|
|
788
|
+
requestRender();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return {
|
|
792
|
+
render(width: number) {
|
|
793
|
+
const lines = mode === "detail"
|
|
794
|
+
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: state().detailOverlayRows })
|
|
795
|
+
: buildListLines(overlayState, width, deps.truncate, fg);
|
|
796
|
+
return lines;
|
|
797
|
+
},
|
|
798
|
+
handleInput(data: string) {
|
|
799
|
+
if (closed) return;
|
|
800
|
+
if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
|
|
801
|
+
handleCloseKey();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (mode === "detail") {
|
|
805
|
+
if (deps.matchKey(data, "left")) close();
|
|
806
|
+
else if (deps.matchKey(data, "enter")) {
|
|
807
|
+
const sectionId = firstToggleableSectionId(detail);
|
|
808
|
+
if (sectionId) {
|
|
809
|
+
if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
|
|
810
|
+
else expandedSections.add(sectionId);
|
|
811
|
+
requestRender();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
else if (data === "[") {
|
|
815
|
+
logTailRows = previousLogTailRows(logTailRows);
|
|
816
|
+
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
817
|
+
requestRender();
|
|
818
|
+
}
|
|
819
|
+
else if (data === "]") {
|
|
820
|
+
logTailRows = nextLogTailRows(logTailRows);
|
|
821
|
+
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
822
|
+
requestRender();
|
|
823
|
+
}
|
|
824
|
+
else if (data === "l" || data === "L") {
|
|
825
|
+
logTailRows = cycleLogTailRows(logTailRows);
|
|
826
|
+
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
827
|
+
requestRender();
|
|
828
|
+
}
|
|
829
|
+
else if (deps.matchKey(data, "escape")) close();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (deps.matchKey(data, "up")) {
|
|
833
|
+
overlayState.selected = Math.max(0, overlayState.selected - 1);
|
|
834
|
+
clearCloseArm();
|
|
835
|
+
requestRender();
|
|
836
|
+
} else if (deps.matchKey(data, "down")) {
|
|
837
|
+
overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), overlayState.selected + 1);
|
|
838
|
+
clearCloseArm();
|
|
839
|
+
requestRender();
|
|
840
|
+
} else if (deps.matchKey(data, "enter")) {
|
|
841
|
+
openDetail();
|
|
842
|
+
} else if (deps.matchKey(data, "escape")) {
|
|
843
|
+
close();
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
invalidate() {},
|
|
847
|
+
dispose() {
|
|
848
|
+
clearCloseArm();
|
|
849
|
+
stopDetailTimer();
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function detailOverlayOptions() {
|
|
855
|
+
const navigatorRows = state().lastMainListLines?.length ?? 0;
|
|
856
|
+
const marginBottom = DETAIL_OVERLAY_FOOTER_MARGIN_ROWS + navigatorRows;
|
|
857
|
+
return {
|
|
858
|
+
anchor: "top-left" as const,
|
|
859
|
+
width: "100%" as const,
|
|
860
|
+
maxHeight: "100%" as const,
|
|
861
|
+
margin: {
|
|
862
|
+
top: DETAIL_OVERLAY_HEADER_MARGIN_ROWS,
|
|
863
|
+
right: 0,
|
|
864
|
+
bottom: marginBottom,
|
|
865
|
+
left: 0,
|
|
866
|
+
},
|
|
867
|
+
visible: (_termWidth: number, termHeight: number) => {
|
|
868
|
+
state().detailOverlayRows = Math.max(1, termHeight - DETAIL_OVERLAY_HEADER_MARGIN_ROWS - marginBottom);
|
|
869
|
+
return true;
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
|
|
875
|
+
return {
|
|
876
|
+
providerId: row.providerId,
|
|
877
|
+
id: row.id,
|
|
878
|
+
title: row.name || row.id,
|
|
879
|
+
status: row.status,
|
|
880
|
+
statusTone: row.statusTone,
|
|
881
|
+
subtitle: row.primary,
|
|
882
|
+
metadata: [
|
|
883
|
+
{ label: "provider", value: row.providerLabel },
|
|
884
|
+
{ label: "kind", value: row.kind },
|
|
885
|
+
{ label: "elapsed", value: row.elapsed },
|
|
886
|
+
],
|
|
887
|
+
evidence: { label: "details", text: row.secondary || "(no details)" },
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function buildListLines(nav: OverlayState, width: number, truncate: (s: string, width: number) => string, fg: (color: string, value: string) => string): string[] {
|
|
892
|
+
const lines: string[] = [];
|
|
893
|
+
lines.push(rule(`Work · ${nav.rows.length}`, width));
|
|
894
|
+
const selected = nav.rows[nav.selected];
|
|
895
|
+
const closeAction = selected ? "x close" : null;
|
|
896
|
+
lines.push(dim(` ${["↑↓ select", "Enter view", closeAction, "Esc close"].filter(Boolean).join(" · ")}`, fg));
|
|
897
|
+
lines.push("");
|
|
898
|
+
if (nav.rows.length === 0) lines.push(" (no work)");
|
|
899
|
+
let lastProvider = "";
|
|
900
|
+
for (let i = 0; i < nav.rows.length; i += 1) {
|
|
901
|
+
const row = nav.rows[i]!;
|
|
902
|
+
if (row.providerLabel !== lastProvider) {
|
|
903
|
+
lines.push(dim(section(row.providerLabel, width), fg));
|
|
904
|
+
lastProvider = row.providerLabel;
|
|
905
|
+
}
|
|
906
|
+
const prefix = i === nav.selected ? fg("accent", "› ") : " ";
|
|
907
|
+
const status = fg(toneColor(row.statusTone, row.status), row.status);
|
|
908
|
+
const facts = (row.facts ?? []).filter(Boolean).slice(0, 2);
|
|
909
|
+
const suffix = facts.length ? ` · ${facts.join(" · ")}` : "";
|
|
910
|
+
lines.push(`${prefix}${row.name || row.id} · ${row.kind} · ${row.elapsed} · ${row.primary} · ${status}${suffix}`);
|
|
911
|
+
if (row.secondary) lines.push(` ${row.secondary}`);
|
|
912
|
+
}
|
|
913
|
+
lines.push("");
|
|
914
|
+
lines.push(dim(rule("", width), fg));
|
|
915
|
+
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function buildDetailLines(
|
|
919
|
+
detail: BackgroundWorkDetail | null,
|
|
920
|
+
width: number,
|
|
921
|
+
truncate: (s: string, width: number) => string,
|
|
922
|
+
fg: (color: string, value: string) => string,
|
|
923
|
+
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number } = {},
|
|
924
|
+
): string[] {
|
|
925
|
+
if (!detail) {
|
|
926
|
+
const lines = [rule("Work unavailable", width), dim(" ← back · Esc close", fg), ""];
|
|
927
|
+
const footerLines = [dim(rule("", width), fg)];
|
|
928
|
+
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
929
|
+
lines.push(...footerLines);
|
|
930
|
+
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
931
|
+
}
|
|
932
|
+
const lines: string[] = [];
|
|
933
|
+
lines.push(fg("accent", rule(detail.title, width)));
|
|
934
|
+
const toggleableSectionId = firstToggleableSectionId(detail);
|
|
935
|
+
const foldedAction = toggleableSectionId
|
|
936
|
+
? (options.expandedSections?.has(toggleableSectionId) ? "Enter collapse" : "Enter expand")
|
|
937
|
+
: null;
|
|
938
|
+
const actions = [foldedAction, ...(detail.footerActions?.length ? detail.footerActions : ["x close"]), "[ fewer", "] more", "l cycle", "Esc close"].filter(Boolean).join(" · ");
|
|
939
|
+
lines.push(dim(` ← back · ${actions}`, fg));
|
|
940
|
+
lines.push("");
|
|
941
|
+
lines.push(` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`);
|
|
942
|
+
if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
|
|
943
|
+
for (const item of detail.metadata) {
|
|
944
|
+
lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
|
|
945
|
+
}
|
|
946
|
+
if (detail.foldedSections?.length) {
|
|
947
|
+
lines.push("");
|
|
948
|
+
for (const section of detail.foldedSections) {
|
|
949
|
+
const expanded = options.expandedSections?.has(section.id) === true;
|
|
950
|
+
if (!expanded) {
|
|
951
|
+
const label = section.label.padEnd(8, " ").slice(0, 8);
|
|
952
|
+
const folded = dim("folded", fg);
|
|
953
|
+
const previewWidth = Math.max(8, width - visibleWidth(` ${label} ${folded}`));
|
|
954
|
+
const preview = truncateVisible(singleLine(section.collapsedText ?? section.text), previewWidth);
|
|
955
|
+
lines.push(` ${label} ${preview} ${folded}`);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
lines.push(dim(sectionHeader(section.label, width), fg));
|
|
959
|
+
for (const raw of wrapDetailText(section.text, width - 6)) lines.push(` ${raw}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
lines.push("");
|
|
963
|
+
const tailRows = options.logTailRows ?? DEFAULT_LOG_TAIL_ROWS;
|
|
964
|
+
const body = detail.evidence.text && detail.evidence.text.trim() ? detail.evidence.text : "(no output yet)";
|
|
965
|
+
if (isFoldableEvidence(detail)) {
|
|
966
|
+
const expanded = options.expandedSections?.has(EVIDENCE_SECTION_ID) === true;
|
|
967
|
+
if (!expanded) {
|
|
968
|
+
lines.push(dim(section(`${detail.evidence.label} · folded`, width), fg));
|
|
969
|
+
const folded = dim("folded", fg);
|
|
970
|
+
const previewWidth = Math.max(8, width - visibleWidth(` ${folded}`));
|
|
971
|
+
const preview = truncateVisible(singleLine(body), previewWidth);
|
|
972
|
+
lines.push(` ${preview} ${folded}`);
|
|
973
|
+
} else {
|
|
974
|
+
const wrapped = wrapEvidenceText(body, width - 6);
|
|
975
|
+
const shown = wrapped.slice(0, tailRows);
|
|
976
|
+
lines.push(dim(section(`${detail.evidence.label} · showing ${shown.length}/${wrapped.length} rows`, width), fg));
|
|
977
|
+
for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
|
|
978
|
+
}
|
|
979
|
+
} else {
|
|
980
|
+
const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
|
|
981
|
+
lines.push(dim(section(evidenceLabel, width), fg));
|
|
982
|
+
for (const raw of body.split(/\r?\n/)) lines.push(raw ? ` ${raw}` : " ");
|
|
983
|
+
}
|
|
984
|
+
lines.push("");
|
|
985
|
+
const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
|
|
986
|
+
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
987
|
+
lines.push(...footerLines);
|
|
988
|
+
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function padBeforeFooter(lines: string[], footerLineCount: number, minRows: number | undefined): void {
|
|
992
|
+
if (minRows === undefined || !Number.isFinite(minRows)) return;
|
|
993
|
+
const target = Math.max(1, Math.floor(minRows));
|
|
994
|
+
while (lines.length + footerLineCount < target) lines.push("");
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function firstToggleableSectionId(detail: BackgroundWorkDetail | null | undefined): string | undefined {
|
|
998
|
+
const first = detail?.foldedSections?.[0];
|
|
999
|
+
if (first) return first.id;
|
|
1000
|
+
return detail && isFoldableEvidence(detail) ? EVIDENCE_SECTION_ID : undefined;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function isFoldableEvidence(detail: BackgroundWorkDetail): boolean {
|
|
1004
|
+
return !/log/i.test(detail.evidence.label);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function sectionHeader(label: string, width: number): string {
|
|
1008
|
+
return section(label, width);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function wrapDetailText(text: string, width: number): string[] {
|
|
1012
|
+
const max = Math.max(16, width);
|
|
1013
|
+
const words = String(text ?? "").split(/\s+/).filter(Boolean);
|
|
1014
|
+
const lines: string[] = [];
|
|
1015
|
+
let line = "";
|
|
1016
|
+
for (const word of words) {
|
|
1017
|
+
if (!line) {
|
|
1018
|
+
line = word;
|
|
1019
|
+
} else if (visibleWidth(`${line} ${word}`) <= max) {
|
|
1020
|
+
line += ` ${word}`;
|
|
1021
|
+
} else {
|
|
1022
|
+
lines.push(line);
|
|
1023
|
+
line = word;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (line) lines.push(line);
|
|
1027
|
+
return lines.length ? lines : ["(empty)"];
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function wrapEvidenceText(text: string, width: number): string[] {
|
|
1031
|
+
const rows: string[] = [];
|
|
1032
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
1033
|
+
if (!raw.trim()) {
|
|
1034
|
+
rows.push("");
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
rows.push(...wrapDetailText(raw, width));
|
|
1038
|
+
}
|
|
1039
|
+
return rows.length ? rows : ["(no output yet)"];
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function nextLogTailRows(current: number): number {
|
|
1043
|
+
for (const value of LOG_TAIL_ROW_CHOICES) if (value > current) return value;
|
|
1044
|
+
return LOG_TAIL_ROW_CHOICES[LOG_TAIL_ROW_CHOICES.length - 1];
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function previousLogTailRows(current: number): number {
|
|
1048
|
+
for (let i = LOG_TAIL_ROW_CHOICES.length - 1; i >= 0; i -= 1) {
|
|
1049
|
+
const value = LOG_TAIL_ROW_CHOICES[i]!;
|
|
1050
|
+
if (value < current) return value;
|
|
1051
|
+
}
|
|
1052
|
+
return LOG_TAIL_ROW_CHOICES[0];
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function cycleLogTailRows(current: number): number {
|
|
1056
|
+
const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
|
|
1057
|
+
return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function toneColor(tone: BackgroundWorkStatusTone | undefined, status: string): string {
|
|
1061
|
+
if (tone === "success") return "success";
|
|
1062
|
+
if (tone === "failed") return "error";
|
|
1063
|
+
if (tone === "warning") return "warning";
|
|
1064
|
+
if (tone === "running") return "accent";
|
|
1065
|
+
switch (status) {
|
|
1066
|
+
case "completed":
|
|
1067
|
+
case "succeeded":
|
|
1068
|
+
return "success";
|
|
1069
|
+
case "failed":
|
|
1070
|
+
case "lost":
|
|
1071
|
+
case "timed_out":
|
|
1072
|
+
return "error";
|
|
1073
|
+
case "cancelled":
|
|
1074
|
+
case "killed":
|
|
1075
|
+
case "orphaned":
|
|
1076
|
+
return "warning";
|
|
1077
|
+
default:
|
|
1078
|
+
return "dim";
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function dim(value: string, fg: (color: string, value: string) => string): string {
|
|
1083
|
+
return fg("dim", value);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function section(label: string, width: number): string {
|
|
1087
|
+
return fill("─", label, width);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function rule(label: string, width: number): string {
|
|
1091
|
+
return fill("━", label, width);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function fill(glyph: string, label: string, width: number): string {
|
|
1095
|
+
const w = Math.max(0, Math.floor(width || 0));
|
|
1096
|
+
if (w <= 0) return "";
|
|
1097
|
+
if (!label) return glyph.repeat(w);
|
|
1098
|
+
const prefix = `${glyph}${glyph} ${label} `;
|
|
1099
|
+
if (visibleWidth(prefix) >= w) return truncateVisible(prefix, w);
|
|
1100
|
+
return prefix + glyph.repeat(w - visibleWidth(prefix));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function safeTruncate(line: string, width: number, truncate: (s: string, width: number) => string): string {
|
|
1104
|
+
if (visibleWidth(line) <= width) return line;
|
|
1105
|
+
const cut = truncate(line, width);
|
|
1106
|
+
return visibleWidth(cut) > width ? truncateVisible(cut, width) : cut;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const ANSI_RE = new RegExp("[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", "g");
|
|
1110
|
+
|
|
1111
|
+
function visibleWidth(value: string): number {
|
|
1112
|
+
return String(value ?? "")
|
|
1113
|
+
.replace(ANSI_RE, "")
|
|
1114
|
+
.replace(/<\/?[a-zA-Z][\w-]*>/g, "")
|
|
1115
|
+
.replace(/<\/>/g, "")
|
|
1116
|
+
.length;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function truncateVisible(value: string, width: number): string {
|
|
1120
|
+
const str = String(value ?? "");
|
|
1121
|
+
const max = Math.max(0, Math.floor(width || 0));
|
|
1122
|
+
if (visibleWidth(str) <= max) return str;
|
|
1123
|
+
let out = "";
|
|
1124
|
+
let vis = 0;
|
|
1125
|
+
let i = 0;
|
|
1126
|
+
while (i < str.length && vis < max) {
|
|
1127
|
+
if (str[i] === "\u001b" || str[i] === "\u009b") {
|
|
1128
|
+
const match = str.slice(i).match(ANSI_RE);
|
|
1129
|
+
if (match && match.index === 0) {
|
|
1130
|
+
out += match[0];
|
|
1131
|
+
i += match[0].length;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (str[i] === "<") {
|
|
1136
|
+
const close = str.indexOf(">", i);
|
|
1137
|
+
if (close !== -1) {
|
|
1138
|
+
const tag = str.slice(i, close + 1);
|
|
1139
|
+
if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
|
|
1140
|
+
out += tag;
|
|
1141
|
+
i = close + 1;
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
out += str[i];
|
|
1147
|
+
vis += 1;
|
|
1148
|
+
i += 1;
|
|
1149
|
+
}
|
|
1150
|
+
return out;
|
|
1151
|
+
}
|