@signalridge/pi-subagents 1.11.0 → 1.12.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/CHANGELOG.md +40 -0
- package/README.md +76 -8
- package/package.json +8 -8
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +27 -2
- package/src/agent-runner.ts +142 -56
- package/src/agent-types.ts +6 -0
- package/src/custom-agents.ts +12 -4
- package/src/enabled-models.ts +12 -5
- package/src/extension-resource-names.ts +105 -0
- package/src/gate.ts +0 -0
- package/src/index.ts +177 -67
- package/src/model-scope.ts +4 -1
- package/src/nested-tools.ts +10 -4
- package/src/parent-questions.ts +453 -0
- package/src/project-trust.ts +146 -0
- package/src/question-origin.ts +24 -0
- package/src/settings.ts +7 -4
- package/src/skill-loader.ts +22 -10
- package/src/supervisor.ts +28 -8
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +49 -18
- package/src/ui/fleet-list.ts +125 -58
- package/src/ui/owner-group.ts +102 -0
- package/src/ui/schedule-menu.ts +14 -7
package/src/ui/fleet-list.ts
CHANGED
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
* can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import { borderedComponent } from "@signalridge/pi-ui";
|
|
14
|
+
import { type Component, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { borderedComponent, sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
16
16
|
import { renderAgentName } from "../agent-color.js";
|
|
17
17
|
import type { AgentManager } from "../agent-manager.js";
|
|
18
18
|
import type { AgentRecord } from "../types.js";
|
|
19
19
|
import { getLifetimeTotal } from "../usage.js";
|
|
20
20
|
import { type AgentActivity, type Theme } from "./agent-display.js";
|
|
21
21
|
import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
|
|
22
|
-
import {
|
|
22
|
+
import { type OwnerGroup, OwnerGroupViewer, workflowGroupId } from "./owner-group.js";
|
|
23
23
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
24
24
|
import type { ViewerKeybindings } from "./viewer-keys.js";
|
|
25
25
|
|
|
@@ -48,9 +48,10 @@ export type FleetUICtx = {
|
|
|
48
48
|
): Promise<T>;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
type MainEntry = { kind: "main" };
|
|
52
|
-
type AgentEntry = { kind: "agent"; record: AgentRecord };
|
|
53
|
-
type
|
|
51
|
+
type MainEntry = { kind: "main"; id: string };
|
|
52
|
+
type AgentEntry = { kind: "agent"; id: string; record: AgentRecord };
|
|
53
|
+
type GroupEntry = { kind: "group"; id: string; group: OwnerGroup };
|
|
54
|
+
type FleetEntry = MainEntry | AgentEntry | GroupEntry;
|
|
54
55
|
|
|
55
56
|
/** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
|
|
56
57
|
export function formatFleetElapsed(ms: number): string {
|
|
@@ -91,6 +92,8 @@ export class FleetList {
|
|
|
91
92
|
private active = false;
|
|
92
93
|
/** 0 = `main`, 1..N = subagents. */
|
|
93
94
|
private selectedIndex = 0;
|
|
95
|
+
private selectedId = "main";
|
|
96
|
+
private overlayGeneration = 0;
|
|
94
97
|
/** Set while a conversation overlay is open; calling it closes the overlay. */
|
|
95
98
|
private viewerClose: (() => void) | undefined;
|
|
96
99
|
private viewingAgentId: string | undefined;
|
|
@@ -112,7 +115,7 @@ export class FleetList {
|
|
|
112
115
|
/** Capture the UI context and (re)register the global input handler. */
|
|
113
116
|
setUICtx(ui: FleetUICtx): void {
|
|
114
117
|
if (ui === this.ui) return;
|
|
115
|
-
this.
|
|
118
|
+
this.dispose();
|
|
116
119
|
this.ui = ui;
|
|
117
120
|
this.widgetRegistered = false;
|
|
118
121
|
this.tui = undefined;
|
|
@@ -133,6 +136,7 @@ export class FleetList {
|
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
dispose(): void {
|
|
139
|
+
this.overlayGeneration++;
|
|
136
140
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
|
137
141
|
this.inputUnsub?.();
|
|
138
142
|
this.inputUnsub = undefined;
|
|
@@ -149,7 +153,7 @@ export class FleetList {
|
|
|
149
153
|
/** Re-register/refresh the below-editor widget; clears it when no agents remain. */
|
|
150
154
|
update(): void {
|
|
151
155
|
if (!this.ui) return;
|
|
152
|
-
const hasAgents = this.enabled && this.
|
|
156
|
+
const hasAgents = this.enabled && this.roster().length > 1;
|
|
153
157
|
|
|
154
158
|
if (!hasAgents) {
|
|
155
159
|
if (this.widgetRegistered) {
|
|
@@ -160,6 +164,7 @@ export class FleetList {
|
|
|
160
164
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
|
161
165
|
this.active = false;
|
|
162
166
|
this.selectedIndex = 0;
|
|
167
|
+
this.selectedId = "main";
|
|
163
168
|
return;
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -171,7 +176,8 @@ export class FleetList {
|
|
|
171
176
|
this.tui = tui;
|
|
172
177
|
return {
|
|
173
178
|
render: (w: number) => this.renderBar(w, theme),
|
|
174
|
-
invalidate: () => {
|
|
179
|
+
invalidate: () => { /* no render cache; mounting/focus remain owned */ },
|
|
180
|
+
dispose: () => { if (this.tui === tui) { this.widgetRegistered = false; this.tui = undefined; } },
|
|
175
181
|
};
|
|
176
182
|
}, { placement: "belowEditor" });
|
|
177
183
|
this.widgetRegistered = true;
|
|
@@ -193,7 +199,7 @@ export class FleetList {
|
|
|
193
199
|
private agentRecords(): AgentRecord[] {
|
|
194
200
|
const now = Date.now();
|
|
195
201
|
return this.manager.listAgentsMutable()
|
|
196
|
-
.filter(a => !a.parentAgentId && a.session && (
|
|
202
|
+
.filter(a => !a.parentAgentId && !a.detached && !workflowGroupId(a) && a.session && (
|
|
197
203
|
a.status === "running" || a.status === "queued"
|
|
198
204
|
|| a.id === this.viewingAgentId
|
|
199
205
|
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
|
|
@@ -201,14 +207,39 @@ export class FleetList {
|
|
|
201
207
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
202
208
|
}
|
|
203
209
|
|
|
210
|
+
private groups(): OwnerGroup[] {
|
|
211
|
+
const groups = new Map<string, OwnerGroup>();
|
|
212
|
+
for (const record of this.manager.listAgentsMutable()) {
|
|
213
|
+
if (record.parentAgentId || record.detached) continue;
|
|
214
|
+
const id = workflowGroupId(record);
|
|
215
|
+
if (!id) continue;
|
|
216
|
+
let group = groups.get(id);
|
|
217
|
+
if (!group) { group = { id, runId: record.owner!.runId, records: [] }; groups.set(id, group); }
|
|
218
|
+
group.records.push(record);
|
|
219
|
+
}
|
|
220
|
+
for (const group of groups.values()) group.records.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
|
|
221
|
+
return [...groups.values()];
|
|
222
|
+
}
|
|
223
|
+
|
|
204
224
|
private roster(): FleetEntry[] {
|
|
205
|
-
return [
|
|
225
|
+
return [
|
|
226
|
+
{ kind: "main", id: "main" },
|
|
227
|
+
...this.groups().map(group => ({ kind: "group" as const, id: `group:${group.id}`, group })),
|
|
228
|
+
...this.agentRecords().map(record => ({ kind: "agent" as const, id: `agent:${record.id}`, record })),
|
|
229
|
+
];
|
|
206
230
|
}
|
|
207
231
|
|
|
208
|
-
private clampSelection(): void {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
232
|
+
private clampSelection(roster = this.roster()): void {
|
|
233
|
+
const index = roster.findIndex(entry => entry.id === this.selectedId);
|
|
234
|
+
// Missing identity returns explicitly to main, never to a different child.
|
|
235
|
+
this.selectedIndex = index >= 0 ? index : 0;
|
|
236
|
+
this.selectedId = roster[this.selectedIndex].id;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private select(index: number): void {
|
|
240
|
+
const roster = this.roster();
|
|
241
|
+
this.selectedIndex = Math.max(0, Math.min(roster.length - 1, index));
|
|
242
|
+
this.selectedId = roster[this.selectedIndex].id;
|
|
212
243
|
}
|
|
213
244
|
|
|
214
245
|
// ---- Key handling ----
|
|
@@ -234,25 +265,27 @@ export class FleetList {
|
|
|
234
265
|
if (!this.active) {
|
|
235
266
|
// Activate: ↓ or ← at an empty prompt moves focus into the list.
|
|
236
267
|
const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
|
|
237
|
-
if (isActivator && this.
|
|
268
|
+
if (isActivator && this.roster().length > 1 && this.ui.getEditorText() === "") {
|
|
238
269
|
this.active = true;
|
|
239
|
-
this.
|
|
270
|
+
this.select(0);
|
|
240
271
|
this.update();
|
|
241
272
|
return { consume: true };
|
|
242
273
|
}
|
|
243
274
|
return undefined;
|
|
244
275
|
}
|
|
245
276
|
|
|
277
|
+
// Reconcile identity before both navigation and opening, even between ticks.
|
|
278
|
+
this.clampSelection();
|
|
246
279
|
// Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
|
|
247
280
|
if (matchesKey(data, "down")) {
|
|
248
281
|
const max = this.roster().length - 1;
|
|
249
|
-
this.
|
|
282
|
+
this.select(Math.min(max, this.selectedIndex + 1));
|
|
250
283
|
this.update();
|
|
251
284
|
return { consume: true };
|
|
252
285
|
}
|
|
253
286
|
if (matchesKey(data, "up")) {
|
|
254
287
|
if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
|
|
255
|
-
this.selectedIndex
|
|
288
|
+
this.select(this.selectedIndex - 1);
|
|
256
289
|
this.update();
|
|
257
290
|
return { consume: true };
|
|
258
291
|
}
|
|
@@ -265,64 +298,91 @@ export class FleetList {
|
|
|
265
298
|
}
|
|
266
299
|
|
|
267
300
|
/**
|
|
268
|
-
* True when pi's prompt editor owns the keyboard.
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
301
|
+
* True when pi's prompt editor owns the keyboard. The editor is pluggable
|
|
302
|
+
* (ui.setEditorComponent) and need not extend `Editor` (#374), so match pi-tui's
|
|
303
|
+
* `EditorComponent` contract instead: `getText` + `setText`, which pi calls on
|
|
304
|
+
* every editor and none of pi's dialogs, selectors or overlays implement.
|
|
305
|
+
* Use the public structural focus getter (not present in every TUI interface
|
|
306
|
+
* declaration). Unknown focus retains the initial editor fallback.
|
|
274
307
|
*/
|
|
275
308
|
private editorHasFocus(): boolean {
|
|
276
|
-
const
|
|
277
|
-
|
|
309
|
+
const focusHost = this.tui as { getFocusedComponent?: () => unknown } | undefined;
|
|
310
|
+
const focused = typeof focusHost?.getFocusedComponent === "function" ? focusHost.getFocusedComponent() : undefined;
|
|
311
|
+
if (focused == null) return true;
|
|
312
|
+
const c = focused as { getText?: unknown; setText?: unknown };
|
|
313
|
+
return typeof c.getText === "function" && typeof c.setText === "function";
|
|
278
314
|
}
|
|
279
315
|
|
|
280
316
|
private deactivate(): void {
|
|
281
317
|
this.active = false;
|
|
282
|
-
this.
|
|
318
|
+
this.select(0);
|
|
283
319
|
this.update();
|
|
284
320
|
}
|
|
285
321
|
|
|
286
322
|
private openSelected(): void {
|
|
287
|
-
const
|
|
323
|
+
const roster = this.roster();
|
|
324
|
+
this.clampSelection(roster);
|
|
325
|
+
const entry = roster[this.selectedIndex];
|
|
288
326
|
if (!entry || entry.kind === "main") {
|
|
289
327
|
// `main` = return to the prompt; the native transcript is already shown.
|
|
290
328
|
this.deactivate();
|
|
291
329
|
return;
|
|
292
330
|
}
|
|
293
|
-
const record = entry.record;
|
|
294
331
|
if (!this.ui) return;
|
|
332
|
+
if (entry.kind === "group") { this.openGroup(entry.group); return; }
|
|
333
|
+
const record = entry.record;
|
|
295
334
|
if (!record.session) {
|
|
296
335
|
this.ui.notify(`Agent is ${record.status}; no session available.`, "info");
|
|
297
336
|
return;
|
|
298
337
|
}
|
|
299
|
-
const session = record.session;
|
|
300
|
-
const activity = this.agentActivity.get(record.id);
|
|
301
338
|
this.viewingAgentId = record.id;
|
|
339
|
+
void this.openOverlay((tui, theme, keys, done) => this.conversation(record, tui, theme, keys, done));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private conversation(record: AgentRecord, tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void): ConversationViewer {
|
|
343
|
+
const session = record.session;
|
|
344
|
+
if (!session) throw new Error("No session available");
|
|
345
|
+
const owner = record.owner ? { ...record.owner } : undefined;
|
|
346
|
+
// An id can be reused for another thread attempt. Bind controls to this
|
|
347
|
+
// session and immutable owner generation, never to id alone.
|
|
348
|
+
const current = (): AgentRecord | undefined => this.manager.listAgentsMutable().find(candidate =>
|
|
349
|
+
candidate.id === record.id && candidate.session === session && !candidate.detached &&
|
|
350
|
+
candidate.owner?.extension === owner?.extension && candidate.owner?.runId === owner?.runId &&
|
|
351
|
+
candidate.owner?.nodeId === owner?.nodeId && candidate.owner?.attemptId === owner?.attemptId);
|
|
352
|
+
return new ConversationViewer(tui, session, record, this.agentActivity.get(record.id), theme, done,
|
|
353
|
+
() => { if (current() && this.manager.abort(record.id)) this.ui?.notify(`Stopped "${sanitizeDisplayText(record.description)}".`, "info"); },
|
|
354
|
+
keys,
|
|
355
|
+
message => !!current() && this.manager.steer(record.id, message),
|
|
356
|
+
() => current() ?? { ...record, status: record.status === "running" || record.status === "queued" ? "stopped" : record.status, detached: true },
|
|
357
|
+
);
|
|
358
|
+
}
|
|
302
359
|
|
|
303
|
-
|
|
304
|
-
|
|
360
|
+
private openGroup(group: OwnerGroup): void {
|
|
361
|
+
void this.openOverlay((tui, theme, keys, done) => new OwnerGroupViewer(tui, theme, group.runId,
|
|
362
|
+
() => this.groups().find(candidate => candidate.id === group.id)?.records ?? [],
|
|
363
|
+
(record, back) => this.conversation(record, tui, theme, keys, back),
|
|
364
|
+
() => done(undefined), keys));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async openOverlay(factory: (tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void) => Component): Promise<void> {
|
|
368
|
+
const ui = this.ui;
|
|
369
|
+
if (!ui) return;
|
|
370
|
+
const generation = ++this.overlayGeneration;
|
|
371
|
+
this.viewerClose = () => {}; // reserve input during asynchronous construction
|
|
372
|
+
try {
|
|
373
|
+
await ui.custom<undefined>((tui, theme, keys, done) => {
|
|
374
|
+
if (generation !== this.overlayGeneration || ui !== this.ui) {
|
|
375
|
+
done(undefined);
|
|
376
|
+
return { render: () => [], invalidate: () => {} };
|
|
377
|
+
}
|
|
305
378
|
this.viewerClose = () => done(undefined);
|
|
306
|
-
return borderedComponent(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
() => {
|
|
314
|
-
if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${sanitizeDisplayText(record.description)}".`, "info");
|
|
315
|
-
},
|
|
316
|
-
keybindings,
|
|
317
|
-
(message: string) => this.manager.steer(record.id, message),
|
|
318
|
-
() => this.manager.getRecord?.(record.id),
|
|
319
|
-
), (text) => theme.fg("borderAccent", text));
|
|
320
|
-
},
|
|
321
|
-
{
|
|
322
|
-
overlay: true,
|
|
323
|
-
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
324
|
-
},
|
|
325
|
-
).then(() => this.clearViewer(), () => this.clearViewer());
|
|
379
|
+
return borderedComponent(factory(tui, theme, keys, done), text => theme.fg("borderAccent", text));
|
|
380
|
+
}, { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` } });
|
|
381
|
+
} catch {
|
|
382
|
+
if (generation === this.overlayGeneration) ui.notify("Cannot open conversation; try again.", "error");
|
|
383
|
+
} finally {
|
|
384
|
+
if (generation === this.overlayGeneration) this.clearViewer();
|
|
385
|
+
}
|
|
326
386
|
}
|
|
327
387
|
|
|
328
388
|
/** Reset overlay state and return to the list (on close, auto-close, or error). */
|
|
@@ -333,7 +393,7 @@ export class FleetList {
|
|
|
333
393
|
// update()'s clamp to settle.
|
|
334
394
|
if (this.viewingAgentId) {
|
|
335
395
|
const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId);
|
|
336
|
-
if (idx >= 0) this.
|
|
396
|
+
if (idx >= 0) this.select(idx);
|
|
337
397
|
}
|
|
338
398
|
this.viewerClose = undefined;
|
|
339
399
|
this.viewingAgentId = undefined;
|
|
@@ -343,9 +403,11 @@ export class FleetList {
|
|
|
343
403
|
// ---- Rendering ----
|
|
344
404
|
|
|
345
405
|
private renderBar(width: number, theme: Theme): string[] {
|
|
346
|
-
const
|
|
406
|
+
const roster = this.roster();
|
|
407
|
+
this.clampSelection(roster);
|
|
408
|
+
const agents = roster.slice(1);
|
|
347
409
|
if (agents.length === 0) return [];
|
|
348
|
-
const sel =
|
|
410
|
+
const sel = this.selectedIndex;
|
|
349
411
|
|
|
350
412
|
// Key names read as proper nouns, the verbs after them stay lowercase, and
|
|
351
413
|
// segments are separated by " · " — the same shape every hint line in the
|
|
@@ -372,7 +434,12 @@ export class FleetList {
|
|
|
372
434
|
|
|
373
435
|
if (start > 0) lines.push(rightAlign("", theme.fg("dim", `${start} more above`), width));
|
|
374
436
|
for (let a = start; a < start + visible; a++) {
|
|
375
|
-
|
|
437
|
+
const entry = agents[a];
|
|
438
|
+
if (entry.kind === "agent") lines.push(this.renderAgentRow(a + 1, sel, entry.record, width, theme));
|
|
439
|
+
else if (entry.kind === "group") {
|
|
440
|
+
const label = ` ▸ Workflow ${sanitizeDisplayText(entry.group.runId)} · ${entry.group.records.length} retained children`;
|
|
441
|
+
lines.push(truncateToWidth(theme.fg(a + 1 === sel ? "accent" : "muted", label), width));
|
|
442
|
+
}
|
|
376
443
|
}
|
|
377
444
|
if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more below`), width));
|
|
378
445
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Observation-only retained workflow children; no workflow engine/RPC dependency. */
|
|
2
|
+
import { type Component, isKeyRelease, matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
4
|
+
import type { AgentRecord } from "../types.js";
|
|
5
|
+
import type { Theme } from "./agent-display.js";
|
|
6
|
+
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
7
|
+
|
|
8
|
+
type DisposableComponent = Component & { dispose?(): void };
|
|
9
|
+
|
|
10
|
+
export type OwnerGroup = { id: string; runId: string; records: AgentRecord[] };
|
|
11
|
+
|
|
12
|
+
export function workflowGroupId(record: AgentRecord): string | undefined {
|
|
13
|
+
const owner = record.owner;
|
|
14
|
+
if (owner?.extension !== "pi-workflows" || typeof owner.runId !== "string" || !owner.runId.trim()) return undefined;
|
|
15
|
+
return JSON.stringify([owner.extension, owner.runId]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A bounded child list that swaps its body for the existing conversation viewer. */
|
|
19
|
+
export class OwnerGroupViewer implements Component {
|
|
20
|
+
private selectedId: string | undefined;
|
|
21
|
+
private initialized = false;
|
|
22
|
+
private child: DisposableComponent | undefined;
|
|
23
|
+
private closed = false;
|
|
24
|
+
private error: string | undefined;
|
|
25
|
+
private keys: ViewerKeys;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private tui: TUI,
|
|
29
|
+
private theme: Theme,
|
|
30
|
+
private runId: string,
|
|
31
|
+
private records: () => AgentRecord[],
|
|
32
|
+
private open: (record: AgentRecord, done: () => void) => DisposableComponent,
|
|
33
|
+
private done: () => void,
|
|
34
|
+
keybindings?: ViewerKeybindings,
|
|
35
|
+
) { this.keys = createViewerKeys(keybindings); }
|
|
36
|
+
|
|
37
|
+
private roster(): AgentRecord[] {
|
|
38
|
+
const records = this.records();
|
|
39
|
+
if (!this.initialized) { this.selectedId = records[0]?.id; this.initialized = true; }
|
|
40
|
+
else if (!records.some(record => record.id === this.selectedId)) this.selectedId = undefined;
|
|
41
|
+
return records;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
handleInput(data: string): void {
|
|
45
|
+
if (this.closed || isKeyRelease(data)) return;
|
|
46
|
+
if (this.child) { this.child.handleInput?.(data); return; }
|
|
47
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) { this.done(); return; }
|
|
48
|
+
const records = this.roster();
|
|
49
|
+
const index = records.findIndex(record => record.id === this.selectedId);
|
|
50
|
+
const page = Math.max(1, Math.floor(this.tui.terminal.rows * 0.7) - 5);
|
|
51
|
+
let next = index;
|
|
52
|
+
if (this.keys.scrollUp(data)) next--;
|
|
53
|
+
else if (this.keys.scrollDown(data)) next++;
|
|
54
|
+
else if (this.keys.pageUp(data)) next -= page;
|
|
55
|
+
else if (this.keys.pageDown(data)) next += page;
|
|
56
|
+
else if (matchesKey(data, "home")) next = 0;
|
|
57
|
+
else if (matchesKey(data, "end")) next = records.length - 1;
|
|
58
|
+
else if (matchesKey(data, "enter")) {
|
|
59
|
+
const record = records[index];
|
|
60
|
+
if (!record?.session) this.error = record ? "No session yet; child retained" : "Selection gone; use ↑↓";
|
|
61
|
+
else {
|
|
62
|
+
try {
|
|
63
|
+
this.child = this.open(record, () => {
|
|
64
|
+
this.child?.dispose?.();
|
|
65
|
+
this.child = undefined;
|
|
66
|
+
this.tui.requestRender();
|
|
67
|
+
});
|
|
68
|
+
this.error = undefined;
|
|
69
|
+
} catch { this.error = "Cannot open conversation; try again"; }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (records.length && next !== index) this.selectedId = records[Math.max(0, Math.min(records.length - 1, next))].id;
|
|
73
|
+
this.tui.requestRender();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render(width: number): string[] {
|
|
77
|
+
if (this.child) return this.child.render(width);
|
|
78
|
+
const records = this.roster();
|
|
79
|
+
const selected = records.findIndex(record => record.id === this.selectedId);
|
|
80
|
+
// Reserve header, two controls rows, and the shared adapter's two borders.
|
|
81
|
+
const bodyRows = Math.max(1, Math.floor(this.tui.terminal.rows * 0.7) - 5);
|
|
82
|
+
const start = Math.max(0, selected - bodyRows + 1);
|
|
83
|
+
const labels = records.map(record => sanitizeDisplayText(record.description));
|
|
84
|
+
const counts = new Map<string, number>();
|
|
85
|
+
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
86
|
+
const lines = [this.theme.fg("accent", `Workflow ${sanitizeDisplayText(this.runId)} · ${records.length} retained children`)];
|
|
87
|
+
if (!records.length) lines.push("No retained children");
|
|
88
|
+
for (let index = start; index < Math.min(records.length, start + bodyRows); index++) {
|
|
89
|
+
const record = records[index];
|
|
90
|
+
const discriminator = (counts.get(labels[index]) ?? 0) > 1 ? ` [${sanitizeDisplayText(record.id)}]` : "";
|
|
91
|
+
// The discriminator leads the label so it survives narrow-width clipping.
|
|
92
|
+
const text = `${record.id === this.selectedId ? ">" : " "}${discriminator} ${labels[index]} · ${record.status}${record.session ? "" : " · no session yet"}`;
|
|
93
|
+
lines.push(this.theme.fg(record.id === this.selectedId ? "accent" : "muted", text));
|
|
94
|
+
}
|
|
95
|
+
lines.push(this.error ? this.theme.fg("error", this.error) : `${selected + 1}/${records.length} · ↑↓/PgUp/PgDn`);
|
|
96
|
+
lines.push(width >= 22 ? "Esc back · Enter view" : width >= 10 ? "Esc back ↵" : "Esc ↵");
|
|
97
|
+
return lines.map(line => truncateToWidth(line, Math.max(0, width)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
invalidate(): void { this.child?.invalidate(); }
|
|
101
|
+
dispose(): void { this.closed = true; this.child?.dispose?.(); this.child = undefined; }
|
|
102
|
+
}
|
package/src/ui/schedule-menu.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
12
14
|
import type { SubagentScheduler } from "../schedule.js";
|
|
13
15
|
import type { ScheduledSubagent } from "../types.js";
|
|
14
16
|
import { selectItem } from "./select-item.js";
|
|
@@ -38,14 +40,19 @@ export function statusText(j: ScheduledSubagent): string {
|
|
|
38
40
|
return "ready";
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
function displayCell(text: string, width: number): string {
|
|
44
|
+
// Drop only trusted truncation resets, after neutralizing untrusted controls.
|
|
45
|
+
return stripTerminalSequences(truncateToWidth(sanitizeDisplayText(text), width));
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
/** Compact selectable row — name, schedule, agent type, next/last run, count. */
|
|
42
49
|
function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
|
|
43
50
|
const next = scheduler.getNextRun(j.id);
|
|
44
51
|
return [
|
|
45
52
|
statusText(j),
|
|
46
|
-
j.name
|
|
47
|
-
j.schedule
|
|
48
|
-
`[${j.subagent_type}]`,
|
|
53
|
+
displayCell(j.name, 18).padEnd(18),
|
|
54
|
+
displayCell(j.schedule, 14).padEnd(14),
|
|
55
|
+
`[${sanitizeDisplayText(j.subagent_type)}]`,
|
|
49
56
|
`next ${relTime(next)}`,
|
|
50
57
|
`last ${relTime(j.lastRun)}`,
|
|
51
58
|
`runs ${j.runCount}`,
|
|
@@ -59,12 +66,12 @@ function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): stri
|
|
|
59
66
|
`name: ${j.name}`,
|
|
60
67
|
`schedule: ${j.schedule} (${j.scheduleType})`,
|
|
61
68
|
`agent: ${j.subagent_type}`,
|
|
62
|
-
`prompt: ${j.prompt
|
|
69
|
+
`prompt: ${displayCell(j.prompt, 200)}`,
|
|
63
70
|
`created: ${j.createdAt}`,
|
|
64
71
|
`last run: ${j.lastRun ?? "not set"} (${j.lastStatus ?? "not set"})`,
|
|
65
72
|
`next run: ${next}`,
|
|
66
73
|
`runs: ${j.runCount}`,
|
|
67
|
-
].join("\n");
|
|
74
|
+
].map(line => sanitizeDisplayText(line)).join("\n");
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
/**
|
|
@@ -94,9 +101,9 @@ export async function showSchedulesMenu(
|
|
|
94
101
|
);
|
|
95
102
|
if (!job) return;
|
|
96
103
|
|
|
97
|
-
const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
|
|
104
|
+
const ok = await ctx.ui.confirm(`Cancel "${sanitizeDisplayText(job.name)}"?`, formatDetails(job, scheduler));
|
|
98
105
|
if (!ok) return;
|
|
99
106
|
|
|
100
107
|
scheduler.removeJob(job.id);
|
|
101
|
-
ctx.ui.notify(`Cancelled "${job.name}".`, "info");
|
|
108
|
+
ctx.ui.notify(`Cancelled "${sanitizeDisplayText(job.name)}".`, "info");
|
|
102
109
|
}
|