@osolmaz/pi-workflows 0.5.0 → 0.5.2
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 +8 -4
- package/dist/extension/index.js +25 -10
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.d.ts +7 -9
- package/dist/extension/widget.js +85 -50
- 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 +77 -0
- package/docs/tui-viewer.md +3 -2
- package/package.json +3 -1
- package/src/extension/index.ts +37 -16
- package/src/extension/widget.ts +110 -56
- package/src/render/graph-render.ts +6 -14
- package/src/render/node-type.ts +28 -0
package/src/extension/widget.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { ansi } from "../render/ansi.js";
|
|
3
|
+
import { formatDuration } from "../render/format.js";
|
|
4
|
+
import { nodeTypeGlyph } from "../render/node-type.js";
|
|
3
5
|
import { sanitizeText } from "../workflows/text.js";
|
|
4
6
|
import type {
|
|
5
7
|
WorkflowDefinitionSnapshot,
|
|
@@ -19,9 +21,10 @@ const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
|
|
|
19
21
|
/**
|
|
20
22
|
* pi renders at most this many widget lines (InteractiveMode.MAX_WIDGET_LINES)
|
|
21
23
|
* and appends its own "(widget truncated)" marker beyond it. Stay inside the
|
|
22
|
-
* budget and choose which
|
|
24
|
+
* budget and choose which node rows to show instead of losing the bottom.
|
|
23
25
|
*/
|
|
24
26
|
const PI_MAX_WIDGET_LINES = 10;
|
|
27
|
+
const MAX_NODE_ERROR_CHARS = 120;
|
|
25
28
|
|
|
26
29
|
export function nodeGlyph(state: WorkflowRunState, nodeId: string): string {
|
|
27
30
|
if (state.currentNode === nodeId) {
|
|
@@ -44,18 +47,16 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
|
|
|
44
47
|
|
|
45
48
|
export type WidgetView = {
|
|
46
49
|
lines: string[];
|
|
47
|
-
/** The clamped first visible
|
|
50
|
+
/** The clamped first visible node row. */
|
|
48
51
|
scroll: number;
|
|
49
|
-
/** Largest useful scroll value; 0 when the whole
|
|
52
|
+
/** Largest useful scroll value; 0 when the whole list fits. */
|
|
50
53
|
maxScroll: number;
|
|
51
54
|
};
|
|
52
55
|
|
|
53
56
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* the active node by default, or at `scroll` when the user scrolled
|
|
58
|
-
* manually. Pure so it can be tested without a TUI.
|
|
57
|
+
* Compact live-progress view for the in-pi widget. It uses one line per node,
|
|
58
|
+
* follows the active node by default, and never returns a line wider than the
|
|
59
|
+
* width supplied by Pi's component renderer.
|
|
59
60
|
*/
|
|
60
61
|
export function buildWidgetView(
|
|
61
62
|
state: WorkflowRunState,
|
|
@@ -63,7 +64,11 @@ export function buildWidgetView(
|
|
|
63
64
|
now: Date = new Date(),
|
|
64
65
|
scroll: number | null = null,
|
|
65
66
|
held = false,
|
|
67
|
+
width = Number.POSITIVE_INFINITY,
|
|
66
68
|
): WidgetView {
|
|
69
|
+
const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
|
|
70
|
+
if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
|
|
71
|
+
|
|
67
72
|
// `held` covers pauses the state cannot see yet: an escape-interrupted
|
|
68
73
|
// step or a pause requested while the current node is still finishing.
|
|
69
74
|
const paused = held || state.paused === true;
|
|
@@ -79,58 +84,122 @@ export function buildWidgetView(
|
|
|
79
84
|
footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
|
|
80
85
|
}
|
|
81
86
|
if (state.status === "waiting" && state.waitingOn) {
|
|
82
|
-
footer.push(` waiting on checkpoint: ${state.waitingOn}`);
|
|
87
|
+
footer.push(` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
91
|
+
const nodes = displayNodeIds(snapshot).map((nodeId) =>
|
|
92
|
+
compactNodeLine(state, snapshot, nodeId, now),
|
|
93
|
+
);
|
|
94
|
+
if (nodes.length === 0) {
|
|
90
95
|
return {
|
|
91
|
-
lines: [header,
|
|
96
|
+
lines: fitLines([header, ...footer], availableWidth),
|
|
92
97
|
scroll: 0,
|
|
93
98
|
maxScroll: 0,
|
|
94
99
|
};
|
|
95
100
|
}
|
|
96
|
-
|
|
101
|
+
|
|
102
|
+
const anchor = scroll ?? compactFocusIndex(state, snapshot);
|
|
103
|
+
const windowed = windowLines(nodes, budget, anchor, scroll !== null);
|
|
104
|
+
const indentation = availableWidth >= 3 ? " " : "";
|
|
97
105
|
return {
|
|
98
|
-
lines:
|
|
106
|
+
lines: fitLines(
|
|
107
|
+
[header, ...windowed.lines.map((line) => `${indentation}${line}`), ...footer],
|
|
108
|
+
availableWidth,
|
|
109
|
+
),
|
|
99
110
|
scroll: windowed.scroll,
|
|
100
111
|
maxScroll: windowed.maxScroll,
|
|
101
112
|
};
|
|
102
113
|
}
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
function compactFocusIndex(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): number {
|
|
116
|
+
const nodeIds = displayNodeIds(snapshot);
|
|
117
|
+
const focused = state.currentNode ?? state.waitingOn;
|
|
118
|
+
if (focused === undefined) return Math.max(0, nodeIds.length - 1);
|
|
119
|
+
const index = nodeIds.indexOf(focused);
|
|
120
|
+
return index === -1 ? Math.max(0, nodeIds.length - 1) : index;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function compactNodeLine(
|
|
106
124
|
state: WorkflowRunState,
|
|
107
125
|
snapshot: WorkflowDefinitionSnapshot,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
126
|
+
nodeId: string,
|
|
127
|
+
now: Date,
|
|
128
|
+
): string {
|
|
129
|
+
const node = snapshot.nodes[nodeId];
|
|
130
|
+
const type = node ? ansi.dim(nodeTypeGlyph(node.nodeType, node.actionExecution)) : "?";
|
|
131
|
+
const segments = nodeRuntimeSegments(state, snapshot, nodeId, now);
|
|
132
|
+
const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
|
|
133
|
+
return `${nodeGlyph(state, nodeId)} ${type} ${sanitizeText(nodeId)}${detail}`;
|
|
111
134
|
}
|
|
112
135
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
136
|
+
function nodeRuntimeSegments(
|
|
137
|
+
state: WorkflowRunState,
|
|
138
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
139
|
+
nodeId: string,
|
|
140
|
+
now: Date,
|
|
141
|
+
): string[] {
|
|
142
|
+
const segments: string[] = [];
|
|
143
|
+
const completedAttempts = state.steps.filter((step) => step.nodeId === nodeId).length;
|
|
144
|
+
const attempts = completedAttempts + (state.currentNode === nodeId ? 1 : 0);
|
|
145
|
+
if (attempts > 1) {
|
|
146
|
+
segments.push(`↻${attempts}`);
|
|
124
147
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
|
|
149
|
+
const result = state.results[nodeId];
|
|
150
|
+
if (state.currentNode === nodeId) {
|
|
151
|
+
if (state.statusDetail) {
|
|
152
|
+
segments.push(sanitizeText(state.statusDetail));
|
|
153
|
+
}
|
|
154
|
+
const elapsed = elapsedSince(state.currentNodeStartedAt, now);
|
|
155
|
+
if (elapsed !== null) {
|
|
156
|
+
segments.push(elapsed);
|
|
157
|
+
}
|
|
158
|
+
return segments;
|
|
128
159
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
160
|
+
|
|
161
|
+
if (state.waitingOn === nodeId) {
|
|
162
|
+
const summary = snapshot.nodes[nodeId]?.summary;
|
|
163
|
+
segments.push(summary ? sanitizeText(summary) : "waiting");
|
|
164
|
+
return segments;
|
|
132
165
|
}
|
|
133
|
-
|
|
166
|
+
|
|
167
|
+
if (!result) {
|
|
168
|
+
return segments;
|
|
169
|
+
}
|
|
170
|
+
if (result.outcome !== "ok") {
|
|
171
|
+
segments.push(
|
|
172
|
+
result.error
|
|
173
|
+
? truncate(sanitizeText(result.error), MAX_NODE_ERROR_CHARS)
|
|
174
|
+
: result.outcome.replaceAll("_", " "),
|
|
175
|
+
);
|
|
176
|
+
return segments;
|
|
177
|
+
}
|
|
178
|
+
if (Number.isFinite(result.durationMs)) {
|
|
179
|
+
segments.push(formatDuration(result.durationMs));
|
|
180
|
+
}
|
|
181
|
+
return segments;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function elapsedSince(startedAt: string | undefined, now: Date): string | null {
|
|
185
|
+
if (!startedAt) return null;
|
|
186
|
+
const started = Date.parse(startedAt);
|
|
187
|
+
if (!Number.isFinite(started)) return null;
|
|
188
|
+
return formatDuration(Math.max(0, now.getTime() - started));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function fitLines(lines: string[], width: number): string[] {
|
|
192
|
+
if (!Number.isFinite(width)) return lines;
|
|
193
|
+
return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Compact line view following the active node. */
|
|
197
|
+
export function buildWidgetLines(
|
|
198
|
+
state: WorkflowRunState,
|
|
199
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
200
|
+
now: Date = new Date(),
|
|
201
|
+
): string[] {
|
|
202
|
+
return buildWidgetView(state, snapshot, now).lines;
|
|
134
203
|
}
|
|
135
204
|
|
|
136
205
|
/**
|
|
@@ -147,8 +216,6 @@ function windowLines(
|
|
|
147
216
|
if (lines.length <= budget) {
|
|
148
217
|
return { lines, scroll: 0, maxScroll: 0 };
|
|
149
218
|
}
|
|
150
|
-
// Reserve one combined overflow row, leaving seven rows for a complete
|
|
151
|
-
// full card even when the widget also has an error footer.
|
|
152
219
|
const inner = Math.max(1, budget - 1);
|
|
153
220
|
const start = clampStart(anchor, inner, lines.length, anchorIsStart);
|
|
154
221
|
const end = start + inner;
|
|
@@ -167,19 +234,6 @@ function clampStart(anchor: number, inner: number, total: number, anchorIsStart:
|
|
|
167
234
|
return Math.max(0, Math.min(start, total - inner));
|
|
168
235
|
}
|
|
169
236
|
|
|
170
|
-
function compactNodeStrip(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): string {
|
|
171
|
-
return displayNodeIds(snapshot)
|
|
172
|
-
.map((nodeId) => {
|
|
173
|
-
const marker = nodeGlyph(state, nodeId);
|
|
174
|
-
const detail =
|
|
175
|
-
state.currentNode === nodeId && state.statusDetail
|
|
176
|
-
? ` (${sanitizeText(state.statusDetail)})`
|
|
177
|
-
: "";
|
|
178
|
-
return `${marker} ${nodeId}${detail}`;
|
|
179
|
-
})
|
|
180
|
-
.join(" ");
|
|
181
|
-
}
|
|
182
|
-
|
|
183
237
|
function truncate(text: string, maxLength: number): string {
|
|
184
238
|
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
185
239
|
}
|
|
@@ -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
|
+
}
|