@davidorex/pi-workflows 0.1.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +169 -0
  3. package/agents/audit-fixer.agent.yaml +18 -0
  4. package/agents/code-explorer.agent.yaml +11 -0
  5. package/agents/decomposer.agent.yaml +51 -0
  6. package/agents/investigator.agent.yaml +49 -0
  7. package/agents/pattern-analyzer.agent.yaml +21 -0
  8. package/agents/phase-author.agent.yaml +16 -0
  9. package/agents/plan-decomposer.agent.yaml +12 -0
  10. package/agents/quality-analyzer.agent.yaml +21 -0
  11. package/agents/researcher.agent.yaml +38 -0
  12. package/agents/spec-implementer.agent.yaml +12 -0
  13. package/agents/structure-analyzer.agent.yaml +21 -0
  14. package/agents/synthesizer.agent.yaml +23 -0
  15. package/agents/verifier.agent.yaml +12 -0
  16. package/package.json +40 -0
  17. package/schemas/decomposition-specs.schema.json +50 -0
  18. package/schemas/execution-results.schema.json +50 -0
  19. package/schemas/investigation-findings.schema.json +45 -0
  20. package/schemas/pattern-analysis.schema.json +43 -0
  21. package/schemas/plan-breakdown.schema.json +22 -0
  22. package/schemas/quality-analysis.schema.json +35 -0
  23. package/schemas/research-findings.schema.json +44 -0
  24. package/schemas/structure-analysis.schema.json +42 -0
  25. package/schemas/synthesis.schema.json +33 -0
  26. package/schemas/verifier-output.schema.json +96 -0
  27. package/src/agent-spec.test.ts +289 -0
  28. package/src/agent-spec.ts +122 -0
  29. package/src/block-validation.test.ts +350 -0
  30. package/src/checkpoint.test.ts +237 -0
  31. package/src/checkpoint.ts +139 -0
  32. package/src/completion.test.ts +143 -0
  33. package/src/completion.ts +68 -0
  34. package/src/dag.test.ts +350 -0
  35. package/src/dag.ts +219 -0
  36. package/src/dispatch.test.ts +473 -0
  37. package/src/dispatch.ts +353 -0
  38. package/src/expression.test.ts +353 -0
  39. package/src/expression.ts +332 -0
  40. package/src/format.test.ts +80 -0
  41. package/src/format.ts +41 -0
  42. package/src/graduated-failure.test.ts +556 -0
  43. package/src/index.test.ts +114 -0
  44. package/src/index.ts +488 -0
  45. package/src/loop.test.ts +549 -0
  46. package/src/output.test.ts +213 -0
  47. package/src/output.ts +70 -0
  48. package/src/parallel-integration.test.ts +175 -0
  49. package/src/resume.test.ts +192 -0
  50. package/src/state.test.ts +192 -0
  51. package/src/state.ts +253 -0
  52. package/src/step-agent.test.ts +327 -0
  53. package/src/step-agent.ts +178 -0
  54. package/src/step-command.test.ts +330 -0
  55. package/src/step-command.ts +132 -0
  56. package/src/step-foreach.test.ts +647 -0
  57. package/src/step-foreach.ts +148 -0
  58. package/src/step-gate.test.ts +185 -0
  59. package/src/step-gate.ts +116 -0
  60. package/src/step-loop.test.ts +626 -0
  61. package/src/step-loop.ts +323 -0
  62. package/src/step-parallel.test.ts +475 -0
  63. package/src/step-parallel.ts +168 -0
  64. package/src/step-pause.test.ts +30 -0
  65. package/src/step-pause.ts +27 -0
  66. package/src/step-shared.test.ts +355 -0
  67. package/src/step-shared.ts +157 -0
  68. package/src/step-transform.test.ts +155 -0
  69. package/src/step-transform.ts +47 -0
  70. package/src/template-integration.test.ts +72 -0
  71. package/src/template.test.ts +162 -0
  72. package/src/template.ts +92 -0
  73. package/src/test-helpers.ts +51 -0
  74. package/src/tui.test.ts +355 -0
  75. package/src/tui.ts +182 -0
  76. package/src/types.ts +195 -0
  77. package/src/verifier-schema.test.ts +226 -0
  78. package/src/workflow-discovery.test.ts +105 -0
  79. package/src/workflow-discovery.ts +113 -0
  80. package/src/workflow-executor.test.ts +1530 -0
  81. package/src/workflow-executor.ts +810 -0
  82. package/src/workflow-sdk.test.ts +238 -0
  83. package/src/workflow-sdk.ts +262 -0
  84. package/src/workflow-spec.test.ts +576 -0
  85. package/src/workflow-spec.ts +471 -0
  86. package/templates/analyzers/base-analyzer.md +9 -0
  87. package/templates/analyzers/macros.md +1 -0
  88. package/templates/analyzers/patterns-task.md +11 -0
  89. package/templates/analyzers/patterns.md +15 -0
  90. package/templates/analyzers/quality-task.md +11 -0
  91. package/templates/analyzers/quality.md +15 -0
  92. package/templates/analyzers/structure-task.md +17 -0
  93. package/templates/analyzers/structure.md +15 -0
  94. package/templates/audit-fixer/task.md +67 -0
  95. package/templates/decomposer/task.md +56 -0
  96. package/templates/explorer/system.md +3 -0
  97. package/templates/explorer/task.md +9 -0
  98. package/templates/investigator/task.md +30 -0
  99. package/templates/phase-author/task.md +74 -0
  100. package/templates/plan-decomposer/task.md +46 -0
  101. package/templates/researcher/task.md +26 -0
  102. package/templates/spec-implementer/task.md +53 -0
  103. package/templates/synthesizer/system.md +3 -0
  104. package/templates/synthesizer/task.md +38 -0
  105. package/templates/verifier/task.md +57 -0
  106. package/workflows/create-phase.workflow.yaml +67 -0
  107. package/workflows/do-gap.workflow.yaml +153 -0
  108. package/workflows/fix-audit.workflow.yaml +217 -0
  109. package/workflows/gap-to-phase.workflow.yaml +98 -0
  110. package/workflows/parallel-analysis.workflow.yaml +62 -0
  111. package/workflows/parallel-explicit.workflow.yaml +42 -0
  112. package/workflows/pausable-analysis.workflow.yaml +44 -0
  113. package/workflows/resumable-analysis.workflow.yaml +42 -0
  114. package/workflows/self-implement.workflow.yaml +63 -0
  115. package/workflows/typed-analysis.workflow.yaml +63 -0
@@ -0,0 +1,355 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import type { Theme } from "@mariozechner/pi-coding-agent";
4
+ import type { TUI } from "@mariozechner/pi-tui";
5
+ import { createProgressWidget, type ProgressWidgetState } from "./tui.ts";
6
+ import type { WorkflowSpec, StepUsage } from "./types.ts";
7
+
8
+ /**
9
+ * Minimal Theme mock — passes through text unchanged so we can assert on content
10
+ * without dealing with ANSI codes. Mocks exactly the methods tui.ts calls.
11
+ */
12
+ function mockTheme(): Theme {
13
+ return {
14
+ bold: (s: string) => s,
15
+ fg: (_color: string, s: string) => s,
16
+ dim: (s: string) => s,
17
+ } as unknown as Theme;
18
+ }
19
+
20
+ /**
21
+ * Minimal TUI mock — just needs requestRender().
22
+ */
23
+ function mockTUI(): TUI {
24
+ return {
25
+ requestRender: () => {},
26
+ } as unknown as TUI;
27
+ }
28
+
29
+ function zeroUsage(): StepUsage {
30
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
31
+ }
32
+
33
+ function makeWidgetState(overrides: Partial<ProgressWidgetState> = {}): ProgressWidgetState {
34
+ const spec: WorkflowSpec = {
35
+ name: "test-workflow",
36
+ description: "test",
37
+ steps: {
38
+ explore: { agent: "explorer" },
39
+ analyze: { agent: "analyzer" },
40
+ report: { agent: "reporter" },
41
+ },
42
+ source: "project",
43
+ filePath: "/test.yaml",
44
+ };
45
+
46
+ return {
47
+ spec,
48
+ state: { input: {}, steps: {}, status: "running" },
49
+ startTime: Date.now(),
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ describe("createProgressWidget", () => {
55
+ it("returns a factory function", () => {
56
+ const factory = createProgressWidget(makeWidgetState());
57
+ assert.strictEqual(typeof factory, "function");
58
+ });
59
+
60
+ it("factory creates a component with render and dispose", () => {
61
+ const factory = createProgressWidget(makeWidgetState());
62
+ const component = factory(mockTUI(), mockTheme());
63
+ assert.strictEqual(typeof component.render, "function");
64
+ assert.strictEqual(typeof component.dispose, "function");
65
+ component.dispose!();
66
+ });
67
+
68
+ it("renders header with workflow name and step count", () => {
69
+ const widgetState = makeWidgetState({ currentStep: "explore" });
70
+ const factory = createProgressWidget(widgetState);
71
+ const component = factory(mockTUI(), mockTheme());
72
+ const lines = component.render(120);
73
+ component.dispose!();
74
+
75
+ assert.ok(lines[0].includes("test-workflow"), "header should include workflow name");
76
+ assert.ok(lines[0].includes("1/3"), "header should show step 1/3");
77
+ });
78
+
79
+ it("renders pending steps with dot indicator", () => {
80
+ const widgetState = makeWidgetState();
81
+ const factory = createProgressWidget(widgetState);
82
+ const component = factory(mockTUI(), mockTheme());
83
+ const lines = component.render(120);
84
+ component.dispose!();
85
+
86
+ assert.ok(lines.some(l => l.includes("·") && l.includes("explore")));
87
+ assert.ok(lines.some(l => l.includes("·") && l.includes("analyze")));
88
+ assert.ok(lines.some(l => l.includes("·") && l.includes("report")));
89
+ });
90
+
91
+ it("renders running step with triangle indicator", () => {
92
+ const widgetState = makeWidgetState({ currentStep: "analyze" });
93
+ const factory = createProgressWidget(widgetState);
94
+ const component = factory(mockTUI(), mockTheme());
95
+ const lines = component.render(120);
96
+ component.dispose!();
97
+
98
+ assert.ok(lines.some(l => l.includes("▸") && l.includes("analyze")));
99
+ });
100
+
101
+ it("renders completed step with checkmark, duration, cost, tokens", () => {
102
+ const widgetState = makeWidgetState({ currentStep: "analyze" });
103
+ widgetState.state.steps.explore = {
104
+ step: "explore",
105
+ agent: "explorer",
106
+ status: "completed",
107
+ usage: { input: 5000, output: 2000, cacheRead: 0, cacheWrite: 0, cost: 0.03, turns: 1 },
108
+ durationMs: 42000,
109
+ };
110
+ const factory = createProgressWidget(widgetState);
111
+ const component = factory(mockTUI(), mockTheme());
112
+ const lines = component.render(120);
113
+ component.dispose!();
114
+
115
+ const exploreLine = lines.find(l => l.includes("explore"));
116
+ assert.ok(exploreLine, "should have explore line");
117
+ assert.ok(exploreLine.includes("✓"), "completed should have checkmark");
118
+ assert.ok(exploreLine.includes("42s"), "should show duration");
119
+ assert.ok(exploreLine.includes("$0.03"), "should show cost");
120
+ assert.ok(exploreLine.includes("7.0k tok"), "should show token count");
121
+ });
122
+
123
+ it("renders failed step with X indicator and error", () => {
124
+ const widgetState = makeWidgetState();
125
+ widgetState.state.steps.explore = {
126
+ step: "explore",
127
+ agent: "explorer",
128
+ status: "failed",
129
+ usage: zeroUsage(),
130
+ durationMs: 5000,
131
+ error: "Agent crashed",
132
+ };
133
+ const factory = createProgressWidget(widgetState);
134
+ const component = factory(mockTUI(), mockTheme());
135
+ const lines = component.render(120);
136
+ component.dispose!();
137
+
138
+ const exploreLine = lines.find(l => l.includes("explore"));
139
+ assert.ok(exploreLine, "should have explore line");
140
+ assert.ok(exploreLine.includes("✗"), "failed should have X");
141
+ assert.ok(exploreLine.includes("Agent crashed"), "should show error");
142
+ });
143
+
144
+ it("renders skipped step with circle indicator", () => {
145
+ const widgetState = makeWidgetState();
146
+ widgetState.state.steps.explore = {
147
+ step: "explore",
148
+ agent: "skipped",
149
+ status: "skipped",
150
+ usage: zeroUsage(),
151
+ durationMs: 0,
152
+ };
153
+ const factory = createProgressWidget(widgetState);
154
+ const component = factory(mockTUI(), mockTheme());
155
+ const lines = component.render(120);
156
+ component.dispose!();
157
+
158
+ const exploreLine = lines.find(l => l.includes("explore"));
159
+ assert.ok(exploreLine, "should have explore line");
160
+ assert.ok(exploreLine.includes("⊘"), "skipped should have ⊘");
161
+ assert.ok(exploreLine.includes("[skipped]"), "should show [skipped] tag");
162
+ });
163
+
164
+ it("renders gate step with [gate] tag", () => {
165
+ const widgetState = makeWidgetState();
166
+ widgetState.state.steps.explore = {
167
+ step: "explore",
168
+ agent: "gate",
169
+ status: "completed",
170
+ output: { passed: true, exitCode: 0, output: "ok" },
171
+ usage: zeroUsage(),
172
+ durationMs: 100,
173
+ };
174
+ const factory = createProgressWidget(widgetState);
175
+ const component = factory(mockTUI(), mockTheme());
176
+ const lines = component.render(120);
177
+ component.dispose!();
178
+
179
+ const exploreLine = lines.find(l => l.includes("explore"));
180
+ assert.ok(exploreLine?.includes("[gate]"), "gate step should show [gate] tag");
181
+ });
182
+
183
+ it("renders transform step with [transform] tag", () => {
184
+ const widgetState = makeWidgetState();
185
+ widgetState.state.steps.explore = {
186
+ step: "explore",
187
+ agent: "transform",
188
+ status: "completed",
189
+ output: { merged: true },
190
+ usage: zeroUsage(),
191
+ durationMs: 1,
192
+ };
193
+ const factory = createProgressWidget(widgetState);
194
+ const component = factory(mockTUI(), mockTheme());
195
+ const lines = component.render(120);
196
+ component.dispose!();
197
+
198
+ const exploreLine = lines.find(l => l.includes("explore"));
199
+ assert.ok(exploreLine?.includes("[transform]"), "transform step should show [transform] tag");
200
+ });
201
+
202
+ it("shows parallel count in header for multiple concurrent steps", () => {
203
+ const widgetState = makeWidgetState({ currentStep: "explore, analyze" });
204
+ const factory = createProgressWidget(widgetState);
205
+ const component = factory(mockTUI(), mockTheme());
206
+ const lines = component.render(120);
207
+ component.dispose!();
208
+
209
+ assert.ok(lines[0].includes("[2 parallel]"), "header should show parallel count");
210
+ });
211
+
212
+ it("shows multiple running indicators for parallel steps", () => {
213
+ const widgetState = makeWidgetState({ currentStep: "explore, analyze" });
214
+ const factory = createProgressWidget(widgetState);
215
+ const component = factory(mockTUI(), mockTheme());
216
+ const lines = component.render(120);
217
+ component.dispose!();
218
+
219
+ const runningLines = lines.filter(l => l.includes("▸"));
220
+ assert.strictEqual(runningLines.length, 2, "should have 2 running step indicators");
221
+ });
222
+
223
+ it("renders correct number of lines (header + one per step)", () => {
224
+ const widgetState = makeWidgetState();
225
+ const factory = createProgressWidget(widgetState);
226
+ const component = factory(mockTUI(), mockTheme());
227
+ const lines = component.render(120);
228
+ component.dispose!();
229
+
230
+ // 1 header + 3 steps = 4 lines
231
+ assert.strictEqual(lines.length, 4);
232
+ });
233
+
234
+ it("truncates lines to width", () => {
235
+ const widgetState = makeWidgetState({ currentStep: "explore" });
236
+ const factory = createProgressWidget(widgetState);
237
+ const component = factory(mockTUI(), mockTheme());
238
+ const lines = component.render(20);
239
+ component.dispose!();
240
+
241
+ for (const line of lines) {
242
+ assert.ok(line.length <= 20, `line exceeds width: "${line}" (${line.length})`);
243
+ }
244
+ });
245
+
246
+ it("renders resumed indicator when resumedSteps is set", () => {
247
+ const widgetState = makeWidgetState({ resumedSteps: 3, currentStep: "report" });
248
+ widgetState.state.steps.explore = {
249
+ step: "explore",
250
+ agent: "explorer",
251
+ status: "completed",
252
+ usage: zeroUsage(),
253
+ durationMs: 10000,
254
+ };
255
+ widgetState.state.steps.analyze = {
256
+ step: "analyze",
257
+ agent: "analyzer",
258
+ status: "completed",
259
+ usage: zeroUsage(),
260
+ durationMs: 20000,
261
+ };
262
+ const factory = createProgressWidget(widgetState);
263
+ const component = factory(mockTUI(), mockTheme());
264
+ const lines = component.render(120);
265
+ component.dispose!();
266
+
267
+ const resumedLine = lines.find(l => l.includes("Resumed"));
268
+ assert.ok(resumedLine, "should have a resumed indicator line");
269
+ assert.ok(resumedLine.includes("↻"), "should have ↻ symbol");
270
+ assert.ok(resumedLine.includes("3 steps from prior run"), "should show step count");
271
+ // Header + resumed line + 3 steps = 5 lines
272
+ assert.strictEqual(lines.length, 5);
273
+ });
274
+
275
+ it("renders paused indicator when state status is paused", () => {
276
+ const widgetState = makeWidgetState();
277
+ widgetState.state.status = "paused";
278
+ widgetState.state.steps.explore = {
279
+ step: "explore",
280
+ agent: "explorer",
281
+ status: "completed",
282
+ usage: zeroUsage(),
283
+ durationMs: 10000,
284
+ };
285
+ const factory = createProgressWidget(widgetState);
286
+ const component = factory(mockTUI(), mockTheme());
287
+ const lines = component.render(120);
288
+ component.dispose!();
289
+
290
+ const pausedLine = lines.find(l => l.includes("Paused"));
291
+ assert.ok(pausedLine, "should have a paused indicator line");
292
+ assert.ok(pausedLine.includes("⏸"), "should have ⏸ symbol");
293
+ });
294
+
295
+ it("does not render resumed indicator when resumedSteps is not set", () => {
296
+ const widgetState = makeWidgetState();
297
+ const factory = createProgressWidget(widgetState);
298
+ const component = factory(mockTUI(), mockTheme());
299
+ const lines = component.render(120);
300
+ component.dispose!();
301
+
302
+ assert.ok(!lines.some(l => l.includes("Resumed")), "should not have resumed indicator");
303
+ });
304
+
305
+ it("shows [truncated] indicator for truncated step", () => {
306
+ const widgetState = makeWidgetState({ currentStep: "analyze" });
307
+ widgetState.state.steps.explore = {
308
+ step: "explore",
309
+ agent: "explorer",
310
+ status: "completed",
311
+ usage: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 1 },
312
+ durationMs: 5000,
313
+ truncated: true,
314
+ warnings: ["Stdout exceeded 10MB limit"],
315
+ };
316
+ const factory = createProgressWidget(widgetState);
317
+ const component = factory(mockTUI(), mockTheme());
318
+ const lines = component.render(120);
319
+ component.dispose!();
320
+
321
+ // Find the explore line and verify it contains a truncated indicator
322
+ const exploreLine = lines.find(l => l.includes("explore"));
323
+ assert.ok(exploreLine, "explore line should exist");
324
+ assert.ok(exploreLine.includes("truncated"), "Truncated step should show [truncated] indicator");
325
+ });
326
+
327
+ it("does not show [truncated] indicator for non-truncated step", () => {
328
+ const widgetState = makeWidgetState({ currentStep: "analyze" });
329
+ widgetState.state.steps.explore = {
330
+ step: "explore",
331
+ agent: "explorer",
332
+ status: "completed",
333
+ usage: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 1 },
334
+ durationMs: 5000,
335
+ };
336
+ const factory = createProgressWidget(widgetState);
337
+ const component = factory(mockTUI(), mockTheme());
338
+ const lines = component.render(120);
339
+ component.dispose!();
340
+
341
+ const exploreLine = lines.find(l => l.includes("explore"));
342
+ assert.ok(exploreLine, "explore line should exist");
343
+ assert.ok(!exploreLine.includes("truncated"), "Non-truncated step should not show [truncated]");
344
+ });
345
+
346
+ it("dispose clears the pulse interval", () => {
347
+ const widgetState = makeWidgetState();
348
+ const factory = createProgressWidget(widgetState);
349
+ const component = factory(mockTUI(), mockTheme());
350
+
351
+ // Should not throw; if dispose didn't clear the interval,
352
+ // the test process would hang (node:test waits for timers to drain)
353
+ component.dispose!();
354
+ });
355
+ });
package/src/tui.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * TUI progress widget for workflow execution.
3
+ * Shows step status, timing, cost, and parallel execution indicators.
4
+ */
5
+ import type { Theme } from "@mariozechner/pi-coding-agent";
6
+ import type { TUI, Component } from "@mariozechner/pi-tui";
7
+ import type { WorkflowSpec, ExecutionState, StepResult } from "./types.ts";
8
+ import { formatDuration, formatCost, formatTokens } from "./format.ts";
9
+
10
+ export interface StepActivity {
11
+ tool: string; // "read", "edit", "bash", etc.
12
+ preview: string; // "src/index.ts", "npm test", etc.
13
+ timestamp: number;
14
+ }
15
+
16
+ export interface StepOutputSummary {
17
+ tasks?: Array<{ name: string; status: string; files?: string[] }>;
18
+ testCount?: number;
19
+ note?: string;
20
+ }
21
+
22
+ export interface ProgressWidgetState {
23
+ spec: WorkflowSpec;
24
+ state: ExecutionState;
25
+ currentStep?: string; // name of the currently running step (comma-separated for parallel)
26
+ startTime: number; // Date.now() when workflow started
27
+ parallelSubSteps?: Record<string, import("./types.ts").StepResult>; // live sub-step results for parallel step
28
+ resumedSteps?: number; // number of steps carried from a prior run (resume indicator)
29
+ activities: Map<string, StepActivity[]>; // stepName → recent tool calls (ring buffer, last 5)
30
+ outputSummaries: Map<string, StepOutputSummary>; // stepName → parsed output after completion
31
+ }
32
+
33
+ /**
34
+ * Create a widget factory for ctx.ui.setWidget().
35
+ * Returns a function that pi calls to get the component.
36
+ *
37
+ * The returned component renders a compact progress view:
38
+ * ─────────────────────────────────────
39
+ * ● bugfix step 2/3 1m32s
40
+ * ✓ diagnose 42s $0.03 12k tok
41
+ * ▸ fix 50s 8k tok...
42
+ * · verify
43
+ * ─────────────────────────────────────
44
+ */
45
+ export function createProgressWidget(
46
+ widgetState: ProgressWidgetState,
47
+ ): (tui: TUI, theme: Theme) => Component & { dispose?(): void } {
48
+ return (tui: TUI, theme: Theme) => {
49
+ /** Pulse interval (ms) for the elapsed-time ticker. Balances update frequency vs overhead. */
50
+ const PULSE_INTERVAL_MS = 800;
51
+ let pulseOn = true;
52
+ const interval = setInterval(() => {
53
+ pulseOn = !pulseOn;
54
+ tui.requestRender();
55
+ }, PULSE_INTERVAL_MS);
56
+
57
+ return {
58
+ render(width: number): string[] {
59
+ const lines: string[] = [];
60
+ const stepNames = Object.keys(widgetState.spec.steps);
61
+ const totalSteps = stepNames.length;
62
+
63
+ // Parse current steps (may be comma-separated for parallel)
64
+ const currentSteps = widgetState.currentStep?.split(", ") ?? [];
65
+ const parallelCount = currentSteps.length;
66
+
67
+ // Determine current step number
68
+ let currentStepNum = 0;
69
+ if (currentSteps.length > 0 && currentSteps[0]) {
70
+ const idx = stepNames.indexOf(currentSteps[0]);
71
+ currentStepNum = idx >= 0 ? idx + 1 : 0;
72
+ } else {
73
+ // Count completed steps
74
+ currentStepNum = Object.values(widgetState.state.steps).filter(
75
+ (s) => s.status === "completed",
76
+ ).length;
77
+ }
78
+
79
+ const elapsed = formatDuration(Date.now() - widgetState.startTime);
80
+ const workflowName = theme.bold(widgetState.spec.name);
81
+ const indicator = pulseOn
82
+ ? theme.fg("accent", "\u25cf")
83
+ : theme.fg("dim", "\u25cf");
84
+
85
+ const parallelTag = parallelCount > 1 ? ` [${parallelCount} parallel]` : "";
86
+ const headerLine = `${indicator} ${workflowName} step ${currentStepNum}/${totalSteps}${parallelTag} ${theme.fg("dim", elapsed)}`;
87
+ lines.push(headerLine.length > width ? headerLine.slice(0, width) : headerLine);
88
+
89
+ // Resumed indicator
90
+ if (widgetState.resumedSteps) {
91
+ const resumedLine = ` ${theme.fg("dim", "\u21bb")} Resumed: ${widgetState.resumedSteps} steps from prior run`;
92
+ lines.push(resumedLine.length > width ? resumedLine.slice(0, width) : resumedLine);
93
+ }
94
+
95
+ // Paused indicator
96
+ if (widgetState.state.status === "paused") {
97
+ const pausedLine = ` ${theme.fg("accent", "\u23f8")} Paused`;
98
+ lines.push(pausedLine.length > width ? pausedLine.slice(0, width) : pausedLine);
99
+ }
100
+
101
+ // Step lines
102
+ for (const stepName of stepNames) {
103
+ const stepResult: StepResult | undefined = widgetState.state.steps[stepName];
104
+ let line: string;
105
+
106
+ if (stepResult && stepResult.status === "skipped") {
107
+ // Skipped step: ⊘ stepName [skipped]
108
+ line = ` ${theme.fg("dim", "\u2298")} ${stepName} ${theme.fg("dim", "[skipped]")}`;
109
+ } else if (stepResult && stepResult.status === "completed") {
110
+ const dur = formatDuration(stepResult.durationMs);
111
+ const cost = formatCost(stepResult.usage.cost);
112
+ const tok = formatTokens(stepResult.usage.input + stepResult.usage.output);
113
+ // Show step type indicator for gate/transform
114
+ const typeTag = stepResult.agent === "gate" ? " [gate]" : stepResult.agent === "transform" ? " [transform]" : "";
115
+ const truncTag = stepResult.truncated ? ` ${theme.fg("warning", "[truncated]")}` : "";
116
+ line = ` ${theme.fg("success", "\u2713")} ${stepName}${typeTag} ${theme.fg("dim", dur)} ${theme.fg("dim", cost)} ${theme.fg("dim", tok)}${truncTag}`;
117
+ lines.push(line.length > width ? line.slice(0, width) : line);
118
+
119
+ // Render output summary sub-lines (capped at 3)
120
+ const summary = widgetState.outputSummaries?.get(stepName);
121
+ if (summary) {
122
+ let summaryLines = 0;
123
+ const MAX_SUMMARY_LINES = 3;
124
+ if (summary.tasks) {
125
+ for (const task of summary.tasks) {
126
+ if (summaryLines >= MAX_SUMMARY_LINES) break;
127
+ const files = task.files?.join(", ") || "";
128
+ const statusIcon = task.status === "done" ? "\u2713" : task.status === "failed" ? "\u2717" : "\u00b7";
129
+ const taskLine = ` ${theme.fg("dim", statusIcon)} ${theme.fg("dim", task.name)}${files ? " " + theme.fg("dim", files) : ""}`;
130
+ lines.push(taskLine.length > width ? taskLine.slice(0, width) : taskLine);
131
+ summaryLines++;
132
+ }
133
+ }
134
+ if (summary.testCount && summaryLines < MAX_SUMMARY_LINES) {
135
+ const testLine = ` ${theme.fg("dim", `${summary.testCount} tests pass`)}`;
136
+ lines.push(testLine.length > width ? testLine.slice(0, width) : testLine);
137
+ summaryLines++;
138
+ }
139
+ if (summary.note && summaryLines < MAX_SUMMARY_LINES) {
140
+ const noteLine = ` ${theme.fg("dim", summary.note)}`;
141
+ lines.push(noteLine.length > width ? noteLine.slice(0, width) : noteLine);
142
+ }
143
+ }
144
+ continue; // skip the push below, already pushed
145
+ } else if (stepResult && stepResult.status === "failed") {
146
+ const dur = formatDuration(stepResult.durationMs);
147
+ const errorPreview = stepResult.error || "Unknown error";
148
+ line = ` ${theme.fg("error", "\u2717")} ${stepName} ${theme.fg("dim", dur)} ${errorPreview}`;
149
+ } else if (stepResult && stepResult.agent === "parallel") {
150
+ // Completed parallel step — show with sub-step count
151
+ const dur = formatDuration(stepResult.durationMs);
152
+ const cost = formatCost(stepResult.usage.cost);
153
+ const tok = formatTokens(stepResult.usage.input + stepResult.usage.output);
154
+ line = ` ${theme.fg("success", "\u2713")} ${stepName} [parallel] ${theme.fg("dim", dur)} ${theme.fg("dim", cost)} ${theme.fg("dim", tok)}`;
155
+ } else if (currentSteps.includes(stepName)) {
156
+ const stepElapsed = formatDuration(Date.now() - widgetState.startTime);
157
+ line = ` ${theme.fg("accent", "\u25b8")} ${theme.fg("accent", stepName)} ${theme.fg("dim", stepElapsed + "...")}`;
158
+ lines.push(line.length > width ? line.slice(0, width) : line);
159
+
160
+ // Render most recent tool activity under running step
161
+ const activities = widgetState.activities?.get(stepName);
162
+ if (activities && activities.length > 0) {
163
+ const latest = activities[activities.length - 1];
164
+ const actLine = ` ${theme.fg("dim", latest.tool)} ${theme.fg("dim", latest.preview)}`;
165
+ lines.push(actLine.length > width ? actLine.slice(0, width) : actLine);
166
+ }
167
+ continue; // skip the push below, already pushed
168
+ } else {
169
+ line = ` ${theme.fg("dim", "\u00b7")} ${stepName}`;
170
+ }
171
+
172
+ lines.push(line.length > width ? line.slice(0, width) : line);
173
+ }
174
+
175
+ return lines;
176
+ },
177
+ dispose() {
178
+ clearInterval(interval);
179
+ },
180
+ };
181
+ };
182
+ }