@osolmaz/pi-workflows 0.5.1 → 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 +8 -14
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.d.ts +7 -12
- package/dist/extension/widget.js +66 -66
- 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 +23 -10
- package/docs/tui-viewer.md +3 -2
- package/package.json +1 -1
- package/src/extension/index.ts +9 -14
- package/src/extension/widget.ts +81 -90
- package/src/render/graph-render.ts +6 -14
- package/src/render/node-type.ts +28 -0
package/src/extension/widget.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { truncateToWidth
|
|
2
|
-
import { ansi
|
|
3
|
-
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";
|
|
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,29 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
|
|
|
43
45
|
return Object.keys(snapshot.nodes);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
export type WidgetLayout = "graph" | "compact";
|
|
47
|
-
|
|
48
|
-
export type WidgetScrollState = Record<WidgetLayout, number | null>;
|
|
49
|
-
|
|
50
48
|
export type WidgetView = {
|
|
51
49
|
lines: string[];
|
|
52
|
-
|
|
53
|
-
/** The clamped first visible row for the selected layout. */
|
|
50
|
+
/** The clamped first visible node row. */
|
|
54
51
|
scroll: number;
|
|
55
|
-
/** Largest useful scroll value; 0 when the whole
|
|
52
|
+
/** Largest useful scroll value; 0 when the whole list fits. */
|
|
56
53
|
maxScroll: number;
|
|
57
54
|
};
|
|
58
55
|
|
|
59
56
|
/**
|
|
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.
|
|
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.
|
|
65
60
|
*/
|
|
66
61
|
export function buildWidgetView(
|
|
67
62
|
state: WorkflowRunState,
|
|
68
63
|
snapshot: WorkflowDefinitionSnapshot,
|
|
69
64
|
now: Date = new Date(),
|
|
70
|
-
scroll:
|
|
65
|
+
scroll: number | null = null,
|
|
71
66
|
held = false,
|
|
72
67
|
width = Number.POSITIVE_INFINITY,
|
|
73
68
|
): WidgetView {
|
|
74
69
|
const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
|
|
75
|
-
if (availableWidth === 0) return { lines: [],
|
|
70
|
+
if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
|
|
76
71
|
|
|
77
72
|
// `held` covers pauses the state cannot see yet: an escape-interrupted
|
|
78
73
|
// step or a pause requested while the current node is still finishing.
|
|
@@ -93,58 +88,25 @@ export function buildWidgetView(
|
|
|
93
88
|
}
|
|
94
89
|
|
|
95
90
|
const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
|
|
96
|
-
const
|
|
97
|
-
|
|
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,
|
|
106
|
-
);
|
|
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
|
-
}
|
|
117
|
-
|
|
118
|
-
return compactWidgetView(state, snapshot, header, footer, budget, availableWidth, scroll.compact);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function compactWidgetView(
|
|
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));
|
|
91
|
+
const nodes = displayNodeIds(snapshot).map((nodeId) =>
|
|
92
|
+
compactNodeLine(state, snapshot, nodeId, now),
|
|
93
|
+
);
|
|
131
94
|
if (nodes.length === 0) {
|
|
132
95
|
return {
|
|
133
|
-
lines: fitLines([header, ...footer],
|
|
134
|
-
layout: "compact",
|
|
96
|
+
lines: fitLines([header, ...footer], availableWidth),
|
|
135
97
|
scroll: 0,
|
|
136
98
|
maxScroll: 0,
|
|
137
99
|
};
|
|
138
100
|
}
|
|
101
|
+
|
|
139
102
|
const anchor = scroll ?? compactFocusIndex(state, snapshot);
|
|
140
103
|
const windowed = windowLines(nodes, budget, anchor, scroll !== null);
|
|
141
|
-
const indentation =
|
|
104
|
+
const indentation = availableWidth >= 3 ? " " : "";
|
|
142
105
|
return {
|
|
143
106
|
lines: fitLines(
|
|
144
107
|
[header, ...windowed.lines.map((line) => `${indentation}${line}`), ...footer],
|
|
145
|
-
|
|
108
|
+
availableWidth,
|
|
146
109
|
),
|
|
147
|
-
layout: "compact",
|
|
148
110
|
scroll: windowed.scroll,
|
|
149
111
|
maxScroll: windowed.maxScroll,
|
|
150
112
|
};
|
|
@@ -162,14 +124,68 @@ function compactNodeLine(
|
|
|
162
124
|
state: WorkflowRunState,
|
|
163
125
|
snapshot: WorkflowDefinitionSnapshot,
|
|
164
126
|
nodeId: string,
|
|
127
|
+
now: Date,
|
|
165
128
|
): string {
|
|
166
129
|
const node = snapshot.nodes[nodeId];
|
|
167
|
-
const type = node
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
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}`);
|
|
147
|
+
}
|
|
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;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (state.waitingOn === nodeId) {
|
|
162
|
+
const summary = snapshot.nodes[nodeId]?.summary;
|
|
163
|
+
segments.push(summary ? sanitizeText(summary) : "waiting");
|
|
164
|
+
return segments;
|
|
165
|
+
}
|
|
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));
|
|
173
189
|
}
|
|
174
190
|
|
|
175
191
|
function fitLines(lines: string[], width: number): string[] {
|
|
@@ -177,7 +193,7 @@ function fitLines(lines: string[], width: number): string[] {
|
|
|
177
193
|
return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
|
|
178
194
|
}
|
|
179
195
|
|
|
180
|
-
/**
|
|
196
|
+
/** Compact line view following the active node. */
|
|
181
197
|
export function buildWidgetLines(
|
|
182
198
|
state: WorkflowRunState,
|
|
183
199
|
snapshot: WorkflowDefinitionSnapshot,
|
|
@@ -186,29 +202,6 @@ export function buildWidgetLines(
|
|
|
186
202
|
return buildWidgetView(state, snapshot, now).lines;
|
|
187
203
|
}
|
|
188
204
|
|
|
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
205
|
/**
|
|
213
206
|
* Slice `lines` to at most `budget` rows, marking hidden rows at either end.
|
|
214
207
|
* Markers count against the budget. `anchor` is a row to center on (follow
|
|
@@ -223,8 +216,6 @@ function windowLines(
|
|
|
223
216
|
if (lines.length <= budget) {
|
|
224
217
|
return { lines, scroll: 0, maxScroll: 0 };
|
|
225
218
|
}
|
|
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
219
|
const inner = Math.max(1, budget - 1);
|
|
229
220
|
const start = clampStart(anchor, inner, lines.length, anchorIsStart);
|
|
230
221
|
const end = start + inner;
|
|
@@ -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
|
+
}
|