@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osolmaz/pi-workflows",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Workflow and controller runtime with a live terminal viewer for the pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -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, type WidgetLayout, type WidgetScrollState } from "./widget.js";
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 graph row, set by shift+↑/↓ and reset on step advance.
316
+ // first visible node row, set by shift+↑/↓ and reset on step advance.
317
317
  let widgetSource: WidgetSource | null = null;
318
- let widgetScroll: WidgetScrollState = { graph: null, compact: null };
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[view.layout] !== null) {
395
- widgetScroll[view.layout] = view.scroll;
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, () => ({ render, invalidate() {} }));
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 = { graph: null, compact: null };
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 = { graph: null, compact: null };
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
- if (widgetLayout === null) return;
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 = { graph: null, compact: null };
1817
- widgetLayout = null;
1816
+ widgetScroll = null;
1818
1817
  });
1819
1818
  }
1820
1819
 
@@ -1,6 +1,7 @@
1
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { ansi, stripAnsi } from "../render/ansi.js";
3
- import { renderGraphLines } from "../render/graph-render.js";
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 graph rows to show instead of losing the bottom.
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 WidgetLayout = "graph" | "compact";
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
- layout: WidgetLayout;
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 graph fits. */
54
+ /** Largest useful scroll value; 0 when the whole list fits. */
56
55
  maxScroll: number;
57
56
  };
58
57
 
59
58
  /**
60
- * Live-progress view for the in-pi widget: a header plus the same boxed
61
- * graph the standalone viewer draws. When the graph is taller than pi's
62
- * widget budget, a window is shown with ↑/↓ overflow markers — centered on
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: WidgetScrollState = { graph: null, compact: null },
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: [], layout: "compact", scroll: 0, maxScroll: 0 };
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 header = `${glyph} workflow ${sanitizeText(state.workflowName)}${title} [${statusText}]`;
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(` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`);
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
- 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));
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], width),
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 = width >= 3 ? " " : "";
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
- width,
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 type = node?.nodeType === undefined ? "" : ` · ${node.nodeType}`;
168
- const detail =
169
- state.currentNode === nodeId && state.statusDetail
170
- ? ` · ${sanitizeText(state.statusDetail)}`
171
- : "";
172
- return `${nodeGlyph(state, nodeId)} ${sanitizeText(nodeId)}${type}${detail}`;
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
- /** Back-compatible line view following the active node. */
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(ansi.dim(`${directions} more · shift+↑/↓ scroll`));
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
- 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
+ }