@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.
@@ -1,5 +1,7 @@
1
- import { ansi, stripAnsi } from "../render/ansi.js";
2
- import { renderGraphLines } from "../render/graph-render.js";
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 graph rows to show instead of losing the bottom.
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 graph row; feed back in to scroll relatively. */
50
+ /** The clamped first visible node row. */
48
51
  scroll: number;
49
- /** Largest useful scroll value; 0 when the whole graph fits. */
52
+ /** Largest useful scroll value; 0 when the whole list fits. */
50
53
  maxScroll: number;
51
54
  };
52
55
 
53
56
  /**
54
- * Live-progress view for the in-pi widget: a header plus the same boxed
55
- * graph the standalone viewer draws. When the graph is taller than pi's
56
- * widget budget, a window is shown with ↑/↓ overflow markers — centered on
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 graph = renderGraphLines({ state, snapshot }, state.steps.length - 1, now, {
87
- nodeStyle: "box",
88
- });
89
- if (graph.length === 0) {
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, ` ${compactNodeStrip(state, snapshot)}`, ...footer],
96
+ lines: fitLines([header, ...footer], availableWidth),
92
97
  scroll: 0,
93
98
  maxScroll: 0,
94
99
  };
95
100
  }
96
- const windowed = windowLines(graph, budget, scroll ?? focusLine(graph, state), scroll !== null);
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: [header, ...windowed.lines.map((line) => ` ${line}`), ...footer],
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
- /** Back-compatible line view following the active node. */
105
- export function buildWidgetLines(
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
- now: Date = new Date(),
109
- ): string[] {
110
- return buildWidgetView(state, snapshot, now).lines;
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
- /** The graph row the window should center on: the active or waiting node. */
114
- function focusLine(graph: string[], state: WorkflowRunState): number {
115
- const active = graph.findIndex((line) => stripAnsi(line).includes("◐"));
116
- const focus =
117
- active !== -1
118
- ? active
119
- : state.waitingOn
120
- ? graph.findIndex((line) => stripAnsi(line).includes(state.waitingOn as string))
121
- : -1;
122
- if (focus === -1) {
123
- return graph.length - 1;
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
- let top = focus;
126
- while (top > 0 && !/[┌┏]/u.test(stripAnsi(graph[top] ?? ""))) {
127
- top -= 1;
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
- let bottom = focus;
130
- while (bottom + 1 < graph.length && !/[└┗]/u.test(stripAnsi(graph[bottom] ?? ""))) {
131
- bottom += 1;
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
- return Math.floor((top + bottom) / 2);
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
- nodeTypeBadge(rendered.nodeType),
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
+ }