@signalridge/pi-subagents 1.11.1 → 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.
@@ -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
+ }
@@ -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.padEnd(18).slice(0, 18),
47
- j.schedule.padEnd(14).slice(0, 14),
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.slice(0, 200)}${j.prompt.length > 200 ? "..." : ""}`,
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
  }