@signalridge/pi-workflows 1.8.0 → 1.9.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/src/ui.ts CHANGED
@@ -1,276 +1,54 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
- import { Key, matchesKey, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
2
  import type { WorkflowEngine } from "./engine.js";
5
- import type { ScriptRun } from "./journal.js";
6
3
  import { sanitizeDisplayText } from "./safe-text.js";
7
-
8
- /** Bounds, in code points, for untrusted strings rendered into the navigator. */
9
- const MAX_NAME_CHARS = 200;
10
- const MAX_ID_CHARS = 128;
11
- const MAX_RESULT_CHARS = 300;
12
- const MAX_RUN_ERROR_CHARS = 500;
13
- const MAX_DESCRIPTION_CHARS = 120;
14
-
15
- /**
16
- * Pure snapshot render layer shared by the navigator, the live widget, and any
17
- * inline render site. All child-agent output passes through sanitizeDisplayText
18
- * BEFORE truncation so a live escape introducer is never cut mid-sequence.
19
- */
20
- export function snapshotRunLine(run: ScriptRun): string {
21
- return formatWorkflowRunLabel(run);
22
- }
23
-
24
- /** Live-widget lines for all non-terminal runs (A10). */
25
- export function liveWidgetLines(
26
- runs: readonly ScriptRun[],
27
- mode: "compact" | "detailed" = "compact",
28
- maxAgentsShown = 6,
29
- ): string[] {
30
- const active = runs.filter((run) => !isTerminal(run.status));
31
- if (active.length === 0) return [];
32
- const boundedMax = Math.max(1, Math.min(32, Math.floor(maxAgentsShown)));
33
- const lines = ["Workflows"];
34
- for (const run of active.slice(0, boundedMax)) {
35
- lines.push(` ${snapshotRunLine(run)}`);
36
- if (mode === "detailed") {
37
- for (const [nodeId, status] of Object.entries(run.callStatus).slice(0, boundedMax)) {
38
- lines.push(` ${nodeId}: ${status}`);
39
- }
40
- }
41
- }
42
- return lines.slice(0, 1 + boundedMax * (mode === "detailed" ? boundedMax + 1 : 1));
43
- }
44
-
45
- export type RunStatus = ScriptRun["status"];
46
-
47
- function elapsed(ms: number): string {
48
- const seconds = Math.floor(ms / 1_000);
49
- if (seconds < 60) return `${seconds}s`;
50
- const minutes = Math.floor(seconds / 60);
51
- return `${minutes}m ${seconds % 60}s`;
52
- }
53
-
54
- function isTerminal(status: RunStatus): boolean {
55
- return status === "completed" || status === "failed" || status === "stopped";
56
- }
57
-
58
- export function formatWorkflowRunLabel(run: ScriptRun): string {
59
- const phases = run.meta.phases && run.meta.phases.length > 0 ? ` · phases ${run.meta.phases.length}` : "";
60
- const calls = Object.keys(run.callResults).length;
61
- const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
62
- return `${name} · ${run.status}${phases} · ${calls} calls · ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`;
63
- }
64
-
65
- function resultText(value: unknown): string {
66
- if (typeof value === "string") return value;
67
- try {
68
- return JSON.stringify(value) ?? "(no preview)";
69
- } catch {
70
- return "(unavailable preview)";
71
- }
72
- }
73
-
74
- export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
75
- const state = engine.getState(runId);
76
- if (!state) return "Workflow run is no longer available.";
77
- const run = state.run;
78
- const tokenValues = Object.values(run.callResults)
79
- .map((result) => result.tokenCount)
80
- .filter((value): value is number => value !== undefined);
81
- const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
82
- const lines = [
83
- `${sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS)} [${run.status}]`,
84
- `run: ${sanitizeDisplayText(run.runId, MAX_ID_CHARS)}`,
85
- `elapsed: ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
86
- "",
87
- ...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
88
- ];
89
- for (const phase of run.meta.phases ?? []) {
90
- lines.push(`Phase: ${sanitizeDisplayText(phase.title, MAX_NAME_CHARS)}`);
91
- }
92
- const calls = Object.entries(run.callStatus)
93
- .sort(([a], [b]) => Number(a) - Number(b))
94
- .map(([nodeId, status]) => {
95
- const result = run.callResults[nodeId];
96
- const agent = run.agentIds[nodeId] ? ` agent=${sanitizeDisplayText(run.agentIds[nodeId], MAX_ID_CHARS)}` : "";
97
- const compact = run.compactions[nodeId] ? ` compactions=${run.compactions[nodeId]}` : "";
98
- const parts = [` ${status.padEnd(11)} call ${sanitizeDisplayText(nodeId, MAX_ID_CHARS)}${agent}${compact}`];
99
- if (result?.error) parts.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
100
- if (result?.result !== undefined && result.status === "completed") {
101
- parts.push(` result: ${sanitizeDisplayText(resultText(result.result), MAX_RESULT_CHARS)}`);
102
- }
103
- return parts.join("\n");
104
- });
105
- if (calls.length > 0) {
106
- lines.push("Calls:");
107
- lines.push(...calls);
108
- }
109
- lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
110
- if (run.error) lines.push(`Error: ${sanitizeDisplayText(run.error, MAX_RUN_ERROR_CHARS)}`);
111
- return lines.join("\n");
112
- }
113
-
114
- /** Actions the `/workflows` detail menu can offer, in display order. */
115
- export type WorkflowAction = "refresh" | "pause" | "resume" | "stop" | "back";
116
-
117
- export interface WorkflowActionItem {
118
- value: WorkflowAction;
119
- label: string;
120
- }
121
-
122
- const REFRESH: WorkflowActionItem = { value: "refresh", label: "Refresh" };
123
- const PAUSE: WorkflowActionItem = { value: "pause", label: "Pause" };
124
- const RESUME: WorkflowActionItem = { value: "resume", label: "Resume" };
125
- const STOP: WorkflowActionItem = { value: "stop", label: "Stop" };
126
- const BACK: WorkflowActionItem = { value: "back", label: "Back" };
127
-
128
- /**
129
- * Menu actions legal for a run in `status`. Exhaustive by construction.
130
- */
131
- export function workflowActionsFor(status: RunStatus): readonly WorkflowActionItem[] {
132
- switch (status) {
133
- case "pending":
134
- case "pausing":
135
- case "stopping":
136
- return [REFRESH, STOP, BACK];
137
- case "running":
138
- return [REFRESH, PAUSE, STOP, BACK];
139
- case "paused":
140
- case "interrupted":
141
- return [REFRESH, RESUME, STOP, BACK];
142
- case "completed":
143
- case "failed":
144
- case "stopped":
145
- return [REFRESH, BACK];
146
- }
147
- const unreachable: never = status;
148
- return unreachable;
149
- }
150
-
151
- async function selectList<T>(
152
- ctx: ExtensionCommandContext,
153
- title: string,
154
- items: SelectItem[],
155
- renderExtra: (theme: Pick<Theme, "fg">) => string,
156
- ): Promise<T | undefined> {
157
- if (ctx.mode !== "tui" || !ctx.hasUI) return undefined;
158
- return ctx.ui.custom<T | undefined>((tui, theme, _keybindings, done) => {
159
- const border = new DynamicBorder((s: string) => theme.fg("borderAccent", s));
160
- const heading = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
161
- const detail = new Text(theme.fg("dim", renderExtra(theme)), 1, 0);
162
- let offset = 0;
163
- let pageSize = 1;
164
- const list = new SelectList(items, Math.min(12, Math.max(1, items.length)), {
165
- selectedPrefix: (text) => theme.fg("accent", text),
166
- selectedText: (text) => theme.fg("accent", text),
167
- description: (text) => theme.fg("muted", text),
168
- scrollInfo: (text) => theme.fg("dim", text),
169
- noMatch: (text) => theme.fg("warning", text),
170
- });
171
- list.onSelect = (item) => done(item.value as T);
172
- list.onCancel = () => done(undefined);
173
- const hint = new Text(theme.fg("dim", "↑↓ select · enter confirm · esc back · PgUp/PgDn details"), 1, 0);
174
- return {
175
- render: (width) => {
176
- const top = [...border.render(width), ...heading.render(width).slice(0, 2)];
177
- const bottom = [...list.render(width), ...hint.render(width), ...border.render(width)];
178
- pageSize = Math.max(1, tui.terminal.rows - top.length - bottom.length - 1);
179
- const lines = detail.render(width);
180
- offset = Math.max(0, Math.min(offset, lines.length - pageSize));
181
- return [...top, ...lines.slice(offset, offset + pageSize), ...bottom];
182
- },
183
- invalidate: () => {
184
- heading.invalidate();
185
- detail.invalidate();
186
- list.invalidate();
187
- },
188
- handleInput: (data) => {
189
- if (matchesKey(data, Key.pageDown)) offset += pageSize;
190
- else if (matchesKey(data, Key.pageUp)) offset = Math.max(0, offset - pageSize);
191
- else list.handleInput(data);
192
- tui.requestRender();
193
- },
194
- };
195
- });
196
- }
197
-
198
- /** Interactive `/workflows` navigator. TUI-only by design. */
4
+ import { WorkflowCommandCenter, type WorkflowNavigatorOptions } from "./ui-command-center.js";
5
+
6
+ export type { WorkflowNavigatorOptions } from "./ui-command-center.js";
7
+ export {
8
+ formatWorkflowDetail,
9
+ formatWorkflowRunLabel,
10
+ liveWidgetLines,
11
+ type RunStatus,
12
+ snapshotRunLine,
13
+ type WorkflowAction,
14
+ type WorkflowActionItem,
15
+ workflowActionsFor,
16
+ } from "./ui-presentation.js";
17
+
18
+ /** Live, explicitly opened `/workflows` command center. No headless TUI mounts. */
199
19
  export async function showWorkflowNavigator(
200
- ctx: ExtensionCommandContext,
20
+ ctx: ExtensionContext,
201
21
  engineSource: WorkflowEngine | (() => WorkflowEngine),
22
+ options: WorkflowNavigatorOptions = {},
202
23
  ): Promise<void> {
203
- if (ctx.mode !== "tui") {
24
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
204
25
  ctx.ui.notify("/workflows requires TUI mode; use workflow_control for machine-readable access.", "warning");
205
26
  return;
206
27
  }
207
- const resolveEngine = typeof engineSource === "function" ? engineSource : () => engineSource;
208
- let inactiveNotified = false;
209
- const currentEngine = (): WorkflowEngine | undefined => {
210
- try {
211
- return resolveEngine();
212
- } catch (error: unknown) {
213
- if (!inactiveNotified) {
214
- inactiveNotified = true;
215
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
216
- }
217
- return undefined;
218
- }
219
- };
220
- while (true) {
221
- const engine = currentEngine();
222
- if (!engine) return;
223
- const states = engine
224
- .list()
225
- .map((summary) => engine.getRun(String(summary.runId)))
226
- .filter((run): run is ScriptRun => run !== undefined)
227
- .sort((a, b) => b.startedAt - a.startedAt);
228
- const selected = await selectList<string>(
229
- ctx,
230
- "Workflow runs",
231
- states.map((run) => ({
232
- value: run.runId,
233
- label: formatWorkflowRunLabel(run),
234
- description: run.error === undefined ? undefined : sanitizeDisplayText(run.error, MAX_DESCRIPTION_CHARS),
235
- })),
236
- (theme) =>
237
- states.length > 0
238
- ? theme.fg("dim", "Select a run to inspect.")
239
- : theme.fg("muted", "No workflow runs in this session."),
28
+ const source = typeof engineSource === "function" ? engineSource : () => engineSource;
29
+ let component: WorkflowCommandCenter | undefined;
30
+ try {
31
+ const sessionId = ctx.sessionManager.getSessionId();
32
+ await ctx.ui.custom<void>(
33
+ (tui, theme, keys, done) => {
34
+ component = new WorkflowCommandCenter(
35
+ tui,
36
+ theme,
37
+ keys,
38
+ source,
39
+ () => ctx.sessionManager.getSessionId() === sessionId,
40
+ done,
41
+ (message) => ctx.ui.notify(message, "error"),
42
+ options,
43
+ );
44
+ return component;
45
+ },
46
+ { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0 } },
240
47
  );
241
- if (!selected) return;
242
- while (true) {
243
- const latest = currentEngine();
244
- if (!latest) return;
245
- const run = latest.getRun(selected);
246
- if (!run) break;
247
- const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
248
- const action = await selectList<WorkflowAction>(
249
- ctx,
250
- `${name} · ${run.status}`,
251
- workflowActionsFor(run.status).map((item) => ({
252
- value: item.value,
253
- label: item.label,
254
- })),
255
- () => {
256
- const current = currentEngine();
257
- return current ? formatWorkflowDetail(current, selected) : "Workflow surface is no longer active.";
258
- },
259
- );
260
- if (action === undefined || action === "back") break;
261
- if (action === "refresh") continue;
262
- if (action === "stop") {
263
- const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${name}? The run will be non-resumable.`);
264
- if (!confirmed) continue;
265
- }
266
- try {
267
- const current = currentEngine();
268
- if (!current) return;
269
- await current.control(action, selected);
270
- ctx.ui.notify(`Workflow ${action} requested.`, "info");
271
- } catch (error: unknown) {
272
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
273
- }
274
- }
48
+ } catch (error: unknown) {
49
+ ctx.ui.notify(sanitizeDisplayText(error instanceof Error ? error.message : String(error), 500), "error");
50
+ } finally {
51
+ // Also covers external cancellation or custom() rejection before TUI disposal.
52
+ component?.dispose();
275
53
  }
276
54
  }