@osolmaz/pi-workflows 0.1.0

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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/extension/executor.d.ts +58 -0
  4. package/dist/extension/executor.js +201 -0
  5. package/dist/extension/executor.js.map +1 -0
  6. package/dist/extension/index.d.ts +17 -0
  7. package/dist/extension/index.js +504 -0
  8. package/dist/extension/index.js.map +1 -0
  9. package/dist/extension/widget.d.ts +21 -0
  10. package/dist/extension/widget.js +142 -0
  11. package/dist/extension/widget.js.map +1 -0
  12. package/dist/render/ansi.d.ts +16 -0
  13. package/dist/render/ansi.js +42 -0
  14. package/dist/render/ansi.js.map +1 -0
  15. package/dist/render/canvas.d.ts +40 -0
  16. package/dist/render/canvas.js +177 -0
  17. package/dist/render/canvas.js.map +1 -0
  18. package/dist/render/format.d.ts +3 -0
  19. package/dist/render/format.js +17 -0
  20. package/dist/render/format.js.map +1 -0
  21. package/dist/render/graph-render.d.ts +22 -0
  22. package/dist/render/graph-render.js +520 -0
  23. package/dist/render/graph-render.js.map +1 -0
  24. package/dist/render/graph.d.ts +46 -0
  25. package/dist/render/graph.js +272 -0
  26. package/dist/render/graph.js.map +1 -0
  27. package/dist/viewer/cli.d.ts +10 -0
  28. package/dist/viewer/cli.js +132 -0
  29. package/dist/viewer/cli.js.map +1 -0
  30. package/dist/viewer/render.d.ts +19 -0
  31. package/dist/viewer/render.js +162 -0
  32. package/dist/viewer/render.js.map +1 -0
  33. package/dist/viewer/tui.d.ts +11 -0
  34. package/dist/viewer/tui.js +140 -0
  35. package/dist/viewer/tui.js.map +1 -0
  36. package/dist/viewer/watch.d.ts +9 -0
  37. package/dist/viewer/watch.js +46 -0
  38. package/dist/viewer/watch.js.map +1 -0
  39. package/dist/workflows/decision.d.ts +25 -0
  40. package/dist/workflows/decision.js +96 -0
  41. package/dist/workflows/decision.js.map +1 -0
  42. package/dist/workflows/definition.d.ts +9 -0
  43. package/dist/workflows/definition.js +61 -0
  44. package/dist/workflows/definition.js.map +1 -0
  45. package/dist/workflows/engine.d.ts +65 -0
  46. package/dist/workflows/engine.js +574 -0
  47. package/dist/workflows/engine.js.map +1 -0
  48. package/dist/workflows/errors.d.ts +9 -0
  49. package/dist/workflows/errors.js +24 -0
  50. package/dist/workflows/errors.js.map +1 -0
  51. package/dist/workflows/graph.d.ts +17 -0
  52. package/dist/workflows/graph.js +127 -0
  53. package/dist/workflows/graph.js.map +1 -0
  54. package/dist/workflows/index.d.ts +11 -0
  55. package/dist/workflows/index.js +11 -0
  56. package/dist/workflows/index.js.map +1 -0
  57. package/dist/workflows/json.d.ts +14 -0
  58. package/dist/workflows/json.js +134 -0
  59. package/dist/workflows/json.js.map +1 -0
  60. package/dist/workflows/loader.d.ts +28 -0
  61. package/dist/workflows/loader.js +94 -0
  62. package/dist/workflows/loader.js.map +1 -0
  63. package/dist/workflows/schema.d.ts +7 -0
  64. package/dist/workflows/schema.js +176 -0
  65. package/dist/workflows/schema.js.map +1 -0
  66. package/dist/workflows/shell.d.ts +9 -0
  67. package/dist/workflows/shell.js +177 -0
  68. package/dist/workflows/shell.js.map +1 -0
  69. package/dist/workflows/store.d.ts +35 -0
  70. package/dist/workflows/store.js +181 -0
  71. package/dist/workflows/store.js.map +1 -0
  72. package/dist/workflows/text.d.ts +10 -0
  73. package/dist/workflows/text.js +32 -0
  74. package/dist/workflows/text.js.map +1 -0
  75. package/dist/workflows/types.d.ts +280 -0
  76. package/dist/workflows/types.js +2 -0
  77. package/dist/workflows/types.js.map +1 -0
  78. package/docs/development.md +130 -0
  79. package/docs/run-bundles.md +114 -0
  80. package/docs/workflows.md +311 -0
  81. package/examples/workflows/autoimplement.workflow.ts +92 -0
  82. package/examples/workflows/autoresearch.workflow.ts +139 -0
  83. package/examples/workflows/branch.workflow.ts +63 -0
  84. package/examples/workflows/echo.workflow.ts +23 -0
  85. package/examples/workflows/elegant-solution.workflow.ts +95 -0
  86. package/examples/workflows/shell.workflow.ts +31 -0
  87. package/examples/workflows/two-turn.workflow.ts +64 -0
  88. package/package.json +80 -0
  89. package/src/extension/executor.ts +251 -0
  90. package/src/extension/index.ts +627 -0
  91. package/src/extension/widget.ts +183 -0
  92. package/src/render/ansi.ts +47 -0
  93. package/src/render/canvas.ts +196 -0
  94. package/src/render/format.ts +19 -0
  95. package/src/render/graph-render.ts +738 -0
  96. package/src/render/graph.ts +341 -0
  97. package/src/viewer/cli.ts +150 -0
  98. package/src/viewer/render.ts +236 -0
  99. package/src/viewer/tui.ts +159 -0
  100. package/src/viewer/watch.ts +55 -0
  101. package/src/workflows/decision.ts +127 -0
  102. package/src/workflows/definition.ts +104 -0
  103. package/src/workflows/engine.ts +793 -0
  104. package/src/workflows/errors.ts +27 -0
  105. package/src/workflows/graph.ts +161 -0
  106. package/src/workflows/index.ts +76 -0
  107. package/src/workflows/json.ts +155 -0
  108. package/src/workflows/loader.ts +123 -0
  109. package/src/workflows/schema.ts +218 -0
  110. package/src/workflows/shell.ts +199 -0
  111. package/src/workflows/store.ts +234 -0
  112. package/src/workflows/text.ts +34 -0
  113. package/src/workflows/types.ts +318 -0
@@ -0,0 +1,738 @@
1
+ import type {
2
+ WorkflowDefinitionSnapshot,
3
+ WorkflowRunState,
4
+ WorkflowStepRecord,
5
+ } from "../workflows/types.js";
6
+ import { ansi, sanitizeText } from "./ansi.js";
7
+ import { CharCanvas, type CanvasStyle } from "./canvas.js";
8
+ import { formatDuration } from "./format.js";
9
+ import {
10
+ layoutGraph,
11
+ type GraphCell,
12
+ type GraphEdge,
13
+ type GraphLayout,
14
+ type GraphSegment,
15
+ } from "./graph.js";
16
+
17
+ /**
18
+ * Renders the workflow DAG as text, mirroring the acpx replay viewer's graph
19
+ * pane: statuses derive from the steps visible up to the selected step, taken
20
+ * transitions highlight, switch branches carry case labels, and loop edges
21
+ * route through a right-hand gutter.
22
+ */
23
+
24
+ /** Everything the graph needs; a LoadedRunBundle satisfies this shape. */
25
+ export type GraphView = {
26
+ state: WorkflowRunState;
27
+ snapshot: WorkflowDefinitionSnapshot | null;
28
+ };
29
+
30
+ type NodeStatus = "completed" | "failed" | "active" | "waiting" | "queued" | "cancelled";
31
+
32
+ const STATUS_GLYPHS: Record<NodeStatus, string> = {
33
+ completed: "✓",
34
+ failed: "✗",
35
+ active: "◐",
36
+ waiting: "⏸",
37
+ cancelled: "~",
38
+ queued: "·",
39
+ };
40
+
41
+ const STATUS_STYLES: Record<NodeStatus, CanvasStyle> = {
42
+ completed: "ok",
43
+ failed: "fail",
44
+ active: "active",
45
+ waiting: "warn",
46
+ cancelled: "warn",
47
+ queued: "dim",
48
+ };
49
+
50
+ const CELL_GAP = 4;
51
+ const GUTTER_GAP = 2;
52
+
53
+ /** How node cells are drawn: single text lines or bordered boxes. */
54
+ export type GraphNodeStyle = "line" | "box";
55
+
56
+ export type GraphRenderOptions = {
57
+ nodeStyle?: GraphNodeStyle;
58
+ };
59
+
60
+ /** Rows a node cell occupies: boxes add a border row above and below. */
61
+ function cellHeight(nodeStyle: GraphNodeStyle): number {
62
+ return nodeStyle === "box" ? 3 : 1;
63
+ }
64
+
65
+ function paint(text: string, style: CanvasStyle): string {
66
+ switch (style) {
67
+ case "taken":
68
+ case "ok":
69
+ return ansi.green(text);
70
+ case "active":
71
+ return ansi.cyan(text);
72
+ case "back":
73
+ case "warn":
74
+ return ansi.yellow(text);
75
+ case "fail":
76
+ return ansi.red(text);
77
+ case "dim":
78
+ return ansi.dim(text);
79
+ default:
80
+ return text;
81
+ }
82
+ }
83
+
84
+ function latestVisibleAttempt(
85
+ steps: WorkflowStepRecord[],
86
+ nodeId: string,
87
+ ): WorkflowStepRecord | undefined {
88
+ for (let index = steps.length - 1; index >= 0; index -= 1) {
89
+ if (steps[index]?.nodeId === nodeId) {
90
+ return steps[index];
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function deriveNodeStatus(
97
+ view: GraphView,
98
+ nodeId: string,
99
+ visibleSteps: WorkflowStepRecord[],
100
+ atLatestStep: boolean,
101
+ ): NodeStatus {
102
+ const state = view.state;
103
+ if (atLatestStep && state.currentNode === nodeId) {
104
+ return "active";
105
+ }
106
+ if (atLatestStep && state.waitingOn === nodeId) {
107
+ return "waiting";
108
+ }
109
+ const attempt = latestVisibleAttempt(visibleSteps, nodeId);
110
+ if (!attempt) {
111
+ return "queued";
112
+ }
113
+ // While scrubbing, the selected step's node reads as the active position.
114
+ if (!atLatestStep && visibleSteps.at(-1)?.nodeId === nodeId) {
115
+ return "active";
116
+ }
117
+ switch (attempt.outcome) {
118
+ case "ok":
119
+ return "completed";
120
+ case "cancelled":
121
+ return "cancelled";
122
+ default:
123
+ return "failed";
124
+ }
125
+ }
126
+
127
+ type RenderedCell = {
128
+ cell: GraphCell;
129
+ text: string;
130
+ status: NodeStatus | null;
131
+ width: number;
132
+ };
133
+
134
+ function renderCellText(
135
+ view: GraphView,
136
+ cell: GraphCell,
137
+ visibleSteps: WorkflowStepRecord[],
138
+ atLatestStep: boolean,
139
+ now: Date,
140
+ nodeStyle: GraphNodeStyle,
141
+ ): RenderedCell {
142
+ if (cell.kind === "virtual") {
143
+ return { cell, text: "", status: null, width: 1 };
144
+ }
145
+ const state = view.state;
146
+ const nodeId = cell.nodeId;
147
+ const status = deriveNodeStatus(view, nodeId, visibleSteps, atLatestStep);
148
+ const nodeType = view.snapshot?.nodes[nodeId]?.nodeType ?? "?";
149
+ const attempt = latestVisibleAttempt(visibleSteps, nodeId);
150
+ const attempts = visibleSteps.filter((step) => step.nodeId === nodeId).length;
151
+ const parts = [`${nodeId} [${nodeType}]`];
152
+ if (atLatestStep && state.currentNode === nodeId) {
153
+ const startedAt = state.currentNodeStartedAt
154
+ ? Date.parse(state.currentNodeStartedAt)
155
+ : now.getTime();
156
+ parts.push(`running ${formatDuration(now.getTime() - startedAt)}`);
157
+ if (state.statusDetail) {
158
+ // statusDetail can be set by workflow authors; keep terminal-safe.
159
+ parts.push(`· ${sanitizeText(state.statusDetail)}`);
160
+ }
161
+ } else if (attempt) {
162
+ const durationMs = Date.parse(attempt.finishedAt) - Date.parse(attempt.startedAt);
163
+ parts.push(formatDuration(durationMs));
164
+ }
165
+ if (attempts > 1) {
166
+ parts.push(`×${attempts}`);
167
+ }
168
+ const text = parts.join(" ");
169
+ // Width includes the status glyph and the space after it; boxes add a
170
+ // border and one padding column on each side.
171
+ const contentWidth = text.length + 2;
172
+ return { cell, text, status, width: nodeStyle === "box" ? contentWidth + 4 : contentWidth };
173
+ }
174
+
175
+ type RankGeometry = {
176
+ cells: RenderedCell[];
177
+ centers: number[];
178
+ };
179
+
180
+ type PlacedRank = RankGeometry & { y: number };
181
+
182
+ /** A strip segment with final pixel geometry and its assigned track row. */
183
+ type GeomSegment = {
184
+ edgeId: string;
185
+ label?: string | undefined;
186
+ fromX: number;
187
+ toX: number;
188
+ track: number;
189
+ targetIsNode: boolean;
190
+ };
191
+
192
+ type StripGeometry = {
193
+ segments: GeomSegment[];
194
+ trackCount: number;
195
+ hasLabels: boolean;
196
+ /** True when every segment is an unlabeled vertical line. */
197
+ straight: boolean;
198
+ };
199
+
200
+ /** Transitions actually taken between the visible steps, as "from->to". */
201
+ function takenTransitions(visibleSteps: WorkflowStepRecord[]): Set<string> {
202
+ const transitions = new Set<string>();
203
+ for (let index = 1; index < visibleSteps.length; index += 1) {
204
+ transitions.add(`${visibleSteps[index - 1]?.nodeId}->${visibleSteps[index]?.nodeId}`);
205
+ }
206
+ return transitions;
207
+ }
208
+
209
+ /**
210
+ * Render the graph pane. `selectedStepIndex` scrubs the replay position;
211
+ * pass `steps.length - 1` (or larger) for the live view.
212
+ */
213
+ export function renderGraphLines(
214
+ view: GraphView,
215
+ selectedStepIndex: number,
216
+ now: Date = new Date(),
217
+ options: GraphRenderOptions = {},
218
+ ): string[] {
219
+ const snapshot = view.snapshot;
220
+ if (!snapshot) {
221
+ return [];
222
+ }
223
+ const nodeStyle = options.nodeStyle ?? "line";
224
+ const layout = layoutGraph(snapshot);
225
+ const steps = view.state.steps;
226
+ const boundedIndex = Math.min(Math.max(selectedStepIndex, -1), steps.length - 1);
227
+ const atLatestStep = boundedIndex >= steps.length - 1;
228
+ const visibleSteps = steps.slice(0, boundedIndex + 1);
229
+ const transitions = takenTransitions(visibleSteps);
230
+ const activePair = derivePairInFlight(view, visibleSteps, atLatestStep);
231
+
232
+ const rendered = layout.ranks.map((rank) =>
233
+ rank.map((cell) => renderCellText(view, cell, visibleSteps, atLatestStep, now, nodeStyle)),
234
+ );
235
+
236
+ // Column positions: pack cells left to right per rank, then center every
237
+ // rank against the widest one so vertical edges stay near-vertical.
238
+ const rankWidths = rendered.map(
239
+ (cells) =>
240
+ cells.reduce((sum, cell) => sum + cell.width, 0) + Math.max(0, cells.length - 1) * CELL_GAP,
241
+ );
242
+ const graphWidth = Math.max(0, ...rankWidths);
243
+ const geometry: RankGeometry[] = rendered.map((cells, rankIndex) => {
244
+ const centers: number[] = [];
245
+ let x = Math.floor((graphWidth - (rankWidths[rankIndex] ?? 0)) / 2);
246
+ for (const cell of cells) {
247
+ // Single-cell ranks share the exact graph center so chains render as
248
+ // straight vertical lines instead of one-column elbows.
249
+ centers.push(
250
+ cells.length === 1 ? Math.floor(graphWidth / 2) : x + Math.floor(cell.width / 2),
251
+ );
252
+ x += cell.width + CELL_GAP;
253
+ }
254
+ return { cells, centers };
255
+ });
256
+
257
+ // Horizontal edge geometry (exit/entry columns, pixel-space track rows) is
258
+ // fully decided before vertical placement, so row budgeting is exact.
259
+ const strips = geometry.map((_rank, rankIndex) =>
260
+ computeStripGeometry(layout, rankIndex, geometry),
261
+ );
262
+
263
+ const lanes = backEdgeLanes(layout);
264
+ const placed: PlacedRank[] = [];
265
+ // Entry lanes above the first rank need an arrow row of their own.
266
+ const topLanes = lanes.above(0).length;
267
+ let y = topLanes > 0 ? topLanes + 1 : 0;
268
+ for (const [rankIndex, rank] of geometry.entries()) {
269
+ placed.push({ ...rank, y });
270
+ y +=
271
+ cellHeight(nodeStyle) +
272
+ lanes.below(rankIndex).length +
273
+ gapRows(strips[rankIndex] as StripGeometry, rankIndex, layout.ranks.length) +
274
+ lanes.above(rankIndex + 1).length;
275
+ }
276
+
277
+ const canvas = new CharCanvas();
278
+ drawNodes(canvas, placed, layout, transitions, nodeStyle);
279
+ const labels = drawSegments(
280
+ canvas,
281
+ placed,
282
+ strips,
283
+ layout,
284
+ transitions,
285
+ activePair,
286
+ graphWidth,
287
+ nodeStyle,
288
+ lanes,
289
+ );
290
+ drawBackEdges(canvas, placed, layout, transitions, graphWidth, nodeStyle, lanes);
291
+ // Labels go on last, once every line is on the canvas: placement can then
292
+ // guarantee no later stroke crosses through a label.
293
+ for (const label of labels) {
294
+ drawSegmentLabel(canvas, label);
295
+ }
296
+ return canvas.render(paint);
297
+ }
298
+
299
+ /**
300
+ * Back edges route through dedicated lane rows: one below their source rank
301
+ * (box bottom to the right gutter) and one above their target rank (gutter
302
+ * to the target's top). Dedicated rows mean a loop line can never collide
303
+ * with node cells or other horizontal runs, no matter where the loop's
304
+ * endpoints sit in their ranks; forward edges merely cross them vertically.
305
+ */
306
+ type BackEdgeLanes = {
307
+ edges: GraphEdge[];
308
+ below: (rank: number) => GraphEdge[];
309
+ above: (rank: number) => GraphEdge[];
310
+ };
311
+
312
+ function backEdgeLanes(layout: GraphLayout): BackEdgeLanes {
313
+ const edges = layout.edges.filter((edge) => edge.isBackEdge);
314
+ return {
315
+ edges,
316
+ below: (rank) => edges.filter((edge) => layout.rankOfNode.get(edge.from) === rank),
317
+ above: (rank) => edges.filter((edge) => layout.rankOfNode.get(edge.to) === rank),
318
+ };
319
+ }
320
+
321
+ /** The transition currently in flight, drawn in the active style. */
322
+ function derivePairInFlight(
323
+ view: GraphView,
324
+ visibleSteps: WorkflowStepRecord[],
325
+ atLatestStep: boolean,
326
+ ): string | null {
327
+ const state = view.state;
328
+ if (atLatestStep) {
329
+ if (state.status === "running" && state.currentNode && visibleSteps.length > 0) {
330
+ return `${visibleSteps.at(-1)?.nodeId}->${state.currentNode}`;
331
+ }
332
+ return null;
333
+ }
334
+ if (visibleSteps.length >= 2) {
335
+ return `${visibleSteps.at(-2)?.nodeId}->${visibleSteps.at(-1)?.nodeId}`;
336
+ }
337
+ return null;
338
+ }
339
+
340
+ /** Rows between rank r's cell rows and rank r+1's cell rows. */
341
+ function gapRows(strip: StripGeometry, rank: number, rankCount: number): number {
342
+ if (strip.segments.length === 0) {
343
+ return rank < rankCount - 1 ? 1 : 0;
344
+ }
345
+ // Straight unlabeled strips need no track rows: one line row, one arrow row.
346
+ if (strip.straight) {
347
+ return 2;
348
+ }
349
+ // Labelled strips reserve one extra row below the tracks so labels that do
350
+ // not fit on their horizontal run always have a collision-free home.
351
+ return 2 + strip.trackCount + (strip.hasLabels ? 1 : 0);
352
+ }
353
+
354
+ /**
355
+ * Resolve a strip (all segments between rank r and rank r+1) to final pixel
356
+ * geometry: exit and entry columns, and a horizontal track row per segment.
357
+ *
358
+ * Two rules make the drawing collision-free by construction. First, when
359
+ * several edges leave or enter one cell, they fan out over separate columns
360
+ * (ordered by the far end so lines inside a fan never cross), so corner
361
+ * characters cannot merge into fake junctions. Second, tracks are assigned
362
+ * from the final pixel spans, so two horizontal runs share a row only when
363
+ * they cannot touch, corners included.
364
+ */
365
+ function computeStripGeometry(
366
+ layout: GraphLayout,
367
+ rank: number,
368
+ geometry: RankGeometry[],
369
+ ): StripGeometry {
370
+ const strip = layout.segments.filter((segment) => segment.rank === rank);
371
+ const top = geometry[rank];
372
+ const bottom = geometry[rank + 1];
373
+ if (strip.length === 0 || !top || !bottom) {
374
+ return { segments: [], trackCount: 1, hasLabels: false, straight: true };
375
+ }
376
+ const exitOffsets = fanOffsets(strip, "from", top, bottom);
377
+ const entryOffsets = fanOffsets(strip, "to", top, bottom);
378
+ const resolved = strip.map((segment) => {
379
+ const fromX =
380
+ (top.centers[segment.fromCell] as number) + (exitOffsets.get(segment.edgeId) ?? 0);
381
+ let toX = (bottom.centers[segment.toCell] as number) + (entryOffsets.get(segment.edgeId) ?? 0);
382
+ const targetIsNode = (bottom.cells[segment.toCell] as RenderedCell).cell.kind === "node";
383
+ // A one-column jog reads as noise; draw it straight into the target,
384
+ // whose rendered cell is wide enough to absorb the offset. Virtual
385
+ // cells are exactly one column wide, so they must never be snapped.
386
+ if (targetIsNode && Math.abs(toX - fromX) <= 1) {
387
+ toX = fromX;
388
+ }
389
+ return { edgeId: segment.edgeId, label: segment.label, fromX, toX, targetIsNode };
390
+ });
391
+
392
+ // First-fit track assignment over pixel spans; straight unlabeled
393
+ // segments draw a plain vertical line and need no track row.
394
+ const segments: GeomSegment[] = [];
395
+ const trackRanges: [number, number][][] = [];
396
+ for (const segment of resolved.toSorted((a, b) => a.fromX - b.fromX)) {
397
+ let track = 0;
398
+ if (segment.fromX !== segment.toX || segment.label !== undefined) {
399
+ const span: [number, number] = [
400
+ Math.min(segment.fromX, segment.toX),
401
+ Math.max(segment.fromX, segment.toX),
402
+ ];
403
+ track = trackRanges.findIndex((ranges) =>
404
+ ranges.every(([start, end]) => span[1] < start || span[0] > end),
405
+ );
406
+ if (track === -1) {
407
+ track = trackRanges.length;
408
+ trackRanges.push([]);
409
+ }
410
+ (trackRanges[track] as [number, number][]).push(span);
411
+ }
412
+ segments.push({ ...segment, track });
413
+ }
414
+ return {
415
+ segments,
416
+ trackCount: Math.max(1, trackRanges.length),
417
+ hasLabels: segments.some((segment) => segment.label !== undefined),
418
+ straight: segments.every(
419
+ (segment) => segment.fromX === segment.toX && segment.label === undefined,
420
+ ),
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Fan columns for edges sharing a cell: segment i (ordered by the far
426
+ * end's x) gets column center - 2*(n-1-i), clamped to the cell, never
427
+ * right of center. Forward fans stay at or left of center while back-edge
428
+ * anchors sit right of center, so the two can never collide.
429
+ */
430
+ function fanOffsets(
431
+ strip: GraphSegment[],
432
+ side: "from" | "to",
433
+ top: RankGeometry,
434
+ bottom: RankGeometry,
435
+ ): Map<string, number> {
436
+ const [ownRank, ownCell, farRank, farCell] =
437
+ side === "from"
438
+ ? ([top, (s: GraphSegment) => s.fromCell, bottom, (s: GraphSegment) => s.toCell] as const)
439
+ : ([bottom, (s: GraphSegment) => s.toCell, top, (s: GraphSegment) => s.fromCell] as const);
440
+ const offsets = new Map<string, number>();
441
+ const groups = new Map<number, GraphSegment[]>();
442
+ for (const segment of strip) {
443
+ // Virtual cells are one column wide and always have one edge per side.
444
+ if ((ownRank.cells[ownCell(segment)] as RenderedCell).cell.kind === "node") {
445
+ groups.set(ownCell(segment), [...(groups.get(ownCell(segment)) ?? []), segment]);
446
+ }
447
+ }
448
+ for (const [cellIndex, group] of groups) {
449
+ if (group.length < 2) {
450
+ continue;
451
+ }
452
+ const cell = ownRank.cells[cellIndex] as RenderedCell;
453
+ const maxOffset = Math.max(1, Math.floor(cell.width / 2) - 1);
454
+ const ordered = group.toSorted(
455
+ (a, b) => (farRank.centers[farCell(a)] as number) - (farRank.centers[farCell(b)] as number),
456
+ );
457
+ for (const [index, segment] of ordered.entries()) {
458
+ const offset = -2 * (ordered.length - 1 - index);
459
+ offsets.set(segment.edgeId, Math.max(-maxOffset, offset));
460
+ }
461
+ }
462
+ return offsets;
463
+ }
464
+
465
+ const BOX_CHARS = {
466
+ light: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
467
+ heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
468
+ } as const;
469
+
470
+ function drawNodes(
471
+ canvas: CharCanvas,
472
+ placed: PlacedRank[],
473
+ layout: GraphLayout,
474
+ transitions: Set<string>,
475
+ nodeStyle: GraphNodeStyle,
476
+ ): void {
477
+ for (const rank of placed) {
478
+ for (const [index, rendered] of rank.cells.entries()) {
479
+ const center = rank.centers[index] as number;
480
+ const cell = rendered.cell;
481
+ if (cell.kind === "virtual") {
482
+ const edge = layout.edges.find((candidate) => candidate.edgeId === cell.edgeId);
483
+ const taken = edge ? transitions.has(`${edge.from}->${edge.to}`) : false;
484
+ // Pass-through cells span the full cell height so the edge stays
485
+ // visually continuous across the rank row(s).
486
+ canvas.vline(center, rank.y, rank.y + cellHeight(nodeStyle) - 1, taken ? "taken" : "dim");
487
+ continue;
488
+ }
489
+ const status = rendered.status ?? "queued";
490
+ const startX = center - Math.floor(rendered.width / 2);
491
+ if (nodeStyle === "box") {
492
+ drawNodeBox(canvas, startX, rank.y, rendered, status);
493
+ } else {
494
+ canvas.put(startX, rank.y, STATUS_GLYPHS[status], STATUS_STYLES[status]);
495
+ canvas.text(startX + 2, rank.y, rendered.text, status === "queued" ? "dim" : "plain");
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * A bordered node cell. Edge geometry keeps lines outside the border rows,
503
+ * so borders stay unbroken; the active node gets a heavy border so the
504
+ * current position stands out.
505
+ */
506
+ function drawNodeBox(
507
+ canvas: CharCanvas,
508
+ startX: number,
509
+ y: number,
510
+ rendered: RenderedCell,
511
+ status: NodeStatus,
512
+ ): void {
513
+ const chars = status === "active" ? BOX_CHARS.heavy : BOX_CHARS.light;
514
+ const style = STATUS_STYLES[status];
515
+ const innerWidth = rendered.width - 2;
516
+ canvas.text(startX, y, `${chars.tl}${chars.h.repeat(innerWidth)}${chars.tr}`, style);
517
+ canvas.text(startX, y + 1, chars.v, style);
518
+ canvas.put(startX + 2, y + 1, STATUS_GLYPHS[status], style);
519
+ canvas.text(startX + 4, y + 1, rendered.text, status === "queued" ? "dim" : "plain");
520
+ canvas.text(startX + rendered.width - 1, y + 1, chars.v, style);
521
+ canvas.text(startX, y + 2, `${chars.bl}${chars.h.repeat(innerWidth)}${chars.br}`, style);
522
+ }
523
+
524
+ function edgeStyle(
525
+ pairKey: string,
526
+ transitions: Set<string>,
527
+ activePair: string | null,
528
+ ): CanvasStyle {
529
+ if (activePair === pairKey) {
530
+ return "active";
531
+ }
532
+ if (transitions.has(pairKey)) {
533
+ return "taken";
534
+ }
535
+ return "dim";
536
+ }
537
+
538
+ function drawSegments(
539
+ canvas: CharCanvas,
540
+ placed: PlacedRank[],
541
+ strips: StripGeometry[],
542
+ layout: GraphLayout,
543
+ transitions: Set<string>,
544
+ activePair: string | null,
545
+ graphWidth: number,
546
+ nodeStyle: GraphNodeStyle,
547
+ lanes: BackEdgeLanes,
548
+ ): PendingLabel[] {
549
+ const labels: PendingLabel[] = [];
550
+ for (let rank = 0; rank < placed.length - 1; rank += 1) {
551
+ const strip = strips[rank] as StripGeometry;
552
+ if (strip.segments.length === 0) {
553
+ continue;
554
+ }
555
+ const top = placed[rank] as PlacedRank;
556
+ const bottom = placed[rank + 1] as PlacedRank;
557
+ // Forward lines start right below the source cell, cross any back-edge
558
+ // lane rows (as ┼ crossings), run their strip tracks, then cross the
559
+ // entry lanes to the arrow row directly above the target cell.
560
+ const stubTop = top.y + cellHeight(nodeStyle);
561
+ const stripTop = stubTop + lanes.below(rank).length;
562
+ const arrowY = bottom.y - 1;
563
+ const stripBottom = arrowY - 1 - lanes.above(rank + 1).length;
564
+ for (const segment of strip.segments) {
565
+ const edge = layout.edges.find((candidate) => candidate.edgeId === segment.edgeId);
566
+ if (!edge) {
567
+ continue;
568
+ }
569
+ const style = edgeStyle(`${edge.from}->${edge.to}`, transitions, activePair);
570
+ const { fromX, toX } = segment;
571
+ const trackY = stripTop + segment.track;
572
+ if (fromX === toX) {
573
+ canvas.vline(fromX, stubTop, arrowY, style);
574
+ } else {
575
+ if (trackY > stubTop) {
576
+ canvas.vline(fromX, stubTop, trackY - 1, style);
577
+ }
578
+ canvas.put(fromX, trackY, toX > fromX ? "└" : "┘", style);
579
+ canvas.hline(trackY, Math.min(fromX, toX) + 1, Math.max(fromX, toX) - 1, style);
580
+ canvas.put(toX, trackY, toX > fromX ? "┐" : "┌", style);
581
+ if (arrowY > trackY) {
582
+ canvas.vline(toX, trackY + 1, arrowY, style);
583
+ }
584
+ }
585
+ if (segment.targetIsNode) {
586
+ canvas.put(toX, arrowY, "▼", style);
587
+ }
588
+ if (segment.label !== undefined) {
589
+ labels.push({
590
+ text: segment.label,
591
+ style,
592
+ fromX,
593
+ toX,
594
+ trackY,
595
+ labelRow: Math.min(stripTop + strip.trackCount, stripBottom),
596
+ graphWidth,
597
+ });
598
+ }
599
+ }
600
+ }
601
+ return labels;
602
+ }
603
+
604
+ type PendingLabel = {
605
+ text: string;
606
+ style: CanvasStyle;
607
+ fromX: number;
608
+ toX: number;
609
+ trackY: number;
610
+ labelRow: number;
611
+ graphWidth: number;
612
+ };
613
+
614
+ /**
615
+ * Place a branch label. Labels are drawn after every line is on the canvas,
616
+ * so a spot that is free now stays free: first try writing over the
617
+ * segment's own horizontal run (only plain `─` cells may be replaced), then
618
+ * the strip's reserved label row beside the descending line, trying the
619
+ * side facing the graph center first.
620
+ */
621
+ function drawSegmentLabel(canvas: CharCanvas, label: PendingLabel): void {
622
+ const { text, style, fromX, toX, trackY, labelRow, graphWidth } = label;
623
+ const padded = ` ${text} `;
624
+ if (fromX !== toX) {
625
+ const runStart = Math.min(fromX, toX) + 1;
626
+ const runEnd = Math.max(fromX, toX) - 1;
627
+ const center = Math.floor((runStart + runEnd) / 2) - Math.floor(padded.length / 2);
628
+ if (
629
+ runEnd - runStart + 1 >= padded.length + 2 &&
630
+ canvas.textOverRun(center, trackY, padded, style)
631
+ ) {
632
+ return;
633
+ }
634
+ }
635
+ const candidates: [number, number][] =
636
+ toX >= Math.floor(graphWidth / 2)
637
+ ? [
638
+ [toX - text.length - 1, labelRow],
639
+ [toX + 2, labelRow],
640
+ ]
641
+ : [
642
+ [toX + 2, labelRow],
643
+ [toX - text.length - 1, labelRow],
644
+ ];
645
+ for (const [x, y] of candidates) {
646
+ if (canvas.textIfEmpty(x, y, text, style)) {
647
+ return;
648
+ }
649
+ }
650
+ // Last resort: beside the source corner on the track row.
651
+ canvas.textIfEmpty(fromX + 2, trackY, text, style);
652
+ }
653
+
654
+ /**
655
+ * Each back edge leaves its source cell downward into its own lane row,
656
+ * runs right to a private gutter column, climbs the gutter, and re-enters
657
+ * through its target's entry lane and arrow row from above. Every lane row
658
+ * and gutter column is exclusive to one edge, so loop lines can only ever
659
+ * cross other lines (merging into ┼), never run along them.
660
+ */
661
+ function drawBackEdges(
662
+ canvas: CharCanvas,
663
+ placed: PlacedRank[],
664
+ layout: GraphLayout,
665
+ transitions: Set<string>,
666
+ graphWidth: number,
667
+ nodeStyle: GraphNodeStyle,
668
+ lanes: BackEdgeLanes,
669
+ ): void {
670
+ let gutterX = graphWidth + GUTTER_GAP;
671
+ for (const edge of lanes.edges) {
672
+ const fromRank = layout.rankOfNode.get(edge.from);
673
+ const toRank = layout.rankOfNode.get(edge.to);
674
+ if (fromRank === undefined || toRank === undefined) {
675
+ continue;
676
+ }
677
+ const from = placed[fromRank] as PlacedRank;
678
+ const to = placed[toRank] as PlacedRank;
679
+ const exit = cellAnchor(from, edge.from, lanes.below(fromRank), edge);
680
+ const entry = cellAnchor(to, edge.to, lanes.above(toRank), edge);
681
+ if (!exit || !entry) {
682
+ continue;
683
+ }
684
+ const style: CanvasStyle = transitions.has(`${edge.from}->${edge.to}`) ? "taken" : "back";
685
+ const exitLaneY = from.y + cellHeight(nodeStyle) + exit.lane;
686
+ const aboveCount = lanes.above(toRank).length;
687
+ const arrowY = to.y - 1;
688
+ const entryLaneY = arrowY - aboveCount + entry.lane;
689
+
690
+ // Downward stub out of the source cell, then right along the exit lane.
691
+ if (exitLaneY > from.y + cellHeight(nodeStyle)) {
692
+ canvas.vline(exit.x, from.y + cellHeight(nodeStyle), exitLaneY - 1, style);
693
+ }
694
+ canvas.put(exit.x, exitLaneY, "└", style);
695
+ canvas.hline(exitLaneY, exit.x + 1, gutterX - 1, style);
696
+ canvas.put(gutterX, exitLaneY, "┘", style);
697
+ // Up the gutter, then left along the entry lane into the target.
698
+ canvas.put(gutterX, entryLaneY, "┐", style);
699
+ if (exitLaneY - entryLaneY > 1) {
700
+ canvas.vline(gutterX, entryLaneY + 1, exitLaneY - 1, style);
701
+ }
702
+ canvas.hline(entryLaneY, entry.x + 1, gutterX - 1, style);
703
+ canvas.put(entry.x, entryLaneY, "┌", style);
704
+ if (arrowY - entryLaneY > 1) {
705
+ canvas.vline(entry.x, entryLaneY + 1, arrowY - 1, style);
706
+ }
707
+ canvas.put(entry.x, arrowY, "▼", style);
708
+ if (edge.label !== undefined) {
709
+ canvas.text(gutterX + 2, entryLaneY, edge.label, style);
710
+ }
711
+ // Reserve horizontal room for this gutter and its label before the next.
712
+ gutterX += 2 + (edge.label === undefined ? 0 : edge.label.length + 1);
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Where a back edge touches a node cell: offset right of center so the
718
+ * stub can never collide with forward-edge lines at the center column,
719
+ * clamped inside the cell.
720
+ */
721
+ function cellAnchor(
722
+ rank: PlacedRank,
723
+ nodeId: string,
724
+ laneEdges: GraphEdge[],
725
+ edge: GraphEdge,
726
+ ): { x: number; lane: number } | null {
727
+ const index = rank.cells.findIndex(
728
+ (cell) => cell.cell.kind === "node" && cell.cell.nodeId === nodeId,
729
+ );
730
+ const lane = laneEdges.findIndex((candidate) => candidate.edgeId === edge.edgeId);
731
+ if (index === -1 || lane === -1) {
732
+ return null;
733
+ }
734
+ const cell = rank.cells[index] as RenderedCell;
735
+ const center = rank.centers[index] as number;
736
+ const rightmost = center + Math.floor(cell.width / 2) - 1;
737
+ return { x: Math.min(center + 2 + lane * 2, rightmost), lane };
738
+ }