@workflow-manager/runner 0.6.0 → 0.8.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/README.md +5 -1
- package/dist/cliRunRenderer.js +18 -56
- package/dist/engine.js +201 -68
- package/dist/generated/bundledSkills.d.ts +5 -0
- package/dist/generated/bundledSkills.js +22 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +466 -87
- package/dist/manPage.d.ts +1 -1
- package/dist/manPage.js +47 -9
- package/dist/parser.js +10 -1
- package/dist/runFormat.d.ts +11 -0
- package/dist/runFormat.js +56 -0
- package/dist/runnerApi.js +14 -0
- package/dist/sessionFile.d.ts +11 -0
- package/dist/sessionFile.js +61 -0
- package/dist/tui/ansi.d.ts +23 -0
- package/dist/tui/ansi.js +102 -0
- package/dist/tui/layout.d.ts +18 -0
- package/dist/tui/layout.js +245 -0
- package/dist/tui/logStore.d.ts +22 -0
- package/dist/tui/logStore.js +78 -0
- package/dist/tui/screen.d.ts +35 -0
- package/dist/tui/screen.js +99 -0
- package/dist/tui/theme.d.ts +13 -0
- package/dist/tui/theme.js +55 -0
- package/dist/tui/tuiRunRenderer.d.ts +83 -0
- package/dist/tui/tuiRunRenderer.js +316 -0
- package/dist/types.d.ts +15 -8
- package/package.json +4 -2
- package/skills/workflow-author/README.md +18 -0
- package/skills/workflow-author/SKILL.md +306 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Pure two-pane frame composer for `wfm run --ui`.
|
|
2
|
+
//
|
|
3
|
+
// renderFrame(state) is a plain function: given a TuiFrameState it returns an
|
|
4
|
+
// array of exactly `state.height` display rows, each at most `state.width`
|
|
5
|
+
// visible columns wide (ANSI-aware — see ansi.ts). No I/O, no timers, no
|
|
6
|
+
// Date.now(): every time-dependent value is derived from `state.now`, which
|
|
7
|
+
// callers refresh on their own render loop.
|
|
8
|
+
import { elapsedFrom, formatDurationMs } from "../runFormat.js";
|
|
9
|
+
import { padVisible, truncateVisible, visibleWidth } from "./ansi.js";
|
|
10
|
+
// Below these thresholds the two-pane layout has no room to stay readable;
|
|
11
|
+
// fall back to a single-column stack instead of corrupting column math.
|
|
12
|
+
const DEGRADED_MIN_WIDTH = 60;
|
|
13
|
+
const DEGRADED_MIN_HEIGHT = 12;
|
|
14
|
+
// Fixed chrome rows around the variable-height content area: top border,
|
|
15
|
+
// attach-info row, pane-title separator, footer separator, two footer rows,
|
|
16
|
+
// bottom border.
|
|
17
|
+
const CHROME_ROWS = 7;
|
|
18
|
+
const KEY_HINTS = "↑/↓ select step f follow current a approve r resume c cancel q quit";
|
|
19
|
+
const QA_ICONS = {
|
|
20
|
+
PROCEED: "✓",
|
|
21
|
+
RETRY_CURRENT: "↻",
|
|
22
|
+
ROLLBACK_PREVIOUS: "⤺",
|
|
23
|
+
RESTART_ALL: "⟲",
|
|
24
|
+
};
|
|
25
|
+
export function renderFrame(state) {
|
|
26
|
+
if (state.width < DEGRADED_MIN_WIDTH || state.height < DEGRADED_MIN_HEIGHT) {
|
|
27
|
+
return renderDegradedFrame(state);
|
|
28
|
+
}
|
|
29
|
+
return renderFullFrame(state);
|
|
30
|
+
}
|
|
31
|
+
// --- generic row-fitting helpers -------------------------------------------------
|
|
32
|
+
/** Truncates or right-pads `text` so visibleWidth(result) === width (never more). */
|
|
33
|
+
function fitExact(text, width) {
|
|
34
|
+
const w = visibleWidth(text);
|
|
35
|
+
if (w > width) {
|
|
36
|
+
return truncateVisible(text, width);
|
|
37
|
+
}
|
|
38
|
+
if (w < width) {
|
|
39
|
+
return padVisible(text, width);
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
/** Pads `text` with trailing box-drawing dashes up to `width` (truncates if it overflows). */
|
|
44
|
+
function fillDashes(text, width) {
|
|
45
|
+
const w = visibleWidth(text);
|
|
46
|
+
if (w >= width) {
|
|
47
|
+
return truncateVisible(text, width);
|
|
48
|
+
}
|
|
49
|
+
return text + "─".repeat(width - w);
|
|
50
|
+
}
|
|
51
|
+
// --- full (non-degraded) frame ----------------------------------------------------
|
|
52
|
+
function renderFullFrame(state) {
|
|
53
|
+
const { width, height } = state;
|
|
54
|
+
const contentRows = height - CHROME_ROWS;
|
|
55
|
+
const paneLeftWidth = Math.min(38, Math.floor(width * 0.4));
|
|
56
|
+
const paneRightWidth = width - 3 - paneLeftWidth;
|
|
57
|
+
const rows = [];
|
|
58
|
+
rows.push(buildTopBorder(state, width));
|
|
59
|
+
rows.push(buildAttachRow(state, width));
|
|
60
|
+
rows.push(buildPaneTitleRow(state, paneLeftWidth, paneRightWidth));
|
|
61
|
+
const stepLines = buildStepPaneLines(state, paneLeftWidth, contentRows);
|
|
62
|
+
const activityLines = buildActivityPaneLines(state, paneRightWidth, contentRows);
|
|
63
|
+
for (let i = 0; i < contentRows; i++) {
|
|
64
|
+
rows.push(`│${stepLines[i]}│${activityLines[i]}│`);
|
|
65
|
+
}
|
|
66
|
+
rows.push(`├${"─".repeat(paneLeftWidth)}┴${"─".repeat(paneRightWidth)}┤`);
|
|
67
|
+
rows.push(buildFooterRow(width, buildApprovalOrStatusText(state)));
|
|
68
|
+
rows.push(buildFooterRow(width, state.theme.dim(KEY_HINTS)));
|
|
69
|
+
rows.push(`└${"─".repeat(width - 2)}┘`);
|
|
70
|
+
return rows.map((row) => fitExact(row, width));
|
|
71
|
+
}
|
|
72
|
+
function buildTopBorder(state, width) {
|
|
73
|
+
const { snapshot, theme } = state;
|
|
74
|
+
const title = snapshot?.workflowTitle ?? "wfm run";
|
|
75
|
+
const statusSegment = snapshot
|
|
76
|
+
? theme.runStatus(snapshot.status, `● ${snapshot.status.replace(/_/g, " ")} · elapsed ${computeElapsed(snapshot, state.now)}`)
|
|
77
|
+
: theme.dim("● initializing…");
|
|
78
|
+
const left = `┌─ ${title} `;
|
|
79
|
+
const right = ` ${statusSegment} ─┐`;
|
|
80
|
+
const fill = Math.max(0, width - visibleWidth(left) - visibleWidth(right));
|
|
81
|
+
return left + "─".repeat(fill) + right;
|
|
82
|
+
}
|
|
83
|
+
function buildAttachRow(state, width) {
|
|
84
|
+
const { snapshot } = state;
|
|
85
|
+
const tokenPreview = state.attachToken ? `${state.attachToken.slice(0, 8)}…` : "—";
|
|
86
|
+
const runPreview = snapshot?.runId ? `${snapshot.runId.slice(0, 8)}…` : "—";
|
|
87
|
+
const left = `│ Attach: ${state.attachUrl} (token ${tokenPreview})`;
|
|
88
|
+
const right = `run ${runPreview} │`;
|
|
89
|
+
const fill = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
90
|
+
return left + " ".repeat(fill) + right;
|
|
91
|
+
}
|
|
92
|
+
function buildPaneTitleRow(state, paneLeftWidth, paneRightWidth) {
|
|
93
|
+
const { snapshot, selectedStepKey } = state;
|
|
94
|
+
const steps = snapshot?.steps ?? [];
|
|
95
|
+
const succeeded = steps.filter((s) => s.status === "succeeded").length;
|
|
96
|
+
const leftTitle = ` Steps (${succeeded}/${steps.length} done) `;
|
|
97
|
+
const selectedStep = selectedStepKey ? steps.find((s) => s.stepKey === selectedStepKey) : undefined;
|
|
98
|
+
const rightTitle = selectedStep
|
|
99
|
+
? ` Activity: ${selectedStep.stepKey} · ${selectedStep.status.replace(/_/g, " ")} `
|
|
100
|
+
: " Activity: (none) ";
|
|
101
|
+
const leftSeg = fillDashes(leftTitle, paneLeftWidth);
|
|
102
|
+
const rightSeg = fillDashes(rightTitle, paneRightWidth);
|
|
103
|
+
return `├${leftSeg}┬${rightSeg}┤`;
|
|
104
|
+
}
|
|
105
|
+
function buildFooterRow(width, text) {
|
|
106
|
+
const inner = width - 2;
|
|
107
|
+
const content = ` ${text}`;
|
|
108
|
+
return `│${fitExact(content, inner)}│`;
|
|
109
|
+
}
|
|
110
|
+
function buildApprovalOrStatusText(state) {
|
|
111
|
+
const { snapshot, theme } = state;
|
|
112
|
+
const waiting = snapshot?.waitingForApproval;
|
|
113
|
+
if (waiting) {
|
|
114
|
+
const actionHint = waiting.validation === "external" ? "[r]esume" : "[a]pprove";
|
|
115
|
+
const text = `◌ ${waiting.stepKey} waiting for approval — ${actionHint} [c]ancel`;
|
|
116
|
+
return theme.runStatus("waiting_for_approval", text);
|
|
117
|
+
}
|
|
118
|
+
if (state.statusMessage) {
|
|
119
|
+
return theme.dim(state.statusMessage);
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
function buildStepPaneLines(state, paneLeftWidth, contentRows) {
|
|
124
|
+
const { snapshot, theme, selectedStepKey, stepDetails, now } = state;
|
|
125
|
+
const steps = snapshot?.steps ?? [];
|
|
126
|
+
const flatItems = [];
|
|
127
|
+
steps.forEach((step, idx) => {
|
|
128
|
+
const index1based = idx + 1;
|
|
129
|
+
flatItems.push({ step, index1based, isBadge: false });
|
|
130
|
+
const detail = stepDetails.get(step.stepKey);
|
|
131
|
+
const qaAction = detail?.lastExecution.qaAction ?? null;
|
|
132
|
+
const showBadge = step.attempt > 1 || (qaAction !== null && qaAction !== "PROCEED");
|
|
133
|
+
if (showBadge) {
|
|
134
|
+
flatItems.push({ step, index1based, isBadge: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
let windowStart = 0;
|
|
138
|
+
if (flatItems.length > contentRows) {
|
|
139
|
+
const selIdx = selectedStepKey
|
|
140
|
+
? flatItems.findIndex((item) => !item.isBadge && item.step.stepKey === selectedStepKey)
|
|
141
|
+
: -1;
|
|
142
|
+
if (selIdx >= 0) {
|
|
143
|
+
windowStart = Math.min(Math.max(0, selIdx - Math.floor(contentRows / 2)), flatItems.length - contentRows);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const lines = [];
|
|
147
|
+
for (let i = 0; i < contentRows; i++) {
|
|
148
|
+
const item = flatItems[windowStart + i];
|
|
149
|
+
if (!item) {
|
|
150
|
+
if (i === 0 && steps.length === 0 && !snapshot) {
|
|
151
|
+
lines.push(fitExact(theme.dim(" Initializing…"), paneLeftWidth));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
lines.push(padVisible("", paneLeftWidth));
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const selected = !item.isBadge && item.step.stepKey === selectedStepKey;
|
|
159
|
+
lines.push(item.isBadge
|
|
160
|
+
? buildBadgeLine(item, paneLeftWidth, theme, stepDetails)
|
|
161
|
+
: buildStepLine(item, paneLeftWidth, theme, selected, now));
|
|
162
|
+
}
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
function buildStepLine(item, paneLeftWidth, theme, selected, now) {
|
|
166
|
+
const { step, index1based } = item;
|
|
167
|
+
const icon = theme.stepIcon(step.status);
|
|
168
|
+
const duration = stepDuration(step, now);
|
|
169
|
+
const raw = `${icon} ${index1based} ${step.stepKey} ${step.adapter} ${duration}`.trimEnd();
|
|
170
|
+
const fitted = fitExact(raw, paneLeftWidth);
|
|
171
|
+
return selected ? theme.inverse(fitted) : theme.stepStatus(step.status, fitted);
|
|
172
|
+
}
|
|
173
|
+
function buildBadgeLine(item, paneLeftWidth, theme, stepDetails) {
|
|
174
|
+
const { step } = item;
|
|
175
|
+
const detail = stepDetails.get(step.stepKey);
|
|
176
|
+
const parts = [];
|
|
177
|
+
if (step.attempt > 1) {
|
|
178
|
+
parts.push(theme.dim(`attempt ${step.attempt}`));
|
|
179
|
+
}
|
|
180
|
+
const qaAction = detail?.lastExecution.qaAction ?? null;
|
|
181
|
+
if (qaAction && qaAction !== "PROCEED") {
|
|
182
|
+
parts.push(theme.qaAction(qaAction, `${QA_ICONS[qaAction]} ${qaAction}`));
|
|
183
|
+
}
|
|
184
|
+
const raw = ` ${parts.join(" · ")}`;
|
|
185
|
+
return fitExact(raw, paneLeftWidth);
|
|
186
|
+
}
|
|
187
|
+
function stepDuration(step, now) {
|
|
188
|
+
if (step.startedAt && step.finishedAt) {
|
|
189
|
+
return formatDurationMs(Date.parse(step.finishedAt) - Date.parse(step.startedAt));
|
|
190
|
+
}
|
|
191
|
+
if (step.startedAt && step.status === "running") {
|
|
192
|
+
return elapsedFrom(step.startedAt, now);
|
|
193
|
+
}
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
function computeElapsed(snapshot, now) {
|
|
197
|
+
if (!snapshot.startedAt) {
|
|
198
|
+
return "0s";
|
|
199
|
+
}
|
|
200
|
+
const endTs = snapshot.endedAt ? Date.parse(snapshot.endedAt) : now;
|
|
201
|
+
return elapsedFrom(snapshot.startedAt, endTs);
|
|
202
|
+
}
|
|
203
|
+
// --- right pane: activity log --------------------------------------------------
|
|
204
|
+
function buildActivityPaneLines(state, paneRightWidth, contentRows) {
|
|
205
|
+
const { logs, selectedStepKey, theme } = state;
|
|
206
|
+
const lines = selectedStepKey ? logs.tail(selectedStepKey, contentRows) : [];
|
|
207
|
+
const rendered = [];
|
|
208
|
+
for (let i = 0; i < contentRows; i++) {
|
|
209
|
+
const line = lines[i];
|
|
210
|
+
if (!line) {
|
|
211
|
+
rendered.push(padVisible("", paneRightWidth));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const raw = line.kind === "meta" ? `▸ ${line.text}` : line.text;
|
|
215
|
+
const truncated = truncateVisible(raw, paneRightWidth);
|
|
216
|
+
const colored = line.kind === "stderr" ? theme.stderrText(truncated) : line.kind === "meta" ? theme.accent(truncated) : truncated;
|
|
217
|
+
rendered.push(padVisible(colored, paneRightWidth));
|
|
218
|
+
}
|
|
219
|
+
return rendered;
|
|
220
|
+
}
|
|
221
|
+
// --- degraded (narrow/short terminal) frame ----------------------------------------
|
|
222
|
+
function renderDegradedFrame(state) {
|
|
223
|
+
const { snapshot, theme, width, height, selectedStepKey, now } = state;
|
|
224
|
+
const rows = [];
|
|
225
|
+
const title = snapshot?.workflowTitle ?? "wfm run";
|
|
226
|
+
const statusWord = snapshot ? snapshot.status.replace(/_/g, " ") : "initializing";
|
|
227
|
+
const headerText = snapshot ? theme.runStatus(snapshot.status, `${title} — ${statusWord}`) : theme.dim(`${title} — ${statusWord}`);
|
|
228
|
+
rows.push(fitExact(headerText, width));
|
|
229
|
+
const steps = snapshot?.steps ?? [];
|
|
230
|
+
const stepRowBudget = Math.max(0, height - 1);
|
|
231
|
+
for (let i = 0; i < stepRowBudget; i++) {
|
|
232
|
+
const step = steps[i];
|
|
233
|
+
if (!step) {
|
|
234
|
+
rows.push(padVisible("", width));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const icon = theme.stepIcon(step.status);
|
|
238
|
+
const duration = stepDuration(step, now);
|
|
239
|
+
const raw = `${icon} ${i + 1} ${step.stepKey} ${step.adapter} ${duration}`.trimEnd();
|
|
240
|
+
const selected = step.stepKey === selectedStepKey;
|
|
241
|
+
const fitted = fitExact(raw, width);
|
|
242
|
+
rows.push(selected ? theme.inverse(fitted) : theme.stepStatus(step.status, fitted));
|
|
243
|
+
}
|
|
244
|
+
return rows.slice(0, height);
|
|
245
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RunnerLogChunk } from "../types.js";
|
|
2
|
+
export type TuiLogLineKind = "stdout" | "stderr" | "meta";
|
|
3
|
+
export interface TuiLogLine {
|
|
4
|
+
kind: TuiLogLineKind;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TuiLogStoreOptions {
|
|
8
|
+
maxLinesPerStep?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class TuiLogStore {
|
|
11
|
+
private readonly maxLinesPerStep;
|
|
12
|
+
private readonly buckets;
|
|
13
|
+
private readonly partials;
|
|
14
|
+
constructor(options?: TuiLogStoreOptions);
|
|
15
|
+
appendChunk(log: RunnerLogChunk): void;
|
|
16
|
+
appendMeta(stepKey: string | null | undefined, text: string): void;
|
|
17
|
+
flushPartialLines(): void;
|
|
18
|
+
tail(stepKey: string | null | undefined, count: number): TuiLogLine[];
|
|
19
|
+
lineCount(stepKey: string | null | undefined): number;
|
|
20
|
+
private bucketFor;
|
|
21
|
+
private pushLine;
|
|
22
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { splitLines } from "../runFormat.js";
|
|
2
|
+
const DEFAULT_MAX_LINES_PER_STEP = 2000;
|
|
3
|
+
// Sentinel bucket key for chunks/meta with no stepKey (workflow-level output).
|
|
4
|
+
// A symbol can never collide with a real (string) step key.
|
|
5
|
+
const WORKFLOW_BUCKET_KEY = Symbol("workflow");
|
|
6
|
+
function bucketKey(stepKey) {
|
|
7
|
+
return stepKey ?? WORKFLOW_BUCKET_KEY;
|
|
8
|
+
}
|
|
9
|
+
export class TuiLogStore {
|
|
10
|
+
maxLinesPerStep;
|
|
11
|
+
buckets = new Map();
|
|
12
|
+
// Per-bucket, per-stream partial line remainders awaiting a terminating newline.
|
|
13
|
+
partials = new Map();
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.maxLinesPerStep = options?.maxLinesPerStep ?? DEFAULT_MAX_LINES_PER_STEP;
|
|
16
|
+
}
|
|
17
|
+
appendChunk(log) {
|
|
18
|
+
const key = bucketKey(log.stepKey);
|
|
19
|
+
const streamMap = this.partials.get(key) ?? new Map();
|
|
20
|
+
if (!this.partials.has(key)) {
|
|
21
|
+
this.partials.set(key, streamMap);
|
|
22
|
+
}
|
|
23
|
+
const existing = streamMap.get(log.stream) ?? "";
|
|
24
|
+
const { lines, remainder } = splitLines(existing + log.text);
|
|
25
|
+
streamMap.set(log.stream, remainder);
|
|
26
|
+
if (lines.length === 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const bucket = this.bucketFor(key);
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
this.pushLine(bucket, { kind: log.stream, text: line });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
appendMeta(stepKey, text) {
|
|
35
|
+
const bucket = this.bucketFor(bucketKey(stepKey));
|
|
36
|
+
this.pushLine(bucket, { kind: "meta", text });
|
|
37
|
+
}
|
|
38
|
+
flushPartialLines() {
|
|
39
|
+
for (const [key, streamMap] of this.partials.entries()) {
|
|
40
|
+
for (const [stream, text] of streamMap.entries()) {
|
|
41
|
+
if (!text) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const bucket = this.bucketFor(key);
|
|
45
|
+
this.pushLine(bucket, { kind: stream, text });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.partials.clear();
|
|
49
|
+
}
|
|
50
|
+
tail(stepKey, count) {
|
|
51
|
+
const bucket = this.buckets.get(bucketKey(stepKey));
|
|
52
|
+
if (!bucket || count <= 0) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
if (count >= bucket.length) {
|
|
56
|
+
return bucket.slice();
|
|
57
|
+
}
|
|
58
|
+
return bucket.slice(bucket.length - count);
|
|
59
|
+
}
|
|
60
|
+
lineCount(stepKey) {
|
|
61
|
+
return this.buckets.get(bucketKey(stepKey))?.length ?? 0;
|
|
62
|
+
}
|
|
63
|
+
bucketFor(key) {
|
|
64
|
+
const existing = this.buckets.get(key);
|
|
65
|
+
if (existing) {
|
|
66
|
+
return existing;
|
|
67
|
+
}
|
|
68
|
+
const bucket = [];
|
|
69
|
+
this.buckets.set(key, bucket);
|
|
70
|
+
return bucket;
|
|
71
|
+
}
|
|
72
|
+
pushLine(bucket, line) {
|
|
73
|
+
bucket.push(line);
|
|
74
|
+
while (bucket.length > this.maxLinesPerStep) {
|
|
75
|
+
bucket.shift();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface KeyEvent {
|
|
2
|
+
name?: string;
|
|
3
|
+
ctrl?: boolean;
|
|
4
|
+
meta?: boolean;
|
|
5
|
+
shift?: boolean;
|
|
6
|
+
sequence?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TerminalScreenOptions {
|
|
9
|
+
stdout: NodeJS.WriteStream;
|
|
10
|
+
stdin: NodeJS.ReadStream;
|
|
11
|
+
onKey: (key: KeyEvent) => void;
|
|
12
|
+
onResize: (columns: number, rows: number) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare class TerminalScreen {
|
|
15
|
+
private readonly stdout;
|
|
16
|
+
private readonly stdin;
|
|
17
|
+
private readonly onKey;
|
|
18
|
+
private readonly onResize;
|
|
19
|
+
private started;
|
|
20
|
+
private stopped;
|
|
21
|
+
private wasRawMode;
|
|
22
|
+
private readonly handleKeypress;
|
|
23
|
+
private readonly handleResize;
|
|
24
|
+
private readonly handleSignal;
|
|
25
|
+
constructor(options: TerminalScreenOptions);
|
|
26
|
+
start(): void;
|
|
27
|
+
paint(rows: string[]): void;
|
|
28
|
+
size(): {
|
|
29
|
+
columns: number;
|
|
30
|
+
rows: number;
|
|
31
|
+
};
|
|
32
|
+
stop(): void;
|
|
33
|
+
private readonly exitRestore;
|
|
34
|
+
private restore;
|
|
35
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Terminal session manager for the `wfm run --ui` TUI: owns entering/leaving
|
|
2
|
+
// the alt screen, raw-mode keyboard input, resize notifications, and a single
|
|
3
|
+
// buffered full-repaint write. No timers, no rendering/layout logic here.
|
|
4
|
+
import * as readline from "node:readline";
|
|
5
|
+
import { ALT_SCREEN_ENTER, ALT_SCREEN_LEAVE, CLEAR_LINE_END, CLEAR_SCREEN, CURSOR_HIDE, CURSOR_HOME, CURSOR_SHOW, } from "./ansi.js";
|
|
6
|
+
const SIGNALS_TO_RESTORE_ON = ["SIGINT", "SIGTERM"];
|
|
7
|
+
export class TerminalScreen {
|
|
8
|
+
stdout;
|
|
9
|
+
stdin;
|
|
10
|
+
onKey;
|
|
11
|
+
onResize;
|
|
12
|
+
started = false;
|
|
13
|
+
stopped = false;
|
|
14
|
+
wasRawMode = false;
|
|
15
|
+
handleKeypress = (str, key) => {
|
|
16
|
+
this.onKey(key ?? { sequence: str });
|
|
17
|
+
};
|
|
18
|
+
handleResize = () => {
|
|
19
|
+
const { columns, rows } = this.size();
|
|
20
|
+
this.onResize(columns, rows);
|
|
21
|
+
};
|
|
22
|
+
handleSignal = () => {
|
|
23
|
+
this.restore();
|
|
24
|
+
// Conservative: restore the terminal only. Do not call process.exit and
|
|
25
|
+
// do not swallow the signal for other listeners — re-emitting here would
|
|
26
|
+
// recurse into every listener (including this one) on this signal, so we
|
|
27
|
+
// simply return and let Node's normal signal delivery / other installed
|
|
28
|
+
// listeners proceed. If this is the only SIGINT/SIGTERM listener, the
|
|
29
|
+
// process's default disposition (terminate) still applies once all
|
|
30
|
+
// listeners for the event have run.
|
|
31
|
+
};
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.stdout = options.stdout;
|
|
34
|
+
this.stdin = options.stdin;
|
|
35
|
+
this.onKey = options.onKey;
|
|
36
|
+
this.onResize = options.onResize;
|
|
37
|
+
}
|
|
38
|
+
start() {
|
|
39
|
+
if (this.started) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.started = true;
|
|
43
|
+
this.stopped = false;
|
|
44
|
+
this.stdout.write(ALT_SCREEN_ENTER + CURSOR_HIDE + CLEAR_SCREEN + CURSOR_HOME);
|
|
45
|
+
if (this.stdin.isTTY) {
|
|
46
|
+
this.wasRawMode = true;
|
|
47
|
+
this.stdin.setRawMode(true);
|
|
48
|
+
}
|
|
49
|
+
readline.emitKeypressEvents(this.stdin);
|
|
50
|
+
this.stdin.resume();
|
|
51
|
+
this.stdin.on("keypress", this.handleKeypress);
|
|
52
|
+
this.stdout.on("resize", this.handleResize);
|
|
53
|
+
process.once("exit", this.exitRestore);
|
|
54
|
+
for (const signal of SIGNALS_TO_RESTORE_ON) {
|
|
55
|
+
process.on(signal, this.handleSignal);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
paint(rows) {
|
|
59
|
+
const body = rows.map((row) => row + CLEAR_LINE_END).join("\r\n");
|
|
60
|
+
this.stdout.write(CURSOR_HOME + body);
|
|
61
|
+
}
|
|
62
|
+
size() {
|
|
63
|
+
// Some PTYs report a 0x0 winsize; treat any non-positive dimension as unknown.
|
|
64
|
+
const columns = this.stdout.columns;
|
|
65
|
+
const rows = this.stdout.rows;
|
|
66
|
+
return {
|
|
67
|
+
columns: columns && columns > 0 ? columns : 80,
|
|
68
|
+
rows: rows && rows > 0 ? rows : 24,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
stop() {
|
|
72
|
+
this.restore();
|
|
73
|
+
for (const signal of SIGNALS_TO_RESTORE_ON) {
|
|
74
|
+
process.removeListener(signal, this.handleSignal);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Bound reference so `process.once("exit", ...)` can be a no-op re-run of
|
|
78
|
+
// restore() without re-registering listener removal (exit handlers cannot
|
|
79
|
+
// usefully remove listeners on a dying process, and restore() is safe to
|
|
80
|
+
// call multiple times).
|
|
81
|
+
exitRestore = () => {
|
|
82
|
+
this.restore();
|
|
83
|
+
};
|
|
84
|
+
restore() {
|
|
85
|
+
if (this.stopped) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.stopped = true;
|
|
89
|
+
this.started = false;
|
|
90
|
+
this.stdin.removeListener("keypress", this.handleKeypress);
|
|
91
|
+
this.stdout.removeListener("resize", this.handleResize);
|
|
92
|
+
if (this.wasRawMode) {
|
|
93
|
+
this.stdin.setRawMode(false);
|
|
94
|
+
this.wasRawMode = false;
|
|
95
|
+
}
|
|
96
|
+
this.stdin.pause();
|
|
97
|
+
this.stdout.write(CURSOR_SHOW + ALT_SCREEN_LEAVE);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QaAction, StepRunStatus, WorkflowRunStatus } from "../types.js";
|
|
2
|
+
export interface Theme {
|
|
3
|
+
stepStatus(status: StepRunStatus, text: string): string;
|
|
4
|
+
runStatus(status: WorkflowRunStatus, text: string): string;
|
|
5
|
+
qaAction(action: QaAction, text: string): string;
|
|
6
|
+
stepIcon(status: StepRunStatus): string;
|
|
7
|
+
dim(text: string): string;
|
|
8
|
+
bold(text: string): string;
|
|
9
|
+
inverse(text: string): string;
|
|
10
|
+
accent(text: string): string;
|
|
11
|
+
stderrText(text: string): string;
|
|
12
|
+
}
|
|
13
|
+
export declare function createTheme(enableColor: boolean): Theme;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import picocolors from "picocolors";
|
|
2
|
+
// Icons stay plain text; callers color them via stepStatus so icon and label
|
|
3
|
+
// share one style. `satisfies` keeps the map exhaustive: a new StepRunStatus
|
|
4
|
+
// member fails the build here instead of rendering blank.
|
|
5
|
+
const STEP_ICONS = {
|
|
6
|
+
pending: "·",
|
|
7
|
+
runnable: "·",
|
|
8
|
+
running: "▶",
|
|
9
|
+
waiting_for_approval: "◌",
|
|
10
|
+
succeeded: "✓",
|
|
11
|
+
failed: "✗",
|
|
12
|
+
cancelled: "✗",
|
|
13
|
+
};
|
|
14
|
+
// ANSI-16 palette only. createColors(false) yields identity formatters, so a
|
|
15
|
+
// disabled theme returns input text unchanged (tests rely on this).
|
|
16
|
+
export function createTheme(enableColor) {
|
|
17
|
+
const c = picocolors.createColors(enableColor);
|
|
18
|
+
const dimGray = (text) => c.dim(c.gray(text));
|
|
19
|
+
const dimRed = (text) => c.dim(c.red(text));
|
|
20
|
+
const stepStyles = {
|
|
21
|
+
pending: dimGray,
|
|
22
|
+
runnable: dimGray,
|
|
23
|
+
running: c.cyan,
|
|
24
|
+
waiting_for_approval: c.yellow,
|
|
25
|
+
succeeded: c.green,
|
|
26
|
+
failed: c.red,
|
|
27
|
+
cancelled: dimRed,
|
|
28
|
+
};
|
|
29
|
+
const runStyles = {
|
|
30
|
+
queued: dimGray,
|
|
31
|
+
running: c.cyan,
|
|
32
|
+
waiting_for_approval: c.yellow,
|
|
33
|
+
paused: c.yellow,
|
|
34
|
+
succeeded: c.green,
|
|
35
|
+
failed: c.red,
|
|
36
|
+
cancelled: c.red,
|
|
37
|
+
};
|
|
38
|
+
const qaStyles = {
|
|
39
|
+
PROCEED: c.green,
|
|
40
|
+
RETRY_CURRENT: c.yellow,
|
|
41
|
+
ROLLBACK_PREVIOUS: c.magenta,
|
|
42
|
+
RESTART_ALL: c.red,
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
stepStatus: (status, text) => stepStyles[status](text),
|
|
46
|
+
runStatus: (status, text) => runStyles[status](text),
|
|
47
|
+
qaAction: (action, text) => qaStyles[action](text),
|
|
48
|
+
stepIcon: (status) => STEP_ICONS[status],
|
|
49
|
+
dim: (text) => c.dim(text),
|
|
50
|
+
bold: (text) => c.bold(text),
|
|
51
|
+
inverse: (text) => c.inverse(text),
|
|
52
|
+
accent: (text) => c.cyan(text),
|
|
53
|
+
stderrText: dimRed,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RunEvent, RunObserver, RunSnapshot, RunnerLogChunk, StepDetailSnapshot, WorkflowDefinition } from "../types.js";
|
|
2
|
+
import { type KeyEvent } from "./screen.js";
|
|
3
|
+
export interface TuiSessionControls {
|
|
4
|
+
approve(stepKey?: string, metadata?: {
|
|
5
|
+
actor?: string;
|
|
6
|
+
note?: string;
|
|
7
|
+
source?: string;
|
|
8
|
+
}): {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
reason?: string;
|
|
11
|
+
};
|
|
12
|
+
resume(stepKey?: string, metadata?: {
|
|
13
|
+
actor?: string;
|
|
14
|
+
note?: string;
|
|
15
|
+
source?: string;
|
|
16
|
+
}): {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
};
|
|
20
|
+
cancel(stepKey?: string, metadata?: {
|
|
21
|
+
actor?: string;
|
|
22
|
+
note?: string;
|
|
23
|
+
source?: string;
|
|
24
|
+
}): {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
reason?: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface TuiRunRendererOptions {
|
|
30
|
+
workflow: WorkflowDefinition;
|
|
31
|
+
session: TuiSessionControls;
|
|
32
|
+
attachUrl: string;
|
|
33
|
+
attachToken: string;
|
|
34
|
+
stdout?: NodeJS.WriteStream;
|
|
35
|
+
stdin?: NodeJS.ReadStream;
|
|
36
|
+
redrawMs?: number;
|
|
37
|
+
tickMs?: number;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
colorEnabled?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare class TuiRunRenderer implements RunObserver {
|
|
42
|
+
private readonly workflow;
|
|
43
|
+
private readonly session;
|
|
44
|
+
private readonly attachUrl;
|
|
45
|
+
private readonly attachToken;
|
|
46
|
+
private readonly stdout;
|
|
47
|
+
private readonly stdin;
|
|
48
|
+
private readonly redrawMs;
|
|
49
|
+
private readonly tickMs;
|
|
50
|
+
private readonly nowFn;
|
|
51
|
+
private readonly theme;
|
|
52
|
+
private readonly screen;
|
|
53
|
+
private snapshot;
|
|
54
|
+
private stepDetails;
|
|
55
|
+
private readonly logs;
|
|
56
|
+
private selectedStepKey;
|
|
57
|
+
private follow;
|
|
58
|
+
private statusMessage;
|
|
59
|
+
private statusMessageExpiresAt;
|
|
60
|
+
private dirty;
|
|
61
|
+
private redrawTimer;
|
|
62
|
+
private tickTimer;
|
|
63
|
+
private started;
|
|
64
|
+
private stopped;
|
|
65
|
+
constructor(options: TuiRunRendererOptions);
|
|
66
|
+
start(): void;
|
|
67
|
+
stop(): void;
|
|
68
|
+
onSnapshot(snapshot: RunSnapshot, stepDetails: StepDetailSnapshot[]): void;
|
|
69
|
+
onEvent(event: RunEvent): void;
|
|
70
|
+
onLog(log: RunnerLogChunk): void;
|
|
71
|
+
handleKey: (key: KeyEvent) => void;
|
|
72
|
+
renderNow(): string[];
|
|
73
|
+
private handleResize;
|
|
74
|
+
private handleApprove;
|
|
75
|
+
private handleResume;
|
|
76
|
+
private handleCancel;
|
|
77
|
+
private handleQuit;
|
|
78
|
+
private moveSelection;
|
|
79
|
+
private setStatusMessage;
|
|
80
|
+
private currentStatusMessage;
|
|
81
|
+
private markDirty;
|
|
82
|
+
private composeFrame;
|
|
83
|
+
}
|