@osolmaz/pi-workflows 0.5.1 → 0.5.3
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 +10 -4
- package/dist/extension/index.js +14 -17
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.d.ts +9 -12
- package/dist/extension/widget.js +107 -72
- package/dist/extension/widget.js.map +1 -1
- package/dist/render/graph-render.js +5 -12
- package/dist/render/graph-render.js.map +1 -1
- package/dist/render/node-type.d.ts +5 -0
- package/dist/render/node-type.js +19 -0
- package/dist/render/node-type.js.map +1 -0
- package/docs/plans/2026-08-13-responsive-workflow-widget-plan.md +32 -11
- package/docs/tui-viewer.md +3 -2
- package/package.json +1 -1
- package/src/extension/index.ts +17 -18
- package/src/extension/widget.ts +140 -94
- package/src/render/graph-render.ts +6 -14
- package/src/render/node-type.ts +28 -0
package/package.json
CHANGED
package/src/extension/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { builtinWorkflowCatalog } from "../builtins/catalog.js";
|
|
5
5
|
import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
|
|
6
6
|
import type { JsonObject } from "../controllers/types.js";
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
} from "./controller-host.js";
|
|
37
37
|
import { ConversationStepExecutor } from "./executor.js";
|
|
38
38
|
import { SessionRecorder } from "./recorder.js";
|
|
39
|
-
import { buildWidgetView
|
|
39
|
+
import { buildWidgetView } from "./widget.js";
|
|
40
40
|
import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
|
|
41
41
|
|
|
42
42
|
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
@@ -209,7 +209,7 @@ type WorkflowWidgetComponent = {
|
|
|
209
209
|
invalidate: () => void;
|
|
210
210
|
};
|
|
211
211
|
|
|
212
|
-
type WorkflowWidgetFactory = () => WorkflowWidgetComponent;
|
|
212
|
+
type WorkflowWidgetFactory = (_tui: unknown, theme: Theme) => WorkflowWidgetComponent;
|
|
213
213
|
type WorkflowWidgetContent = string[] | WorkflowWidgetFactory;
|
|
214
214
|
|
|
215
215
|
export default function piWorkflows(pi: ExtensionAPI) {
|
|
@@ -313,12 +313,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
313
313
|
let widgetTimer: NodeJS.Timeout | null = null;
|
|
314
314
|
let widgetTicker: NodeJS.Timeout | null = null;
|
|
315
315
|
// Manual widget scroll: null follows the active node; a number is the
|
|
316
|
-
// first visible
|
|
316
|
+
// first visible node row, set by shift+↑/↓ and reset on step advance.
|
|
317
317
|
let widgetSource: WidgetSource | null = null;
|
|
318
|
-
let widgetScroll:
|
|
318
|
+
let widgetScroll: number | null = null;
|
|
319
319
|
let widgetShownScroll = 0;
|
|
320
320
|
let widgetMaxScroll = 0;
|
|
321
|
-
let widgetLayout: WidgetLayout | null = null;
|
|
322
321
|
let widgetStepCount = 0;
|
|
323
322
|
let sessionClosed = false;
|
|
324
323
|
let runGeneration = 0;
|
|
@@ -378,7 +377,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
378
377
|
if (!widgetSource) {
|
|
379
378
|
return;
|
|
380
379
|
}
|
|
381
|
-
const render = (width = Number.POSITIVE_INFINITY): string[] => {
|
|
380
|
+
const render = (width = Number.POSITIVE_INFINITY, theme?: Theme): string[] => {
|
|
382
381
|
if (!widgetSource) return [];
|
|
383
382
|
const view = buildWidgetView(
|
|
384
383
|
widgetSource.state,
|
|
@@ -387,17 +386,20 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
387
386
|
widgetScroll,
|
|
388
387
|
runHeld(),
|
|
389
388
|
width,
|
|
389
|
+
theme,
|
|
390
390
|
);
|
|
391
|
-
widgetLayout = view.layout;
|
|
392
391
|
widgetShownScroll = view.scroll;
|
|
393
392
|
widgetMaxScroll = view.maxScroll;
|
|
394
|
-
if (widgetScroll
|
|
395
|
-
widgetScroll
|
|
393
|
+
if (widgetScroll !== null) {
|
|
394
|
+
widgetScroll = view.scroll;
|
|
396
395
|
}
|
|
397
396
|
return view.lines;
|
|
398
397
|
};
|
|
399
398
|
if (ctx.mode === "tui") {
|
|
400
|
-
setWidget(ctx, () => ({
|
|
399
|
+
setWidget(ctx, (_tui, theme) => ({
|
|
400
|
+
render: (width) => render(width, theme),
|
|
401
|
+
invalidate() {},
|
|
402
|
+
}));
|
|
401
403
|
} else {
|
|
402
404
|
// RPC transports string widgets but cannot serialize TUI component factories.
|
|
403
405
|
setWidget(ctx, render());
|
|
@@ -413,7 +415,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
413
415
|
if (state.steps.length !== widgetStepCount) {
|
|
414
416
|
widgetStepCount = state.steps.length;
|
|
415
417
|
// The workflow moved on; resume following the active node.
|
|
416
|
-
widgetScroll =
|
|
418
|
+
widgetScroll = null;
|
|
417
419
|
}
|
|
418
420
|
widgetSource = { state, snapshot };
|
|
419
421
|
renderWidget(ctx);
|
|
@@ -421,8 +423,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
421
423
|
|
|
422
424
|
const clearWidget = (ctx: ExtensionContext) => {
|
|
423
425
|
widgetSource = null;
|
|
424
|
-
widgetScroll =
|
|
425
|
-
widgetLayout = null;
|
|
426
|
+
widgetScroll = null;
|
|
426
427
|
setWidget(ctx, undefined);
|
|
427
428
|
setStatus(ctx, undefined);
|
|
428
429
|
};
|
|
@@ -431,8 +432,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
431
432
|
if (!widgetSource || widgetMaxScroll === 0) {
|
|
432
433
|
return;
|
|
433
434
|
}
|
|
434
|
-
|
|
435
|
-
widgetScroll[widgetLayout] = Math.max(0, Math.min(widgetShownScroll + delta, widgetMaxScroll));
|
|
435
|
+
widgetScroll = Math.max(0, Math.min(widgetShownScroll + delta, widgetMaxScroll));
|
|
436
436
|
renderWidget(ctx);
|
|
437
437
|
};
|
|
438
438
|
|
|
@@ -1813,8 +1813,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1813
1813
|
clearWidgetTimer();
|
|
1814
1814
|
stopWidgetTicker();
|
|
1815
1815
|
widgetSource = null;
|
|
1816
|
-
widgetScroll =
|
|
1817
|
-
widgetLayout = null;
|
|
1816
|
+
widgetScroll = null;
|
|
1818
1817
|
});
|
|
1819
1818
|
}
|
|
1820
1819
|
|
package/src/extension/widget.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatDuration } from "../render/format.js";
|
|
4
|
+
import { nodeTypeGlyph } from "../render/node-type.js";
|
|
4
5
|
import { sanitizeText } from "../workflows/text.js";
|
|
5
6
|
import type {
|
|
6
7
|
WorkflowDefinitionSnapshot,
|
|
@@ -20,9 +21,10 @@ const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
|
|
|
20
21
|
/**
|
|
21
22
|
* pi renders at most this many widget lines (InteractiveMode.MAX_WIDGET_LINES)
|
|
22
23
|
* and appends its own "(widget truncated)" marker beyond it. Stay inside the
|
|
23
|
-
* budget and choose which
|
|
24
|
+
* budget and choose which node rows to show instead of losing the bottom.
|
|
24
25
|
*/
|
|
25
26
|
const PI_MAX_WIDGET_LINES = 10;
|
|
27
|
+
const MAX_NODE_ERROR_CHARS = 120;
|
|
26
28
|
|
|
27
29
|
export function nodeGlyph(state: WorkflowRunState, nodeId: string): string {
|
|
28
30
|
if (state.currentNode === nodeId) {
|
|
@@ -43,36 +45,32 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
|
|
|
43
45
|
return Object.keys(snapshot.nodes);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
export type
|
|
47
|
-
|
|
48
|
-
export type WidgetScrollState = Record<WidgetLayout, number | null>;
|
|
48
|
+
export type WidgetTheme = Pick<Theme, "bold" | "fg">;
|
|
49
49
|
|
|
50
50
|
export type WidgetView = {
|
|
51
51
|
lines: string[];
|
|
52
|
-
|
|
53
|
-
/** The clamped first visible row for the selected layout. */
|
|
52
|
+
/** The clamped first visible node row. */
|
|
54
53
|
scroll: number;
|
|
55
|
-
/** Largest useful scroll value; 0 when the whole
|
|
54
|
+
/** Largest useful scroll value; 0 when the whole list fits. */
|
|
56
55
|
maxScroll: number;
|
|
57
56
|
};
|
|
58
57
|
|
|
59
58
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* the active node by default, or at `scroll` when the user scrolled
|
|
64
|
-
* manually. Pure so it can be tested without a TUI.
|
|
59
|
+
* Compact live-progress view for the in-pi widget. It uses one line per node,
|
|
60
|
+
* follows the active node by default, and never returns a line wider than the
|
|
61
|
+
* width supplied by Pi's component renderer.
|
|
65
62
|
*/
|
|
66
63
|
export function buildWidgetView(
|
|
67
64
|
state: WorkflowRunState,
|
|
68
65
|
snapshot: WorkflowDefinitionSnapshot,
|
|
69
66
|
now: Date = new Date(),
|
|
70
|
-
scroll:
|
|
67
|
+
scroll: number | null = null,
|
|
71
68
|
held = false,
|
|
72
69
|
width = Number.POSITIVE_INFINITY,
|
|
70
|
+
theme?: WidgetTheme,
|
|
73
71
|
): WidgetView {
|
|
74
72
|
const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
|
|
75
|
-
if (availableWidth === 0) return { lines: [],
|
|
73
|
+
if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
|
|
76
74
|
|
|
77
75
|
// `held` covers pauses the state cannot see yet: an escape-interrupted
|
|
78
76
|
// step or a pause requested while the current node is still finishing.
|
|
@@ -82,69 +80,39 @@ export function buildWidgetView(
|
|
|
82
80
|
// Titles, status details, and errors can carry model- or shell-controlled
|
|
83
81
|
// text; never let escape sequences or newlines reach the terminal.
|
|
84
82
|
const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
|
|
85
|
-
const
|
|
83
|
+
const headerTone = statusTone(paused ? "waiting" : state.status);
|
|
84
|
+
const header = `${paint(theme, headerTone, glyph)} workflow ${sanitizeText(state.workflowName)}${title} ${paint(theme, headerTone, `[${statusText}]`)}`;
|
|
86
85
|
|
|
87
86
|
const footer: string[] = [];
|
|
88
87
|
if (state.error) {
|
|
89
|
-
footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
|
|
88
|
+
footer.push(paint(theme, "error", ` error: ${truncate(sanitizeText(state.error), 120)}`));
|
|
90
89
|
}
|
|
91
90
|
if (state.status === "waiting" && state.waitingOn) {
|
|
92
|
-
footer.push(
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
|
|
96
|
-
const graph = renderGraphLines({ state, snapshot }, state.steps.length - 1, now, {
|
|
97
|
-
nodeStyle: "box",
|
|
98
|
-
});
|
|
99
|
-
if (graph.length > 0) {
|
|
100
|
-
const graphScroll = scroll.graph;
|
|
101
|
-
const windowed = windowLines(
|
|
102
|
-
graph,
|
|
103
|
-
budget,
|
|
104
|
-
graphScroll ?? focusLine(graph, state),
|
|
105
|
-
graphScroll !== null,
|
|
91
|
+
footer.push(
|
|
92
|
+
paint(theme, "warning", ` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`),
|
|
106
93
|
);
|
|
107
|
-
const graphLines = windowed.lines.map((line) => ` ${line}`);
|
|
108
|
-
if (graphLines.every((line) => visibleWidth(line) <= availableWidth)) {
|
|
109
|
-
return {
|
|
110
|
-
lines: fitLines([header, ...graphLines, ...footer], availableWidth),
|
|
111
|
-
layout: "graph",
|
|
112
|
-
scroll: windowed.scroll,
|
|
113
|
-
maxScroll: windowed.maxScroll,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
94
|
}
|
|
117
95
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
state: WorkflowRunState,
|
|
123
|
-
snapshot: WorkflowDefinitionSnapshot,
|
|
124
|
-
header: string,
|
|
125
|
-
footer: string[],
|
|
126
|
-
budget: number,
|
|
127
|
-
width: number,
|
|
128
|
-
scroll: number | null,
|
|
129
|
-
): WidgetView {
|
|
130
|
-
const nodes = displayNodeIds(snapshot).map((nodeId) => compactNodeLine(state, snapshot, nodeId));
|
|
96
|
+
const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
|
|
97
|
+
const nodes = displayNodeIds(snapshot).map((nodeId) =>
|
|
98
|
+
compactNodeLine(state, snapshot, nodeId, now, paused, theme),
|
|
99
|
+
);
|
|
131
100
|
if (nodes.length === 0) {
|
|
132
101
|
return {
|
|
133
|
-
lines: fitLines([header, ...footer],
|
|
134
|
-
layout: "compact",
|
|
102
|
+
lines: fitLines([header, ...footer], availableWidth),
|
|
135
103
|
scroll: 0,
|
|
136
104
|
maxScroll: 0,
|
|
137
105
|
};
|
|
138
106
|
}
|
|
107
|
+
|
|
139
108
|
const anchor = scroll ?? compactFocusIndex(state, snapshot);
|
|
140
|
-
const windowed = windowLines(nodes, budget, anchor, scroll !== null);
|
|
141
|
-
const indentation =
|
|
109
|
+
const windowed = windowLines(nodes, budget, anchor, scroll !== null, theme);
|
|
110
|
+
const indentation = availableWidth >= 3 ? " " : "";
|
|
142
111
|
return {
|
|
143
112
|
lines: fitLines(
|
|
144
113
|
[header, ...windowed.lines.map((line) => `${indentation}${line}`), ...footer],
|
|
145
|
-
|
|
114
|
+
availableWidth,
|
|
146
115
|
),
|
|
147
|
-
layout: "compact",
|
|
148
116
|
scroll: windowed.scroll,
|
|
149
117
|
maxScroll: windowed.maxScroll,
|
|
150
118
|
};
|
|
@@ -162,14 +130,116 @@ function compactNodeLine(
|
|
|
162
130
|
state: WorkflowRunState,
|
|
163
131
|
snapshot: WorkflowDefinitionSnapshot,
|
|
164
132
|
nodeId: string,
|
|
133
|
+
now: Date,
|
|
134
|
+
paused: boolean,
|
|
135
|
+
theme?: WidgetTheme,
|
|
165
136
|
): string {
|
|
166
137
|
const node = snapshot.nodes[nodeId];
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
138
|
+
const glyph = nodeGlyph(state, nodeId);
|
|
139
|
+
const typeGlyph = node ? nodeTypeGlyph(node.nodeType, node.actionExecution) : "?";
|
|
140
|
+
const name = sanitizeText(nodeId);
|
|
141
|
+
const segments = nodeRuntimeSegments(state, snapshot, nodeId, now);
|
|
142
|
+
const isCurrent = state.currentNode === nodeId;
|
|
143
|
+
const isWaiting = state.waitingOn === nodeId;
|
|
144
|
+
|
|
145
|
+
if (isCurrent || isWaiting) {
|
|
146
|
+
const tone = paused || isWaiting ? "warning" : "accent";
|
|
147
|
+
const focusedName = theme ? theme.bold(name) : name;
|
|
148
|
+
const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
|
|
149
|
+
return paint(theme, tone, `${glyph} ${typeGlyph} ${focusedName}${detail}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = state.results[nodeId];
|
|
153
|
+
if (!result) {
|
|
154
|
+
const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
|
|
155
|
+
return paint(theme, "dim", `${glyph} ${typeGlyph} ${name}${detail}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const styledGlyph = paint(theme, result.outcome === "ok" ? "success" : "error", glyph);
|
|
159
|
+
const styledType = paint(theme, "dim", typeGlyph);
|
|
160
|
+
const styledSegments = segments.map((segment, index) =>
|
|
161
|
+
paint(
|
|
162
|
+
theme,
|
|
163
|
+
result.outcome !== "ok" && index === segments.length - 1 ? "error" : "dim",
|
|
164
|
+
segment,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
const detail = styledSegments.length > 0 ? ` · ${styledSegments.join(" · ")}` : "";
|
|
168
|
+
return `${styledGlyph} ${styledType} ${name}${detail}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nodeRuntimeSegments(
|
|
172
|
+
state: WorkflowRunState,
|
|
173
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
174
|
+
nodeId: string,
|
|
175
|
+
now: Date,
|
|
176
|
+
): string[] {
|
|
177
|
+
const segments: string[] = [];
|
|
178
|
+
const completedAttempts = state.steps.filter((step) => step.nodeId === nodeId).length;
|
|
179
|
+
const attempts = completedAttempts + (state.currentNode === nodeId ? 1 : 0);
|
|
180
|
+
if (attempts > 1) {
|
|
181
|
+
segments.push(`↻${attempts}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const result = state.results[nodeId];
|
|
185
|
+
if (state.currentNode === nodeId) {
|
|
186
|
+
if (state.statusDetail) {
|
|
187
|
+
segments.push(sanitizeText(state.statusDetail));
|
|
188
|
+
}
|
|
189
|
+
const elapsed = elapsedSince(state.currentNodeStartedAt, now);
|
|
190
|
+
if (elapsed !== null) {
|
|
191
|
+
segments.push(elapsed);
|
|
192
|
+
}
|
|
193
|
+
return segments;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (state.waitingOn === nodeId) {
|
|
197
|
+
const summary = snapshot.nodes[nodeId]?.summary;
|
|
198
|
+
segments.push(summary ? sanitizeText(summary) : "waiting");
|
|
199
|
+
return segments;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!result) {
|
|
203
|
+
return segments;
|
|
204
|
+
}
|
|
205
|
+
if (result.outcome !== "ok") {
|
|
206
|
+
segments.push(
|
|
207
|
+
result.error
|
|
208
|
+
? truncate(sanitizeText(result.error), MAX_NODE_ERROR_CHARS)
|
|
209
|
+
: result.outcome.replaceAll("_", " "),
|
|
210
|
+
);
|
|
211
|
+
return segments;
|
|
212
|
+
}
|
|
213
|
+
if (Number.isFinite(result.durationMs)) {
|
|
214
|
+
segments.push(formatDuration(result.durationMs));
|
|
215
|
+
}
|
|
216
|
+
return segments;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function elapsedSince(startedAt: string | undefined, now: Date): string | null {
|
|
220
|
+
if (!startedAt) return null;
|
|
221
|
+
const started = Date.parse(startedAt);
|
|
222
|
+
if (!Number.isFinite(started)) return null;
|
|
223
|
+
return formatDuration(Math.max(0, now.getTime() - started));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function statusTone(status: WorkflowRunStatus): ThemeColor {
|
|
227
|
+
switch (status) {
|
|
228
|
+
case "completed":
|
|
229
|
+
return "success";
|
|
230
|
+
case "failed":
|
|
231
|
+
case "timed_out":
|
|
232
|
+
case "cancelled":
|
|
233
|
+
return "error";
|
|
234
|
+
case "waiting":
|
|
235
|
+
return "warning";
|
|
236
|
+
case "running":
|
|
237
|
+
return "accent";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function paint(theme: WidgetTheme | undefined, color: ThemeColor, text: string): string {
|
|
242
|
+
return theme ? theme.fg(color, text) : text;
|
|
173
243
|
}
|
|
174
244
|
|
|
175
245
|
function fitLines(lines: string[], width: number): string[] {
|
|
@@ -177,7 +247,7 @@ function fitLines(lines: string[], width: number): string[] {
|
|
|
177
247
|
return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
|
|
178
248
|
}
|
|
179
249
|
|
|
180
|
-
/**
|
|
250
|
+
/** Compact line view following the active node. */
|
|
181
251
|
export function buildWidgetLines(
|
|
182
252
|
state: WorkflowRunState,
|
|
183
253
|
snapshot: WorkflowDefinitionSnapshot,
|
|
@@ -186,29 +256,6 @@ export function buildWidgetLines(
|
|
|
186
256
|
return buildWidgetView(state, snapshot, now).lines;
|
|
187
257
|
}
|
|
188
258
|
|
|
189
|
-
/** The graph row the window should center on: the active or waiting node. */
|
|
190
|
-
function focusLine(graph: string[], state: WorkflowRunState): number {
|
|
191
|
-
const active = graph.findIndex((line) => stripAnsi(line).includes("◐"));
|
|
192
|
-
const focus =
|
|
193
|
-
active !== -1
|
|
194
|
-
? active
|
|
195
|
-
: state.waitingOn
|
|
196
|
-
? graph.findIndex((line) => stripAnsi(line).includes(state.waitingOn as string))
|
|
197
|
-
: -1;
|
|
198
|
-
if (focus === -1) {
|
|
199
|
-
return graph.length - 1;
|
|
200
|
-
}
|
|
201
|
-
let top = focus;
|
|
202
|
-
while (top > 0 && !/[┌┏]/u.test(stripAnsi(graph[top] ?? ""))) {
|
|
203
|
-
top -= 1;
|
|
204
|
-
}
|
|
205
|
-
let bottom = focus;
|
|
206
|
-
while (bottom + 1 < graph.length && !/[└┗]/u.test(stripAnsi(graph[bottom] ?? ""))) {
|
|
207
|
-
bottom += 1;
|
|
208
|
-
}
|
|
209
|
-
return Math.floor((top + bottom) / 2);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
259
|
/**
|
|
213
260
|
* Slice `lines` to at most `budget` rows, marking hidden rows at either end.
|
|
214
261
|
* Markers count against the budget. `anchor` is a row to center on (follow
|
|
@@ -219,12 +266,11 @@ function windowLines(
|
|
|
219
266
|
budget: number,
|
|
220
267
|
anchor: number,
|
|
221
268
|
anchorIsStart: boolean,
|
|
269
|
+
theme?: WidgetTheme,
|
|
222
270
|
): { lines: string[]; scroll: number; maxScroll: number } {
|
|
223
271
|
if (lines.length <= budget) {
|
|
224
272
|
return { lines, scroll: 0, maxScroll: 0 };
|
|
225
273
|
}
|
|
226
|
-
// Reserve one combined overflow row, leaving seven rows for a complete
|
|
227
|
-
// full card even when the widget also has an error footer.
|
|
228
274
|
const inner = Math.max(1, budget - 1);
|
|
229
275
|
const start = clampStart(anchor, inner, lines.length, anchorIsStart);
|
|
230
276
|
const end = start + inner;
|
|
@@ -234,7 +280,7 @@ function windowLines(
|
|
|
234
280
|
const directions = [above > 0 ? `↑ ${above}` : "", below > 0 ? `↓ ${below}` : ""]
|
|
235
281
|
.filter(Boolean)
|
|
236
282
|
.join(" · ");
|
|
237
|
-
out.push(
|
|
283
|
+
out.push(paint(theme, "dim", `${directions} more · shift+↑/↓ scroll`));
|
|
238
284
|
return { lines: out, scroll: start, maxScroll: Math.max(0, lines.length - inner) };
|
|
239
285
|
}
|
|
240
286
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type GraphLayout,
|
|
14
14
|
type GraphSegment,
|
|
15
15
|
} from "./graph.js";
|
|
16
|
+
import { nodeTypeBadge } from "./node-type.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Renders the workflow DAG as text, mirroring the acpx replay viewer's graph
|
|
@@ -76,14 +77,6 @@ const GRAPH_SIDE_MARGIN = 2;
|
|
|
76
77
|
const CARD_MIN_CONTENT_WIDTH = 28;
|
|
77
78
|
const CARD_DYNAMIC_RESERVE = "↻ 100 ◷ 9999d 23h 59m 59s";
|
|
78
79
|
|
|
79
|
-
const NODE_TYPE_GLYPHS: Record<string, string> = {
|
|
80
|
-
agent: "●",
|
|
81
|
-
compute: "ƒ",
|
|
82
|
-
notify: "✉",
|
|
83
|
-
action: "⚙",
|
|
84
|
-
checkpoint: "◆",
|
|
85
|
-
};
|
|
86
|
-
|
|
87
80
|
function nodeTypeStyle(nodeType: string): CanvasStyle {
|
|
88
81
|
switch (nodeType) {
|
|
89
82
|
case "agent":
|
|
@@ -98,10 +91,6 @@ function nodeTypeStyle(nodeType: string): CanvasStyle {
|
|
|
98
91
|
}
|
|
99
92
|
}
|
|
100
93
|
|
|
101
|
-
function nodeTypeBadge(nodeType: string): string {
|
|
102
|
-
return `${NODE_TYPE_GLYPHS[nodeType] ?? "?"} ${nodeType}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
94
|
function fitText(text: string, width: number): string {
|
|
106
95
|
const chars = [...text];
|
|
107
96
|
if (chars.length <= width) return text;
|
|
@@ -228,7 +217,7 @@ function cardMetrics(view: GraphView): CardMetrics {
|
|
|
228
217
|
}
|
|
229
218
|
for (const [nodeId, node] of Object.entries(snapshot.nodes)) {
|
|
230
219
|
measure(sanitizeText(nodeId));
|
|
231
|
-
measure(nodeTypeBadge(node.nodeType));
|
|
220
|
+
measure(nodeTypeBadge(node.nodeType, node.actionExecution));
|
|
232
221
|
const labels = nodeBranchLabels(view, nodeId);
|
|
233
222
|
branchRows = Math.max(branchRows, labels.length);
|
|
234
223
|
for (const label of labels) measure(`◇ ${label}`);
|
|
@@ -252,6 +241,7 @@ type RenderedCell = {
|
|
|
252
241
|
text: string;
|
|
253
242
|
nodeId: string;
|
|
254
243
|
nodeType: string;
|
|
244
|
+
typeBadge: string;
|
|
255
245
|
status: NodeStatus | null;
|
|
256
246
|
attempts: number;
|
|
257
247
|
elapsed: string;
|
|
@@ -277,6 +267,7 @@ function renderCellText(
|
|
|
277
267
|
text: "",
|
|
278
268
|
nodeId: "",
|
|
279
269
|
nodeType: "",
|
|
270
|
+
typeBadge: "",
|
|
280
271
|
status: null,
|
|
281
272
|
attempts: 0,
|
|
282
273
|
elapsed: "",
|
|
@@ -334,6 +325,7 @@ function renderCellText(
|
|
|
334
325
|
text,
|
|
335
326
|
nodeId: sanitizeText(nodeId),
|
|
336
327
|
nodeType,
|
|
328
|
+
typeBadge: node ? nodeTypeBadge(node.nodeType, node.actionExecution) : "? unknown",
|
|
337
329
|
status,
|
|
338
330
|
attempts: count,
|
|
339
331
|
elapsed,
|
|
@@ -729,7 +721,7 @@ function drawNodeBox(
|
|
|
729
721
|
canvas.text(startX, y + 2, `${chars.ml}${horizontal}${chars.mr}`, borderStyle);
|
|
730
722
|
pairedRow(
|
|
731
723
|
y + 3,
|
|
732
|
-
|
|
724
|
+
rendered.typeBadge,
|
|
733
725
|
typeStyle,
|
|
734
726
|
`${STATUS_GLYPHS[status]} ${STATUS_LABELS[status]}`,
|
|
735
727
|
borderStyle,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { WorkflowNodeSnapshot } from "../workflows/types.js";
|
|
2
|
+
|
|
3
|
+
const NODE_TYPE_GLYPHS: Readonly<Record<WorkflowNodeSnapshot["nodeType"], string>> = {
|
|
4
|
+
agent: "●",
|
|
5
|
+
compute: "ƒ",
|
|
6
|
+
notify: "!",
|
|
7
|
+
action: "*",
|
|
8
|
+
checkpoint: "◆",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** A stable one-column glyph for a workflow node or action subtype. */
|
|
12
|
+
export function nodeTypeGlyph(
|
|
13
|
+
nodeType: WorkflowNodeSnapshot["nodeType"],
|
|
14
|
+
actionExecution?: WorkflowNodeSnapshot["actionExecution"],
|
|
15
|
+
): string {
|
|
16
|
+
if (nodeType === "action" && actionExecution === "shell") {
|
|
17
|
+
return "$";
|
|
18
|
+
}
|
|
19
|
+
return NODE_TYPE_GLYPHS[nodeType];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The graph viewer badge keeps the readable type name beside its glyph. */
|
|
23
|
+
export function nodeTypeBadge(
|
|
24
|
+
nodeType: WorkflowNodeSnapshot["nodeType"],
|
|
25
|
+
actionExecution?: WorkflowNodeSnapshot["actionExecution"],
|
|
26
|
+
): string {
|
|
27
|
+
return `${nodeTypeGlyph(nodeType, actionExecution)} ${nodeType}`;
|
|
28
|
+
}
|