@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.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/extension/executor.d.ts +58 -0
- package/dist/extension/executor.js +201 -0
- package/dist/extension/executor.js.map +1 -0
- package/dist/extension/index.d.ts +17 -0
- package/dist/extension/index.js +504 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/widget.d.ts +21 -0
- package/dist/extension/widget.js +142 -0
- package/dist/extension/widget.js.map +1 -0
- package/dist/render/ansi.d.ts +16 -0
- package/dist/render/ansi.js +42 -0
- package/dist/render/ansi.js.map +1 -0
- package/dist/render/canvas.d.ts +40 -0
- package/dist/render/canvas.js +177 -0
- package/dist/render/canvas.js.map +1 -0
- package/dist/render/format.d.ts +3 -0
- package/dist/render/format.js +17 -0
- package/dist/render/format.js.map +1 -0
- package/dist/render/graph-render.d.ts +22 -0
- package/dist/render/graph-render.js +520 -0
- package/dist/render/graph-render.js.map +1 -0
- package/dist/render/graph.d.ts +46 -0
- package/dist/render/graph.js +272 -0
- package/dist/render/graph.js.map +1 -0
- package/dist/viewer/cli.d.ts +10 -0
- package/dist/viewer/cli.js +132 -0
- package/dist/viewer/cli.js.map +1 -0
- package/dist/viewer/render.d.ts +19 -0
- package/dist/viewer/render.js +162 -0
- package/dist/viewer/render.js.map +1 -0
- package/dist/viewer/tui.d.ts +11 -0
- package/dist/viewer/tui.js +140 -0
- package/dist/viewer/tui.js.map +1 -0
- package/dist/viewer/watch.d.ts +9 -0
- package/dist/viewer/watch.js +46 -0
- package/dist/viewer/watch.js.map +1 -0
- package/dist/workflows/decision.d.ts +25 -0
- package/dist/workflows/decision.js +96 -0
- package/dist/workflows/decision.js.map +1 -0
- package/dist/workflows/definition.d.ts +9 -0
- package/dist/workflows/definition.js +61 -0
- package/dist/workflows/definition.js.map +1 -0
- package/dist/workflows/engine.d.ts +65 -0
- package/dist/workflows/engine.js +574 -0
- package/dist/workflows/engine.js.map +1 -0
- package/dist/workflows/errors.d.ts +9 -0
- package/dist/workflows/errors.js +24 -0
- package/dist/workflows/errors.js.map +1 -0
- package/dist/workflows/graph.d.ts +17 -0
- package/dist/workflows/graph.js +127 -0
- package/dist/workflows/graph.js.map +1 -0
- package/dist/workflows/index.d.ts +11 -0
- package/dist/workflows/index.js +11 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/json.d.ts +14 -0
- package/dist/workflows/json.js +134 -0
- package/dist/workflows/json.js.map +1 -0
- package/dist/workflows/loader.d.ts +28 -0
- package/dist/workflows/loader.js +94 -0
- package/dist/workflows/loader.js.map +1 -0
- package/dist/workflows/schema.d.ts +7 -0
- package/dist/workflows/schema.js +176 -0
- package/dist/workflows/schema.js.map +1 -0
- package/dist/workflows/shell.d.ts +9 -0
- package/dist/workflows/shell.js +177 -0
- package/dist/workflows/shell.js.map +1 -0
- package/dist/workflows/store.d.ts +35 -0
- package/dist/workflows/store.js +181 -0
- package/dist/workflows/store.js.map +1 -0
- package/dist/workflows/text.d.ts +10 -0
- package/dist/workflows/text.js +32 -0
- package/dist/workflows/text.js.map +1 -0
- package/dist/workflows/types.d.ts +280 -0
- package/dist/workflows/types.js +2 -0
- package/dist/workflows/types.js.map +1 -0
- package/docs/development.md +130 -0
- package/docs/run-bundles.md +114 -0
- package/docs/workflows.md +311 -0
- package/examples/workflows/autoimplement.workflow.ts +92 -0
- package/examples/workflows/autoresearch.workflow.ts +139 -0
- package/examples/workflows/branch.workflow.ts +63 -0
- package/examples/workflows/echo.workflow.ts +23 -0
- package/examples/workflows/elegant-solution.workflow.ts +95 -0
- package/examples/workflows/shell.workflow.ts +31 -0
- package/examples/workflows/two-turn.workflow.ts +64 -0
- package/package.json +80 -0
- package/src/extension/executor.ts +251 -0
- package/src/extension/index.ts +627 -0
- package/src/extension/widget.ts +183 -0
- package/src/render/ansi.ts +47 -0
- package/src/render/canvas.ts +196 -0
- package/src/render/format.ts +19 -0
- package/src/render/graph-render.ts +738 -0
- package/src/render/graph.ts +341 -0
- package/src/viewer/cli.ts +150 -0
- package/src/viewer/render.ts +236 -0
- package/src/viewer/tui.ts +159 -0
- package/src/viewer/watch.ts +55 -0
- package/src/workflows/decision.ts +127 -0
- package/src/workflows/definition.ts +104 -0
- package/src/workflows/engine.ts +793 -0
- package/src/workflows/errors.ts +27 -0
- package/src/workflows/graph.ts +161 -0
- package/src/workflows/index.ts +76 -0
- package/src/workflows/json.ts +155 -0
- package/src/workflows/loader.ts +123 -0
- package/src/workflows/schema.ts +218 -0
- package/src/workflows/shell.ts +199 -0
- package/src/workflows/store.ts +234 -0
- package/src/workflows/text.ts +34 -0
- package/src/workflows/types.ts +318 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { ansi, stripAnsi } from "../render/ansi.js";
|
|
2
|
+
import { renderGraphLines } from "../render/graph-render.js";
|
|
3
|
+
import { sanitizeText } from "../workflows/text.js";
|
|
4
|
+
import type {
|
|
5
|
+
WorkflowDefinitionSnapshot,
|
|
6
|
+
WorkflowRunState,
|
|
7
|
+
WorkflowRunStatus,
|
|
8
|
+
} from "../workflows/types.js";
|
|
9
|
+
|
|
10
|
+
const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
|
|
11
|
+
running: "◐",
|
|
12
|
+
waiting: "⏸",
|
|
13
|
+
completed: "✓",
|
|
14
|
+
failed: "✗",
|
|
15
|
+
timed_out: "✗",
|
|
16
|
+
cancelled: "✗",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* pi renders at most this many widget lines (InteractiveMode.MAX_WIDGET_LINES)
|
|
21
|
+
* 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.
|
|
23
|
+
*/
|
|
24
|
+
const PI_MAX_WIDGET_LINES = 10;
|
|
25
|
+
|
|
26
|
+
export function nodeGlyph(state: WorkflowRunState, nodeId: string): string {
|
|
27
|
+
if (state.currentNode === nodeId) {
|
|
28
|
+
return "◐";
|
|
29
|
+
}
|
|
30
|
+
const result = state.results[nodeId];
|
|
31
|
+
if (!result) {
|
|
32
|
+
return "·";
|
|
33
|
+
}
|
|
34
|
+
if (state.waitingOn === nodeId) {
|
|
35
|
+
return "⏸";
|
|
36
|
+
}
|
|
37
|
+
return result.outcome === "ok" ? "✓" : "✗";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Node ids in a stable display order: definition order from the snapshot. */
|
|
41
|
+
export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
|
|
42
|
+
return Object.keys(snapshot.nodes);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type WidgetView = {
|
|
46
|
+
lines: string[];
|
|
47
|
+
/** The clamped first visible graph row; feed back in to scroll relatively. */
|
|
48
|
+
scroll: number;
|
|
49
|
+
/** Largest useful scroll value; 0 when the whole graph fits. */
|
|
50
|
+
maxScroll: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
60
|
+
export function buildWidgetView(
|
|
61
|
+
state: WorkflowRunState,
|
|
62
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
63
|
+
now: Date = new Date(),
|
|
64
|
+
scroll: number | null = null,
|
|
65
|
+
held = false,
|
|
66
|
+
): WidgetView {
|
|
67
|
+
// `held` covers pauses the state cannot see yet: an escape-interrupted
|
|
68
|
+
// step or a pause requested while the current node is still finishing.
|
|
69
|
+
const paused = held || state.paused === true;
|
|
70
|
+
const glyph = paused ? "⏸" : STATUS_GLYPHS[state.status];
|
|
71
|
+
const statusText = paused ? "paused" : state.status;
|
|
72
|
+
// Titles, status details, and errors can carry model- or shell-controlled
|
|
73
|
+
// text; never let escape sequences or newlines reach the terminal.
|
|
74
|
+
const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
|
|
75
|
+
const header = `${glyph} workflow ${sanitizeText(state.workflowName)}${title} [${statusText}]`;
|
|
76
|
+
|
|
77
|
+
const footer: string[] = [];
|
|
78
|
+
if (state.error) {
|
|
79
|
+
footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
|
|
80
|
+
}
|
|
81
|
+
if (state.status === "waiting" && state.waitingOn) {
|
|
82
|
+
footer.push(` waiting on checkpoint: ${state.waitingOn}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
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) {
|
|
90
|
+
return {
|
|
91
|
+
lines: [header, ` ${compactNodeStrip(state, snapshot)}`, ...footer],
|
|
92
|
+
scroll: 0,
|
|
93
|
+
maxScroll: 0,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const windowed = windowLines(graph, budget, scroll ?? focusLine(graph, state), scroll !== null);
|
|
97
|
+
return {
|
|
98
|
+
lines: [header, ...windowed.lines.map((line) => ` ${line}`), ...footer],
|
|
99
|
+
scroll: windowed.scroll,
|
|
100
|
+
maxScroll: windowed.maxScroll,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Back-compatible line view following the active node. */
|
|
105
|
+
export function buildWidgetLines(
|
|
106
|
+
state: WorkflowRunState,
|
|
107
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
108
|
+
now: Date = new Date(),
|
|
109
|
+
): string[] {
|
|
110
|
+
return buildWidgetView(state, snapshot, now).lines;
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
+
if (active !== -1) {
|
|
117
|
+
return active;
|
|
118
|
+
}
|
|
119
|
+
if (state.waitingOn) {
|
|
120
|
+
const waiting = graph.findIndex((line) => stripAnsi(line).includes(state.waitingOn as string));
|
|
121
|
+
if (waiting !== -1) {
|
|
122
|
+
return waiting;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return graph.length - 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Slice `lines` to at most `budget` rows, marking hidden rows at either end.
|
|
130
|
+
* Markers count against the budget. `anchor` is a row to center on (follow
|
|
131
|
+
* mode) or the requested first visible row (manual scroll).
|
|
132
|
+
*/
|
|
133
|
+
function windowLines(
|
|
134
|
+
lines: string[],
|
|
135
|
+
budget: number,
|
|
136
|
+
anchor: number,
|
|
137
|
+
anchorIsStart: boolean,
|
|
138
|
+
): { lines: string[]; scroll: number; maxScroll: number } {
|
|
139
|
+
if (lines.length <= budget) {
|
|
140
|
+
return { lines, scroll: 0, maxScroll: 0 };
|
|
141
|
+
}
|
|
142
|
+
// First pass decides which markers exist; the second re-fits within the
|
|
143
|
+
// remaining space. A marker flag can only turn on (never off) in the
|
|
144
|
+
// second pass, so the result stays within budget.
|
|
145
|
+
let inner = budget;
|
|
146
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
147
|
+
const start = clampStart(anchor, inner, lines.length, anchorIsStart);
|
|
148
|
+
inner = budget - (start > 0 ? 1 : 0) - (start + inner < lines.length ? 1 : 0);
|
|
149
|
+
}
|
|
150
|
+
const start = clampStart(anchor, inner, lines.length, anchorIsStart);
|
|
151
|
+
const end = start + inner;
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
if (start > 0) {
|
|
154
|
+
out.push(ansi.dim(`↑ ${start} more`));
|
|
155
|
+
}
|
|
156
|
+
out.push(...lines.slice(start, end));
|
|
157
|
+
if (end < lines.length) {
|
|
158
|
+
out.push(ansi.dim(`↓ ${lines.length - end} more · shift+↑/↓ scroll`));
|
|
159
|
+
}
|
|
160
|
+
return { lines: out, scroll: start, maxScroll: Math.max(0, lines.length - inner) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function clampStart(anchor: number, inner: number, total: number, anchorIsStart: boolean): number {
|
|
164
|
+
const start = anchorIsStart ? anchor : anchor - Math.floor(inner / 2);
|
|
165
|
+
return Math.max(0, Math.min(start, total - inner));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function compactNodeStrip(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): string {
|
|
169
|
+
return displayNodeIds(snapshot)
|
|
170
|
+
.map((nodeId) => {
|
|
171
|
+
const marker = nodeGlyph(state, nodeId);
|
|
172
|
+
const detail =
|
|
173
|
+
state.currentNode === nodeId && state.statusDetail
|
|
174
|
+
? ` (${sanitizeText(state.statusDetail)})`
|
|
175
|
+
: "";
|
|
176
|
+
return `${marker} ${nodeId}${detail}`;
|
|
177
|
+
})
|
|
178
|
+
.join(" ");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function truncate(text: string, maxLength: number): string {
|
|
182
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
183
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { sanitizeText, stripAnsi } from "../workflows/text.js";
|
|
2
|
+
|
|
3
|
+
export { sanitizeText, stripAnsi };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Colors are on for interactive terminals only, so piped output stays clean.
|
|
7
|
+
* `NO_COLOR`/`PI_WORKFLOWS_NO_COLOR` force them off; `FORCE_COLOR` forces
|
|
8
|
+
* them on. Evaluated per call so env changes (tests) take effect.
|
|
9
|
+
*/
|
|
10
|
+
function colorEnabled(): boolean {
|
|
11
|
+
if (process.env.NO_COLOR !== undefined || process.env.PI_WORKFLOWS_NO_COLOR !== undefined) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (process.env.FORCE_COLOR !== undefined) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return process.stdout.isTTY === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wrap(code: string, text: string): string {
|
|
21
|
+
return colorEnabled() ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const ansi = {
|
|
25
|
+
bold: (text: string) => wrap("1", text),
|
|
26
|
+
dim: (text: string) => wrap("2", text),
|
|
27
|
+
red: (text: string) => wrap("31", text),
|
|
28
|
+
green: (text: string) => wrap("32", text),
|
|
29
|
+
yellow: (text: string) => wrap("33", text),
|
|
30
|
+
blue: (text: string) => wrap("34", text),
|
|
31
|
+
magenta: (text: string) => wrap("35", text),
|
|
32
|
+
cyan: (text: string) => wrap("36", text),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Visible length of a string, ignoring ANSI escape sequences. */
|
|
36
|
+
export function visibleLength(text: string): number {
|
|
37
|
+
return stripAnsi(text).length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Truncate to a visible width, keeping ANSI sequences intact by stripping them first when needed. */
|
|
41
|
+
export function fitWidth(text: string, width: number): string {
|
|
42
|
+
if (visibleLength(text) <= width) {
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
const plain = stripAnsi(text);
|
|
46
|
+
return width <= 1 ? plain.slice(0, width) : `${plain.slice(0, width - 1)}…`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sparse character grid for drawing the workflow graph. Box-drawing
|
|
3
|
+
* characters merge by connectivity (│ crossing ─ becomes ┼) so overlapping
|
|
4
|
+
* edge polylines join instead of overwriting each other.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type CanvasStyle = "plain" | "dim" | "taken" | "active" | "back" | "ok" | "fail" | "warn";
|
|
8
|
+
|
|
9
|
+
type CanvasChar = { char: string; style: CanvasStyle };
|
|
10
|
+
|
|
11
|
+
export const UP = 1;
|
|
12
|
+
export const DOWN = 2;
|
|
13
|
+
export const LEFT = 4;
|
|
14
|
+
export const RIGHT = 8;
|
|
15
|
+
|
|
16
|
+
/** Which sides each box-drawing character connects to (exported for tests). */
|
|
17
|
+
export const CHAR_TO_MASK: Record<string, number> = {
|
|
18
|
+
"─": LEFT | RIGHT,
|
|
19
|
+
"│": UP | DOWN,
|
|
20
|
+
"┌": DOWN | RIGHT,
|
|
21
|
+
"┐": DOWN | LEFT,
|
|
22
|
+
"└": UP | RIGHT,
|
|
23
|
+
"┘": UP | LEFT,
|
|
24
|
+
"├": UP | DOWN | RIGHT,
|
|
25
|
+
"┤": UP | DOWN | LEFT,
|
|
26
|
+
"┬": DOWN | LEFT | RIGHT,
|
|
27
|
+
"┴": UP | LEFT | RIGHT,
|
|
28
|
+
"┼": UP | DOWN | LEFT | RIGHT,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MASK_TO_CHAR = new Map<number, string>(
|
|
32
|
+
Object.entries(CHAR_TO_MASK).map(([char, mask]) => [mask, char]),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/** Styles later in this list win when merged lines overlap. */
|
|
36
|
+
const STYLE_PRIORITY: CanvasStyle[] = [
|
|
37
|
+
"plain",
|
|
38
|
+
"dim",
|
|
39
|
+
"back",
|
|
40
|
+
"warn",
|
|
41
|
+
"ok",
|
|
42
|
+
"fail",
|
|
43
|
+
"taken",
|
|
44
|
+
"active",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function mergeStyles(a: CanvasStyle, b: CanvasStyle): CanvasStyle {
|
|
48
|
+
return STYLE_PRIORITY.indexOf(a) >= STYLE_PRIORITY.indexOf(b) ? a : b;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class CharCanvas {
|
|
52
|
+
private readonly cells = new Map<number, Map<number, CanvasChar>>();
|
|
53
|
+
private maxX = 0;
|
|
54
|
+
private maxY = 0;
|
|
55
|
+
|
|
56
|
+
private row(y: number): Map<number, CanvasChar> {
|
|
57
|
+
let row = this.cells.get(y);
|
|
58
|
+
if (!row) {
|
|
59
|
+
row = new Map();
|
|
60
|
+
this.cells.set(y, row);
|
|
61
|
+
}
|
|
62
|
+
this.maxY = Math.max(this.maxY, y);
|
|
63
|
+
return row;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Place a single character, merging box-drawing connectivity. */
|
|
67
|
+
put(x: number, y: number, char: string, style: CanvasStyle = "plain"): void {
|
|
68
|
+
if (x < 0 || y < 0) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const row = this.row(y);
|
|
72
|
+
this.maxX = Math.max(this.maxX, x);
|
|
73
|
+
const existing = row.get(x);
|
|
74
|
+
// Spaces never occupy cells; textOverRun handles deliberate padding.
|
|
75
|
+
if (char === " ") {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (existing) {
|
|
79
|
+
const existingMask = CHAR_TO_MASK[existing.char];
|
|
80
|
+
const incomingMask = CHAR_TO_MASK[char];
|
|
81
|
+
if (existingMask !== undefined && incomingMask !== undefined) {
|
|
82
|
+
row.set(x, {
|
|
83
|
+
char: MASK_TO_CHAR.get(existingMask | incomingMask) ?? char,
|
|
84
|
+
style: mergeStyles(existing.style, style),
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Non-line characters (labels, glyphs, arrows) win over lines; between
|
|
89
|
+
// two non-line characters the newest wins.
|
|
90
|
+
if (existingMask === undefined && incomingMask !== undefined) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
row.set(x, { char, style });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Write a text run left to right (labels, node lines). */
|
|
98
|
+
text(x: number, y: number, value: string, style: CanvasStyle = "plain"): void {
|
|
99
|
+
for (const [index, char] of [...value].entries()) {
|
|
100
|
+
this.put(x + index, y, char, style);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Write text only when every target cell is empty. Returns whether the
|
|
106
|
+
* text was written. Used for optional decorations (edge labels) that must
|
|
107
|
+
* never corrupt lines or nodes already on the canvas.
|
|
108
|
+
*/
|
|
109
|
+
textIfEmpty(x: number, y: number, value: string, style: CanvasStyle = "plain"): boolean {
|
|
110
|
+
if (x < 0 || y < 0) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const row = this.cells.get(y);
|
|
114
|
+
const width = [...value].length;
|
|
115
|
+
for (let index = 0; index < width; index += 1) {
|
|
116
|
+
if (row?.has(x + index)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
this.text(x, y, value, style);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Write text over a plain horizontal run, replacing `─` cells only.
|
|
126
|
+
* Refuses (returns false) unless every target cell and both flanking
|
|
127
|
+
* cells are exactly `─`, so a label can never overwrite or sit against
|
|
128
|
+
* corners, crossings, or other labels. Spaces in `value` become real
|
|
129
|
+
* blanks, visually breaking the run around the label on purpose.
|
|
130
|
+
*/
|
|
131
|
+
textOverRun(x: number, y: number, value: string, style: CanvasStyle = "plain"): boolean {
|
|
132
|
+
if (x < 1 || y < 0) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const chars = [...value];
|
|
136
|
+
const row = this.cells.get(y);
|
|
137
|
+
for (let index = -1; index <= chars.length; index += 1) {
|
|
138
|
+
if (row?.get(x + index)?.char !== "─") {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [index, char] of chars.entries()) {
|
|
143
|
+
this.row(y).set(x + index, { char, style });
|
|
144
|
+
}
|
|
145
|
+
this.maxX = Math.max(this.maxX, x + chars.length - 1);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
hline(y: number, x1: number, x2: number, style: CanvasStyle = "plain"): void {
|
|
150
|
+
const [start, end] = x1 <= x2 ? [x1, x2] : [x2, x1];
|
|
151
|
+
for (let x = start; x <= end; x += 1) {
|
|
152
|
+
this.put(x, y, "─", style);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
vline(x: number, y1: number, y2: number, style: CanvasStyle = "plain"): void {
|
|
157
|
+
const [start, end] = y1 <= y2 ? [y1, y2] : [y2, y1];
|
|
158
|
+
for (let y = start; y <= end; y += 1) {
|
|
159
|
+
this.put(x, y, "│", style);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Render to lines; `paint` applies terminal styling per character run. */
|
|
164
|
+
render(paint: (text: string, style: CanvasStyle) => string): string[] {
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
for (let y = 0; y <= this.maxY; y += 1) {
|
|
167
|
+
const row = this.cells.get(y);
|
|
168
|
+
if (!row || row.size === 0) {
|
|
169
|
+
lines.push("");
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
let line = "";
|
|
173
|
+
let runText = "";
|
|
174
|
+
let runStyle: CanvasStyle = "plain";
|
|
175
|
+
const flush = () => {
|
|
176
|
+
if (runText.length > 0) {
|
|
177
|
+
line += paint(runText, runStyle);
|
|
178
|
+
runText = "";
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
for (let x = 0; x <= this.maxX; x += 1) {
|
|
182
|
+
const cell = row.get(x);
|
|
183
|
+
const char = cell?.char ?? " ";
|
|
184
|
+
const style = cell?.style ?? "plain";
|
|
185
|
+
if (style !== runStyle) {
|
|
186
|
+
flush();
|
|
187
|
+
runStyle = style;
|
|
188
|
+
}
|
|
189
|
+
runText += char;
|
|
190
|
+
}
|
|
191
|
+
flush();
|
|
192
|
+
lines.push(line.replace(/\s+$/, ""));
|
|
193
|
+
}
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { WorkflowRunState } from "../workflows/types.js";
|
|
2
|
+
|
|
3
|
+
export function formatDuration(durationMs: number): string {
|
|
4
|
+
if (durationMs < 1_000) {
|
|
5
|
+
return `${Math.max(durationMs, 0)}ms`;
|
|
6
|
+
}
|
|
7
|
+
const seconds = durationMs / 1_000;
|
|
8
|
+
if (seconds < 60) {
|
|
9
|
+
return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
|
|
10
|
+
}
|
|
11
|
+
const minutes = Math.floor(seconds / 60);
|
|
12
|
+
const rest = Math.round(seconds % 60);
|
|
13
|
+
return `${minutes}m${rest.toString().padStart(2, "0")}s`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runElapsedMs(state: WorkflowRunState, now: Date = new Date()): number {
|
|
17
|
+
const end = state.finishedAt ? Date.parse(state.finishedAt) : now.getTime();
|
|
18
|
+
return Math.max(0, end - Date.parse(state.startedAt));
|
|
19
|
+
}
|