@quintinshaw/pi-dynamic-workflows 1.7.1 → 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/README.md +6 -8
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/task-panel.d.ts +24 -0
- package/dist/task-panel.js +82 -0
- package/dist/workflow-commands.js +12 -0
- package/dist/workflow-manager.d.ts +3 -0
- package/dist/workflow-manager.js +3 -0
- package/dist/workflow-tool.js +4 -1
- package/dist/workflow-ui.d.ts +110 -0
- package/dist/workflow-ui.js +426 -0
- package/dist/workflow.d.ts +21 -0
- package/dist/workflow.js +54 -19
- package/extensions/workflow.ts +9 -3
- package/package.json +1 -1
- package/src/index.ts +11 -0
- package/src/task-panel.ts +103 -0
- package/src/workflow-commands.ts +12 -0
- package/src/workflow-manager.ts +5 -0
- package/src/workflow-tool.ts +4 -1
- package/src/workflow-ui.ts +496 -0
- package/src/workflow.ts +71 -27
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-run UX, mirroring Claude Code:
|
|
3
|
+
* - A live task panel below the input lists in-progress runs while you keep working.
|
|
4
|
+
* Focus it (↓) and press enter to open the full navigator.
|
|
5
|
+
* - When a background run finishes, its result is delivered back into the
|
|
6
|
+
* conversation so the paused task continues with the outcome.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
11
|
+
import { parseKey } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { ManagedRun, WorkflowManager } from "./workflow-manager.js";
|
|
13
|
+
import type { WorkflowStorage } from "./workflow-saved.js";
|
|
14
|
+
import { openWorkflowNavigator } from "./workflow-ui.js";
|
|
15
|
+
|
|
16
|
+
const RUN_EVENTS = ["agentStart", "agentEnd", "phase", "log", "complete", "error", "stopped", "paused", "resumed"];
|
|
17
|
+
|
|
18
|
+
export interface TaskPanelOptions {
|
|
19
|
+
storage?: WorkflowStorage;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function deliverText(run: ManagedRun): string {
|
|
24
|
+
const r = run.result?.result as { report?: unknown } | undefined;
|
|
25
|
+
const body =
|
|
26
|
+
r && typeof r.report === "string" && r.report.trim() ? r.report : JSON.stringify(run.result?.result, null, 2);
|
|
27
|
+
const tokens = run.result?.tokenUsage ? ` · ${run.result.tokenUsage.total.toLocaleString()} tokens` : "";
|
|
28
|
+
const agents = run.result?.agentCount ?? run.snapshot.agentCount;
|
|
29
|
+
return `✓ Workflow "${run.snapshot.name}" finished (${agents} agents${tokens}).\n\n${body}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deliver a background run's result into the conversation when it completes or
|
|
34
|
+
* fails. Set up once per extension; idempotent via an internal guard.
|
|
35
|
+
*/
|
|
36
|
+
export function installResultDelivery(pi: ExtensionAPI, manager: WorkflowManager): void {
|
|
37
|
+
if ((manager as unknown as { __deliveryInstalled?: boolean }).__deliveryInstalled) return;
|
|
38
|
+
(manager as unknown as { __deliveryInstalled?: boolean }).__deliveryInstalled = true;
|
|
39
|
+
|
|
40
|
+
manager.on("complete", ({ runId }: { runId: string }) => {
|
|
41
|
+
const run = manager.getRun(runId);
|
|
42
|
+
if (run) void pi.sendMessage({ customType: "workflow-result", content: deliverText(run), display: true });
|
|
43
|
+
});
|
|
44
|
+
manager.on("error", ({ runId, error }: { runId: string; error?: { message?: string } }) => {
|
|
45
|
+
void pi.sendMessage({
|
|
46
|
+
customType: "workflow-result",
|
|
47
|
+
content: `✗ Workflow ${runId} failed: ${error?.message ?? "unknown error"}`,
|
|
48
|
+
display: true,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderPanel(manager: WorkflowManager, theme: Theme, focused: boolean): string[] {
|
|
54
|
+
const active = manager.listRuns().filter((r) => r.status === "running" || r.status === "paused");
|
|
55
|
+
if (!active.length) return [];
|
|
56
|
+
const rows = active.map((r) => {
|
|
57
|
+
const live = manager.getRun(r.runId);
|
|
58
|
+
const agents = live?.snapshot.agents ?? r.agents;
|
|
59
|
+
const done = agents.filter((a) => a.status === "done").length;
|
|
60
|
+
const icon = r.status === "paused" ? "⏸" : "◆";
|
|
61
|
+
const phase = live?.snapshot.currentPhase ? ` · ${live.snapshot.currentPhase}` : "";
|
|
62
|
+
return ` ${icon} ${r.workflowName} ${done}/${agents.length} agents${phase}`;
|
|
63
|
+
});
|
|
64
|
+
const hint = focused
|
|
65
|
+
? theme.fg("accent", " enter: open · esc: back")
|
|
66
|
+
: theme.fg("dim", " ↓ then enter, or /workflows, to open");
|
|
67
|
+
return [theme.bold(`Workflows running (${active.length}):`), ...rows, hint];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Install the live "workflows running" panel below the editor. Re-rendered on
|
|
72
|
+
* every manager event; focus + enter opens the navigator.
|
|
73
|
+
*/
|
|
74
|
+
export function installTaskPanel(
|
|
75
|
+
pi: ExtensionAPI,
|
|
76
|
+
manager: WorkflowManager,
|
|
77
|
+
ui: ExtensionUIContext,
|
|
78
|
+
opts: TaskPanelOptions = {},
|
|
79
|
+
): void {
|
|
80
|
+
ui.setWidget(
|
|
81
|
+
"workflow-tasks",
|
|
82
|
+
(tui: TUI, theme: Theme) => {
|
|
83
|
+
const onEvent = () => tui.requestRender();
|
|
84
|
+
for (const ev of RUN_EVENTS) manager.on(ev, onEvent);
|
|
85
|
+
const comp: Component & { focused?: boolean; dispose?(): void } = {
|
|
86
|
+
focused: false,
|
|
87
|
+
render: () => renderPanel(manager, theme, comp.focused ?? false),
|
|
88
|
+
handleInput: (data: string) => {
|
|
89
|
+
const key = parseKey(data);
|
|
90
|
+
if (key === "enter" || key === "return" || key === "right") {
|
|
91
|
+
void openWorkflowNavigator(pi, manager, ui, opts);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
invalidate: () => {},
|
|
95
|
+
dispose: () => {
|
|
96
|
+
for (const ev of RUN_EVENTS) manager.off(ev, onEvent);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return comp;
|
|
100
|
+
},
|
|
101
|
+
{ placement: "belowEditor" },
|
|
102
|
+
);
|
|
103
|
+
}
|
package/src/workflow-commands.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { PersistedRunState } from "./run-persistence.js";
|
|
|
9
9
|
import { registerSavedWorkflow } from "./saved-commands.js";
|
|
10
10
|
import type { WorkflowManager } from "./workflow-manager.js";
|
|
11
11
|
import type { WorkflowStorage } from "./workflow-saved.js";
|
|
12
|
+
import { openWorkflowNavigator } from "./workflow-ui.js";
|
|
12
13
|
|
|
13
14
|
const STATUS_ICON: Record<string, string> = {
|
|
14
15
|
pending: "·",
|
|
@@ -126,7 +127,18 @@ export function registerWorkflowCommands(
|
|
|
126
127
|
const print = (text: string) => pi.sendMessage({ customType: "workflows", content: text, display: true });
|
|
127
128
|
|
|
128
129
|
switch (sub) {
|
|
130
|
+
case "ui":
|
|
129
131
|
case "list": {
|
|
132
|
+
// Interactive navigator when a UI is available; plain text otherwise
|
|
133
|
+
// (print/RPC mode) or when the user explicitly asks for `list`.
|
|
134
|
+
if (sub !== "list" && ctx.hasUI) {
|
|
135
|
+
await openWorkflowNavigator(pi, manager, ctx.ui, { storage: opts.storage, cwd: opts.cwd });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (parts.length === 0 && ctx.hasUI) {
|
|
139
|
+
await openWorkflowNavigator(pi, manager, ctx.ui, { storage: opts.storage, cwd: opts.cwd });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
130
142
|
const runs = manager.listRuns();
|
|
131
143
|
if (!runs.length) {
|
|
132
144
|
await print("No workflow runs yet. Start one with a background workflow (background: true).");
|
package/src/workflow-manager.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface ManagedRun {
|
|
|
32
32
|
export interface WorkflowManagerOptions {
|
|
33
33
|
cwd?: string;
|
|
34
34
|
concurrency?: number;
|
|
35
|
+
/** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
|
|
36
|
+
loadSavedWorkflow?: (name: string) => string | undefined;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export class WorkflowManager extends EventEmitter {
|
|
@@ -39,11 +41,13 @@ export class WorkflowManager extends EventEmitter {
|
|
|
39
41
|
private persistence: RunPersistence;
|
|
40
42
|
private cwd: string;
|
|
41
43
|
private concurrency: number;
|
|
44
|
+
private loadSavedWorkflow?: (name: string) => string | undefined;
|
|
42
45
|
|
|
43
46
|
constructor(options: WorkflowManagerOptions = {}) {
|
|
44
47
|
super();
|
|
45
48
|
this.cwd = options.cwd ?? process.cwd();
|
|
46
49
|
this.concurrency = options.concurrency ?? 8;
|
|
50
|
+
this.loadSavedWorkflow = options.loadSavedWorkflow;
|
|
47
51
|
this.persistence = createRunPersistence(this.cwd);
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -144,6 +148,7 @@ export class WorkflowManager extends EventEmitter {
|
|
|
144
148
|
args,
|
|
145
149
|
signal: managed.controller.signal,
|
|
146
150
|
concurrency: this.concurrency,
|
|
151
|
+
loadSavedWorkflow: this.loadSavedWorkflow,
|
|
147
152
|
resumeJournal,
|
|
148
153
|
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
149
154
|
onAgentJournal: (entry) => {
|
package/src/workflow-tool.ts
CHANGED
|
@@ -61,8 +61,9 @@ export interface WorkflowToolOptions {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefinition<typeof workflowToolSchema, any> {
|
|
64
|
+
const storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
|
|
64
65
|
const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
|
|
65
|
-
const
|
|
66
|
+
const loadSavedWorkflow = (name: string) => storage.load(name)?.script;
|
|
66
67
|
|
|
67
68
|
return defineTool({
|
|
68
69
|
name: "workflow",
|
|
@@ -88,6 +89,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
|
|
|
88
89
|
"For workflow, if agent() needs machine-readable output, pass a plain JSON Schema via opts.schema; agent() will return the validated object. Use JSON Schema syntax, not TypeScript or TypeBox constructors.",
|
|
89
90
|
"For workflow, do not assume the parent assistant has repository code context inside subagents; include enough task context and relevant paths in each agent prompt.",
|
|
90
91
|
"For workflow, set background: true to run asynchronously. The workflow will return immediately with a run ID that can be used to check status later.",
|
|
92
|
+
"For workflow, you may call `await workflow('saved-name', argsObject)` to run a saved workflow inline and use its result; nesting is one level deep only, and the global 16-concurrent / 1000-total caps hold across the nesting.",
|
|
91
93
|
],
|
|
92
94
|
parameters: workflowToolSchema,
|
|
93
95
|
prepareArguments(args) {
|
|
@@ -140,6 +142,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
|
|
|
140
142
|
concurrency: options.concurrency,
|
|
141
143
|
maxAgents: params.maxAgents,
|
|
142
144
|
agentTimeoutMs: params.agentTimeoutMs,
|
|
145
|
+
loadSavedWorkflow,
|
|
143
146
|
onLog(message) {
|
|
144
147
|
snapshot.logs.push(message);
|
|
145
148
|
update();
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive `/workflows` navigator, modeled on Claude Code's view:
|
|
3
|
+
*
|
|
4
|
+
* runs ──enter──▶ phases ──enter──▶ agents ──enter──▶ agent detail
|
|
5
|
+
* ◀──esc─── ◀──esc──── ◀──esc────
|
|
6
|
+
*
|
|
7
|
+
* Keys: ↑/↓ (or j/k) select · enter/→ drill in · esc/← back (esc at top closes)
|
|
8
|
+
* p pause/resume · x stop · r restart · s save · q quit
|
|
9
|
+
*
|
|
10
|
+
* The state machine and line rendering are pure and unit-tested; the pi-tui
|
|
11
|
+
* Component shell (openWorkflowNavigator) wires them to live manager events.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
16
|
+
import { parseKey } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { WorkflowAgentSnapshot, WorkflowSnapshot } from "./display.js";
|
|
18
|
+
import type { PersistedRunState } from "./run-persistence.js";
|
|
19
|
+
import { registerSavedWorkflow } from "./saved-commands.js";
|
|
20
|
+
import type { WorkflowManager } from "./workflow-manager.js";
|
|
21
|
+
import type { WorkflowStorage } from "./workflow-saved.js";
|
|
22
|
+
|
|
23
|
+
const STATUS_ICON: Record<string, string> = {
|
|
24
|
+
pending: "·",
|
|
25
|
+
queued: "·",
|
|
26
|
+
running: "◆",
|
|
27
|
+
paused: "⏸",
|
|
28
|
+
completed: "✓",
|
|
29
|
+
done: "✓",
|
|
30
|
+
failed: "✗",
|
|
31
|
+
error: "✗",
|
|
32
|
+
aborted: "⊘",
|
|
33
|
+
skipped: "⊘",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Minimal theme surface so rendering is testable without the real Theme class. */
|
|
37
|
+
export interface ThemeLike {
|
|
38
|
+
fg(color: string, text: string): string;
|
|
39
|
+
bold(text: string): string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PLAIN: ThemeLike = { fg: (_c, t) => t, bold: (t) => t };
|
|
43
|
+
|
|
44
|
+
export type ViewKind = "runs" | "phases" | "agents" | "detail";
|
|
45
|
+
|
|
46
|
+
interface RunRow {
|
|
47
|
+
runId: string;
|
|
48
|
+
name: string;
|
|
49
|
+
status: string;
|
|
50
|
+
done: number;
|
|
51
|
+
total: number;
|
|
52
|
+
tokens: number;
|
|
53
|
+
}
|
|
54
|
+
interface PhaseRow {
|
|
55
|
+
title: string;
|
|
56
|
+
done: number;
|
|
57
|
+
total: number;
|
|
58
|
+
tokens: number;
|
|
59
|
+
}
|
|
60
|
+
interface AgentRow {
|
|
61
|
+
id: number;
|
|
62
|
+
label: string;
|
|
63
|
+
status: string;
|
|
64
|
+
phase?: string;
|
|
65
|
+
tokens?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reads run/phase/agent data from the manager, preferring live snapshots. */
|
|
69
|
+
export class NavigatorModel {
|
|
70
|
+
constructor(private readonly manager: Pick<WorkflowManager, "listRuns" | "getRun">) {}
|
|
71
|
+
|
|
72
|
+
private snapshot(runId: string): { snapshot: WorkflowSnapshot; status: string } | undefined {
|
|
73
|
+
const live = this.manager.getRun(runId);
|
|
74
|
+
if (live) return { snapshot: live.snapshot, status: live.status };
|
|
75
|
+
const p = this.manager.listRuns().find((r) => r.runId === runId);
|
|
76
|
+
if (!p) return undefined;
|
|
77
|
+
return { snapshot: persistedToSnapshot(p), status: p.status };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
runs(): RunRow[] {
|
|
81
|
+
return this.manager.listRuns().map((p) => {
|
|
82
|
+
const live = this.manager.getRun(p.runId);
|
|
83
|
+
const agents = (live?.snapshot.agents ?? p.agents) as WorkflowAgentSnapshot[];
|
|
84
|
+
return {
|
|
85
|
+
runId: p.runId,
|
|
86
|
+
name: live?.snapshot.name ?? p.workflowName,
|
|
87
|
+
status: live?.status ?? p.status,
|
|
88
|
+
done: agents.filter((a) => a.status === "done").length,
|
|
89
|
+
total: agents.length,
|
|
90
|
+
tokens: (live?.snapshot.tokenUsage ?? p.tokenUsage)?.total ?? 0,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
runName(runId: string): string {
|
|
96
|
+
return this.snapshot(runId)?.snapshot.name ?? runId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
runStatus(runId: string): string {
|
|
100
|
+
return this.snapshot(runId)?.status ?? "unknown";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
phases(runId: string): PhaseRow[] {
|
|
104
|
+
const snap = this.snapshot(runId)?.snapshot;
|
|
105
|
+
if (!snap) return [];
|
|
106
|
+
const order = snap.phases.length ? [...snap.phases] : [];
|
|
107
|
+
const byPhase = new Map<string, AgentRow[]>();
|
|
108
|
+
for (const a of snap.agents) {
|
|
109
|
+
const key = a.phase ?? "(no phase)";
|
|
110
|
+
if (!byPhase.has(key)) byPhase.set(key, []);
|
|
111
|
+
byPhase.get(key)?.push(a);
|
|
112
|
+
if (!order.includes(key)) order.push(key);
|
|
113
|
+
}
|
|
114
|
+
return order.map((title) => {
|
|
115
|
+
const agents = byPhase.get(title) ?? [];
|
|
116
|
+
return {
|
|
117
|
+
title,
|
|
118
|
+
done: agents.filter((a) => a.status === "done").length,
|
|
119
|
+
total: agents.length,
|
|
120
|
+
tokens: agents.reduce((n, a) => n + (a.tokens ?? 0), 0),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
agents(runId: string, phase: string): AgentRow[] {
|
|
126
|
+
const snap = this.snapshot(runId)?.snapshot;
|
|
127
|
+
if (!snap) return [];
|
|
128
|
+
return snap.agents
|
|
129
|
+
.filter((a) => (a.phase ?? "(no phase)") === phase)
|
|
130
|
+
.map((a) => ({ id: a.id, label: a.label, status: a.status, phase: a.phase, tokens: a.tokens }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
agentDetail(runId: string, agentId: number): WorkflowAgentSnapshot | undefined {
|
|
134
|
+
return this.snapshot(runId)?.snapshot.agents.find((a) => a.id === agentId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function persistedToSnapshot(p: PersistedRunState): WorkflowSnapshot {
|
|
139
|
+
return {
|
|
140
|
+
name: p.workflowName,
|
|
141
|
+
phases: p.phases,
|
|
142
|
+
currentPhase: p.currentPhase,
|
|
143
|
+
logs: p.logs,
|
|
144
|
+
agents: p.agents.map((a) => ({
|
|
145
|
+
id: a.id,
|
|
146
|
+
label: a.label,
|
|
147
|
+
phase: a.phase,
|
|
148
|
+
prompt: a.prompt,
|
|
149
|
+
status: a.status,
|
|
150
|
+
resultPreview:
|
|
151
|
+
a.result == null ? undefined : String(typeof a.result === "string" ? a.result : JSON.stringify(a.result)),
|
|
152
|
+
error: a.error,
|
|
153
|
+
})),
|
|
154
|
+
agentCount: p.agents.length,
|
|
155
|
+
runningCount: p.agents.filter((a) => a.status === "running").length,
|
|
156
|
+
doneCount: p.agents.filter((a) => a.status === "done").length,
|
|
157
|
+
errorCount: p.agents.filter((a) => a.status === "error").length,
|
|
158
|
+
tokenUsage: p.tokenUsage ? { ...p.tokenUsage } : undefined,
|
|
159
|
+
runId: p.runId,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Navigation state machine: a stack of (view, cursor) frames plus detail scroll. */
|
|
164
|
+
export class NavigatorState {
|
|
165
|
+
private stack: Array<{ kind: ViewKind; cursor: number; runId?: string; phase?: string; agentId?: number }> = [
|
|
166
|
+
{ kind: "runs", cursor: 0 },
|
|
167
|
+
];
|
|
168
|
+
scroll = 0;
|
|
169
|
+
|
|
170
|
+
private top() {
|
|
171
|
+
return this.stack[this.stack.length - 1];
|
|
172
|
+
}
|
|
173
|
+
get kind(): ViewKind {
|
|
174
|
+
return this.top().kind;
|
|
175
|
+
}
|
|
176
|
+
get cursor(): number {
|
|
177
|
+
return this.top().cursor;
|
|
178
|
+
}
|
|
179
|
+
get runId(): string | undefined {
|
|
180
|
+
return this.top().runId;
|
|
181
|
+
}
|
|
182
|
+
get phase(): string | undefined {
|
|
183
|
+
return this.top().phase;
|
|
184
|
+
}
|
|
185
|
+
get agentId(): number | undefined {
|
|
186
|
+
return this.top().agentId;
|
|
187
|
+
}
|
|
188
|
+
get depth(): number {
|
|
189
|
+
return this.stack.length;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Clamp the cursor to [0, count). */
|
|
193
|
+
clamp(count: number) {
|
|
194
|
+
const t = this.top();
|
|
195
|
+
t.cursor = count <= 0 ? 0 : Math.max(0, Math.min(t.cursor, count - 1));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
move(delta: number, count: number) {
|
|
199
|
+
if (this.kind === "detail") {
|
|
200
|
+
this.scroll = Math.max(0, this.scroll + delta);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (count <= 0) return;
|
|
204
|
+
const t = this.top();
|
|
205
|
+
t.cursor = (t.cursor + delta + count) % count;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Drill into the selected item. Returns true if the view changed. */
|
|
209
|
+
drill(model: NavigatorModel): boolean {
|
|
210
|
+
const t = this.top();
|
|
211
|
+
if (t.kind === "runs") {
|
|
212
|
+
const runs = model.runs();
|
|
213
|
+
const run = runs[t.cursor];
|
|
214
|
+
if (!run) return false;
|
|
215
|
+
this.stack.push({ kind: "phases", cursor: 0, runId: run.runId });
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
if (t.kind === "phases" && t.runId) {
|
|
219
|
+
const phases = model.phases(t.runId);
|
|
220
|
+
const ph = phases[t.cursor];
|
|
221
|
+
if (!ph) return false;
|
|
222
|
+
this.stack.push({ kind: "agents", cursor: 0, runId: t.runId, phase: ph.title });
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (t.kind === "agents" && t.runId && t.phase) {
|
|
226
|
+
const agents = model.agents(t.runId, t.phase);
|
|
227
|
+
const ag = agents[t.cursor];
|
|
228
|
+
if (!ag) return false;
|
|
229
|
+
this.scroll = 0;
|
|
230
|
+
this.stack.push({ kind: "detail", cursor: 0, runId: t.runId, phase: t.phase, agentId: ag.id });
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Pop one level. Returns false when already at the top (caller should close). */
|
|
237
|
+
back(): boolean {
|
|
238
|
+
if (this.stack.length <= 1) return false;
|
|
239
|
+
this.stack.pop();
|
|
240
|
+
this.scroll = 0;
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The runId the current view acts on (for pause/stop/save). */
|
|
245
|
+
activeRunId(model: NavigatorModel): string | undefined {
|
|
246
|
+
if (this.runId) return this.runId;
|
|
247
|
+
if (this.kind === "runs") return model.runs()[this.cursor]?.runId;
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pad(n: number): string {
|
|
253
|
+
return n.toLocaleString();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function fmtTokens(t: number): string {
|
|
257
|
+
return t > 0 ? `${pad(t)} tok` : "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Build the lines for the current view. Pure: depends only on state + model + theme. */
|
|
261
|
+
export function renderNavigator(
|
|
262
|
+
state: NavigatorState,
|
|
263
|
+
model: NavigatorModel,
|
|
264
|
+
width: number,
|
|
265
|
+
theme: ThemeLike = PLAIN,
|
|
266
|
+
): string[] {
|
|
267
|
+
const lines: string[] = [];
|
|
268
|
+
const sel = (i: number, text: string) =>
|
|
269
|
+
i === state.cursor ? theme.fg("accent", theme.bold(`❯ ${text}`)) : ` ${text}`;
|
|
270
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
271
|
+
|
|
272
|
+
if (state.kind === "runs") {
|
|
273
|
+
const runs = model.runs();
|
|
274
|
+
state.clamp(runs.length);
|
|
275
|
+
lines.push(theme.bold("Workflows"));
|
|
276
|
+
if (!runs.length) lines.push(dim(" No runs yet. Start one with a background workflow."));
|
|
277
|
+
runs.forEach((r, i) => {
|
|
278
|
+
const icon = STATUS_ICON[r.status] ?? "?";
|
|
279
|
+
const meta = [`${r.done}/${r.total}`, fmtTokens(r.tokens)].filter(Boolean).join(" · ");
|
|
280
|
+
lines.push(sel(i, `${icon} ${r.name} ${dim(`${r.runId} · ${r.status} · ${meta}`)}`));
|
|
281
|
+
});
|
|
282
|
+
} else if (state.kind === "phases" && state.runId) {
|
|
283
|
+
const phases = model.phases(state.runId);
|
|
284
|
+
state.clamp(phases.length);
|
|
285
|
+
lines.push(theme.bold(model.runName(state.runId)) + dim(` (${model.runStatus(state.runId)})`));
|
|
286
|
+
phases.forEach((p, i) => {
|
|
287
|
+
const meta = [`${p.done}/${p.total} agents`, fmtTokens(p.tokens)].filter(Boolean).join(" · ");
|
|
288
|
+
lines.push(sel(i, `${p.title} ${dim(meta)}`));
|
|
289
|
+
});
|
|
290
|
+
} else if (state.kind === "agents" && state.runId && state.phase) {
|
|
291
|
+
const agents = model.agents(state.runId, state.phase);
|
|
292
|
+
state.clamp(agents.length);
|
|
293
|
+
lines.push(theme.bold(`${model.runName(state.runId)} › ${state.phase}`));
|
|
294
|
+
agents.forEach((a, i) => {
|
|
295
|
+
const icon = STATUS_ICON[a.status] ?? "?";
|
|
296
|
+
const tok = a.tokens ? dim(` ${fmtTokens(a.tokens)}`) : "";
|
|
297
|
+
lines.push(sel(i, `${icon} ${a.label}${tok}`));
|
|
298
|
+
});
|
|
299
|
+
} else if (state.kind === "detail" && state.runId && state.agentId != null) {
|
|
300
|
+
const a = model.agentDetail(state.runId, state.agentId);
|
|
301
|
+
lines.push(theme.bold(a ? a.label : "agent"));
|
|
302
|
+
if (a) {
|
|
303
|
+
const body: string[] = [];
|
|
304
|
+
body.push(dim("Status: ") + (a.status ?? ""));
|
|
305
|
+
if (a.error) body.push(dim("Error: ") + a.error);
|
|
306
|
+
body.push("", dim("Prompt:"));
|
|
307
|
+
body.push(...wrap(a.prompt ?? "", width));
|
|
308
|
+
body.push("", dim("Result:"));
|
|
309
|
+
body.push(...wrap(a.resultPreview ?? "(none)", width));
|
|
310
|
+
// Scrollable region.
|
|
311
|
+
const maxScroll = Math.max(0, body.length - 1);
|
|
312
|
+
state.scroll = Math.min(state.scroll, maxScroll);
|
|
313
|
+
lines.push(...body.slice(state.scroll));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push(footerHint(state, theme));
|
|
319
|
+
return lines;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function footerHint(state: NavigatorState, theme: ThemeLike): string {
|
|
323
|
+
const parts =
|
|
324
|
+
state.kind === "detail"
|
|
325
|
+
? ["j/k scroll", "esc back"]
|
|
326
|
+
: ["↑/↓ select", "enter open", "esc back", "p pause", "x stop", "r restart", "s save", "q quit"];
|
|
327
|
+
return theme.fg("dim", parts.join(" · "));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function wrap(text: string, width: number): string[] {
|
|
331
|
+
const w = Math.max(20, width - 2);
|
|
332
|
+
const out: string[] = [];
|
|
333
|
+
for (const para of String(text).split("\n")) {
|
|
334
|
+
if (para.length <= w) {
|
|
335
|
+
out.push(para);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
let rest = para;
|
|
339
|
+
while (rest.length > w) {
|
|
340
|
+
out.push(rest.slice(0, w));
|
|
341
|
+
rest = rest.slice(w);
|
|
342
|
+
}
|
|
343
|
+
if (rest) out.push(rest);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** What a key press should do. Pure mapping from a parsed key id to an action. */
|
|
349
|
+
export type NavAction =
|
|
350
|
+
| { type: "move"; delta: number }
|
|
351
|
+
| { type: "drill" }
|
|
352
|
+
| { type: "back" }
|
|
353
|
+
| { type: "close" }
|
|
354
|
+
| { type: "pause" }
|
|
355
|
+
| { type: "stop" }
|
|
356
|
+
| { type: "restart" }
|
|
357
|
+
| { type: "save" }
|
|
358
|
+
| { type: "none" };
|
|
359
|
+
|
|
360
|
+
export function keyToAction(keyId: string | undefined, kind: ViewKind): NavAction {
|
|
361
|
+
switch (keyId) {
|
|
362
|
+
case "up":
|
|
363
|
+
return { type: "move", delta: -1 };
|
|
364
|
+
case "down":
|
|
365
|
+
return { type: "move", delta: 1 };
|
|
366
|
+
case "k":
|
|
367
|
+
return { type: "move", delta: -1 };
|
|
368
|
+
case "j":
|
|
369
|
+
return { type: "move", delta: 1 };
|
|
370
|
+
case "enter":
|
|
371
|
+
case "return":
|
|
372
|
+
case "right":
|
|
373
|
+
return kind === "detail" ? { type: "none" } : { type: "drill" };
|
|
374
|
+
case "escape":
|
|
375
|
+
case "esc":
|
|
376
|
+
case "left":
|
|
377
|
+
return { type: "back" };
|
|
378
|
+
case "q":
|
|
379
|
+
return { type: "close" };
|
|
380
|
+
case "p":
|
|
381
|
+
return { type: "pause" };
|
|
382
|
+
case "x":
|
|
383
|
+
return { type: "stop" };
|
|
384
|
+
case "r":
|
|
385
|
+
return { type: "restart" };
|
|
386
|
+
case "s":
|
|
387
|
+
return { type: "save" };
|
|
388
|
+
default:
|
|
389
|
+
return { type: "none" };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function currentCount(state: NavigatorState, model: NavigatorModel): number {
|
|
394
|
+
if (state.kind === "runs") return model.runs().length;
|
|
395
|
+
if (state.kind === "phases" && state.runId) return model.phases(state.runId).length;
|
|
396
|
+
if (state.kind === "agents" && state.runId && state.phase) return model.agents(state.runId, state.phase).length;
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface NavigatorOptions {
|
|
401
|
+
storage?: WorkflowStorage;
|
|
402
|
+
cwd?: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Open the interactive `/workflows` navigator as a focused overlay. Resolves when
|
|
407
|
+
* the user closes it (esc at the top level, or `q`).
|
|
408
|
+
*/
|
|
409
|
+
export function openWorkflowNavigator(
|
|
410
|
+
pi: ExtensionAPI,
|
|
411
|
+
manager: WorkflowManager,
|
|
412
|
+
ui: ExtensionUIContext,
|
|
413
|
+
opts: NavigatorOptions = {},
|
|
414
|
+
): Promise<void> {
|
|
415
|
+
const model = new NavigatorModel(manager);
|
|
416
|
+
const state = new NavigatorState();
|
|
417
|
+
|
|
418
|
+
return ui.custom<void>(
|
|
419
|
+
(tui: TUI, theme: Theme, _keybindings, done: (r: void) => void) => {
|
|
420
|
+
const rerender = () => tui.requestRender();
|
|
421
|
+
const events = ["agentStart", "agentEnd", "phase", "log", "complete", "error", "stopped", "paused", "resumed"];
|
|
422
|
+
const onEvent = () => rerender();
|
|
423
|
+
for (const ev of events) manager.on(ev, onEvent);
|
|
424
|
+
const cleanup = () => {
|
|
425
|
+
for (const ev of events) manager.off(ev, onEvent);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const act = (data: string) => {
|
|
429
|
+
const action = keyToAction(parseKey(data), state.kind);
|
|
430
|
+
switch (action.type) {
|
|
431
|
+
case "move":
|
|
432
|
+
state.move(action.delta, currentCount(state, model));
|
|
433
|
+
break;
|
|
434
|
+
case "drill":
|
|
435
|
+
state.drill(model);
|
|
436
|
+
break;
|
|
437
|
+
case "back":
|
|
438
|
+
if (!state.back()) {
|
|
439
|
+
cleanup();
|
|
440
|
+
done();
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
case "close":
|
|
444
|
+
cleanup();
|
|
445
|
+
done();
|
|
446
|
+
return;
|
|
447
|
+
case "pause": {
|
|
448
|
+
const id = state.activeRunId(model);
|
|
449
|
+
if (id) ui.notify(manager.pause(id) ? `Paused ${id}` : `Cannot pause ${id}`, "info");
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
case "stop": {
|
|
453
|
+
const id = state.activeRunId(model);
|
|
454
|
+
if (id) ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id}`, "info");
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
case "restart":
|
|
458
|
+
ui.notify("Restarting a single agent isn't supported yet", "warning");
|
|
459
|
+
break;
|
|
460
|
+
case "save": {
|
|
461
|
+
const id = state.activeRunId(model);
|
|
462
|
+
const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
|
|
463
|
+
if (!run?.script) {
|
|
464
|
+
ui.notify("No saved run script to save", "warning");
|
|
465
|
+
} else if (!opts.storage) {
|
|
466
|
+
ui.notify("Saving is not available (no storage)", "error");
|
|
467
|
+
} else {
|
|
468
|
+
const name = run.workflowName || "workflow";
|
|
469
|
+
const saved = opts.storage.save({
|
|
470
|
+
name,
|
|
471
|
+
description: run.workflowName,
|
|
472
|
+
script: run.script,
|
|
473
|
+
location: "project",
|
|
474
|
+
});
|
|
475
|
+
registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved);
|
|
476
|
+
ui.notify(`Saved /${name}`, "info");
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
default:
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
rerender();
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const component: Component & { dispose?(): void } = {
|
|
487
|
+
render: (width: number) => renderNavigator(state, model, width, theme),
|
|
488
|
+
handleInput: (data: string) => act(data),
|
|
489
|
+
invalidate: () => {},
|
|
490
|
+
dispose: () => cleanup(),
|
|
491
|
+
};
|
|
492
|
+
return component;
|
|
493
|
+
},
|
|
494
|
+
{ overlay: true },
|
|
495
|
+
);
|
|
496
|
+
}
|