substrate-ai 0.2.21 → 0.2.23

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.
@@ -0,0 +1,734 @@
1
+ import * as readline from "node:readline";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ //#region src/tui/ansi.ts
5
+ /**
6
+ * ANSI escape code helpers for TUI rendering.
7
+ *
8
+ * Provides cursor control, color, and screen manipulation utilities.
9
+ */
10
+ const ANSI = {
11
+ RESET: "\x1B[0m",
12
+ BOLD: "\x1B[1m",
13
+ DIM: "\x1B[2m",
14
+ BLACK: "\x1B[30m",
15
+ RED: "\x1B[31m",
16
+ GREEN: "\x1B[32m",
17
+ YELLOW: "\x1B[33m",
18
+ BLUE: "\x1B[34m",
19
+ MAGENTA: "\x1B[35m",
20
+ CYAN: "\x1B[36m",
21
+ WHITE: "\x1B[37m",
22
+ BRIGHT_BLACK: "\x1B[90m",
23
+ BRIGHT_RED: "\x1B[91m",
24
+ BRIGHT_GREEN: "\x1B[92m",
25
+ BRIGHT_YELLOW: "\x1B[93m",
26
+ BRIGHT_BLUE: "\x1B[94m",
27
+ BRIGHT_MAGENTA: "\x1B[95m",
28
+ BRIGHT_CYAN: "\x1B[96m",
29
+ BRIGHT_WHITE: "\x1B[97m",
30
+ BG_BLACK: "\x1B[40m",
31
+ BG_WHITE: "\x1B[47m",
32
+ BG_BLUE: "\x1B[44m",
33
+ BG_BRIGHT_BLACK: "\x1B[100m",
34
+ HIDE_CURSOR: "\x1B[?25l",
35
+ SHOW_CURSOR: "\x1B[?25h",
36
+ CLEAR_SCREEN: "\x1B[2J",
37
+ HOME: "\x1B[H",
38
+ CLEAR_LINE: "\x1B[2K",
39
+ ERASE_DOWN: "\x1B[J",
40
+ ALT_SCREEN_ENTER: "\x1B[?1049h",
41
+ ALT_SCREEN_EXIT: "\x1B[?1049l"
42
+ };
43
+ /** Check if color output is supported. */
44
+ function supportsColor(isTTY) {
45
+ if (process.env.NO_COLOR !== void 0) return false;
46
+ return isTTY;
47
+ }
48
+ /** Wrap text with an ANSI color code (only if color is enabled). */
49
+ function colorize(text, code, useColor) {
50
+ if (!useColor) return text;
51
+ return `${code}${text}${ANSI.RESET}`;
52
+ }
53
+ /** Bold text. */
54
+ function bold(text, useColor) {
55
+ if (!useColor) return text;
56
+ return `${ANSI.BOLD}${text}${ANSI.RESET}`;
57
+ }
58
+ /**
59
+ * Get current terminal dimensions.
60
+ * Returns { cols, rows } with fallback defaults if unavailable.
61
+ */
62
+ function getTerminalSize() {
63
+ const cols = process.stdout.columns ?? 80;
64
+ const rows = process.stdout.rows ?? 24;
65
+ return {
66
+ cols,
67
+ rows
68
+ };
69
+ }
70
+ /**
71
+ * Truncate a string to fit within maxWidth characters.
72
+ * Adds ellipsis if truncated.
73
+ */
74
+ function truncate(text, maxWidth) {
75
+ if (maxWidth <= 0) return "";
76
+ if (text.length <= maxWidth) return text;
77
+ if (maxWidth <= 3) return text.slice(0, maxWidth);
78
+ return text.slice(0, maxWidth - 3) + "...";
79
+ }
80
+ /**
81
+ * Pad or truncate a string to exactly `width` characters.
82
+ */
83
+ function padOrTruncate(text, width, padChar = " ") {
84
+ if (text.length > width) return truncate(text, width);
85
+ return text.padEnd(width, padChar);
86
+ }
87
+
88
+ //#endregion
89
+ //#region src/tui/story-panel.ts
90
+ const COL_KEY_WIDTH = 12;
91
+ const COL_PHASE_WIDTH = 8;
92
+ const COL_STATUS_WIDTH = 30;
93
+ /**
94
+ * Get ANSI color code for a story status.
95
+ */
96
+ function statusColor(status) {
97
+ switch (status) {
98
+ case "pending": return ANSI.BRIGHT_BLACK;
99
+ case "in_progress": return ANSI.YELLOW;
100
+ case "succeeded": return ANSI.GREEN;
101
+ case "failed":
102
+ case "escalated": return ANSI.RED;
103
+ default: return ANSI.RESET;
104
+ }
105
+ }
106
+ /**
107
+ * Get status indicator symbol for a story status.
108
+ */
109
+ function statusSymbol(status) {
110
+ switch (status) {
111
+ case "pending": return "○";
112
+ case "in_progress": return "◉";
113
+ case "succeeded": return "✓";
114
+ case "failed":
115
+ case "escalated": return "✗";
116
+ default: return "·";
117
+ }
118
+ }
119
+ /**
120
+ * Render the story panel header row.
121
+ */
122
+ function renderStoryPanelHeader(useColor) {
123
+ const key = padOrTruncate("STORY", COL_KEY_WIDTH);
124
+ const phase = padOrTruncate("PHASE", COL_PHASE_WIDTH);
125
+ const status = "STATUS";
126
+ const header = ` ${key} ${phase} ${status}`;
127
+ return bold(colorize(header, ANSI.BRIGHT_WHITE, useColor), useColor);
128
+ }
129
+ /**
130
+ * Render a single story row.
131
+ */
132
+ function renderStoryRow(story, isSelected, useColor, width) {
133
+ const symbol = statusSymbol(story.status);
134
+ const color = statusColor(story.status);
135
+ const keyCol = padOrTruncate(story.key, COL_KEY_WIDTH);
136
+ const phaseCol = padOrTruncate(story.phase, COL_PHASE_WIDTH);
137
+ const statusCol = padOrTruncate(story.statusLabel, COL_STATUS_WIDTH);
138
+ const maxWidth = Math.max(width - 2, 10);
139
+ let row = `${symbol} ${keyCol} ${phaseCol} ${statusCol}`;
140
+ row = row.slice(0, maxWidth);
141
+ if (useColor) row = `${color}${row}${ANSI.RESET}`;
142
+ if (isSelected && useColor) row = `${ANSI.BG_BRIGHT_BLACK}${row}${ANSI.RESET}`;
143
+ else if (isSelected) row = `> ${row.slice(2)}`;
144
+ return row;
145
+ }
146
+ /**
147
+ * Render the complete story panel.
148
+ *
149
+ * Returns an array of lines to be written to the terminal.
150
+ */
151
+ function renderStoryPanel(options) {
152
+ const { stories, selectedIndex, useColor, width } = options;
153
+ const lines = [];
154
+ lines.push(bold(colorize(" Story Status", ANSI.CYAN, useColor), useColor));
155
+ lines.push(renderStoryPanelHeader(useColor));
156
+ lines.push(" " + "─".repeat(Math.max(width - 4, 20)));
157
+ if (stories.length === 0) lines.push(colorize(" (no stories)", ANSI.BRIGHT_BLACK, useColor));
158
+ else for (let i = 0; i < stories.length; i++) {
159
+ const story = stories[i];
160
+ if (story !== void 0) lines.push(renderStoryRow(story, i === selectedIndex, useColor, width));
161
+ }
162
+ return lines;
163
+ }
164
+
165
+ //#endregion
166
+ //#region src/tui/log-panel.ts
167
+ /**
168
+ * Format a log entry timestamp to a short HH:MM:SS format.
169
+ */
170
+ function formatTimestamp(ts) {
171
+ try {
172
+ const date = new Date(ts);
173
+ const hh = date.getHours().toString().padStart(2, "0");
174
+ const mm = date.getMinutes().toString().padStart(2, "0");
175
+ const ss = date.getSeconds().toString().padStart(2, "0");
176
+ return `${hh}:${mm}:${ss}`;
177
+ } catch {
178
+ return ts.slice(11, 19);
179
+ }
180
+ }
181
+ /**
182
+ * Render a single log entry line.
183
+ */
184
+ function renderLogEntry(entry, useColor, width) {
185
+ const ts = formatTimestamp(entry.ts);
186
+ const prefix = `[${ts}] [${entry.key}] `;
187
+ const maxMsgWidth = Math.max(width - prefix.length - 2, 10);
188
+ const msg = truncate(entry.msg, maxMsgWidth);
189
+ const line = `${prefix}${msg}`;
190
+ if (useColor) {
191
+ if (entry.level === "warn") return colorize(line, ANSI.YELLOW, useColor);
192
+ const coloredPrefix = colorize(`[${ts}] `, ANSI.BRIGHT_BLACK, useColor) + colorize(`[${entry.key}] `, ANSI.CYAN, useColor);
193
+ return `${coloredPrefix}${msg}`;
194
+ }
195
+ return line;
196
+ }
197
+ /**
198
+ * Render the complete log panel.
199
+ *
200
+ * Auto-scrolls to show the most recent entries (last `maxLines` entries).
201
+ * Returns an array of lines to be written to the terminal.
202
+ */
203
+ function renderLogPanel(options) {
204
+ const { entries, maxLines, useColor, width, filterKey } = options;
205
+ const lines = [];
206
+ const title = filterKey !== void 0 ? ` Logs for ${filterKey}` : " Live Logs";
207
+ lines.push(bold(colorize(title, ANSI.CYAN, useColor), useColor));
208
+ lines.push(" " + "─".repeat(Math.max(width - 4, 20)));
209
+ const filtered = filterKey !== void 0 ? entries.filter((e) => e.key === filterKey) : entries;
210
+ if (filtered.length === 0) {
211
+ lines.push(colorize(" (no log entries)", ANSI.BRIGHT_BLACK, useColor));
212
+ return lines;
213
+ }
214
+ const visibleEntries = filtered.slice(-maxLines);
215
+ for (const entry of visibleEntries) lines.push(" " + renderLogEntry(entry, useColor, width - 2));
216
+ return lines;
217
+ }
218
+
219
+ //#endregion
220
+ //#region src/tui/detail-view.ts
221
+ /**
222
+ * Render the full detail view for a story.
223
+ *
224
+ * Returns an array of lines to be written to the terminal.
225
+ */
226
+ function renderDetailView(options) {
227
+ const { story, allLogs, maxLogLines, useColor, width, height } = options;
228
+ const lines = [];
229
+ const titleBar = ` Story Detail: ${story.key}`;
230
+ lines.push(bold(colorize(titleBar, ANSI.BRIGHT_WHITE, useColor), useColor));
231
+ lines.push(" " + "═".repeat(Math.max(width - 4, 20)));
232
+ lines.push("");
233
+ const phaseLabel = padOrTruncate("Phase:", 12);
234
+ const statusLabel = padOrTruncate("Status:", 12);
235
+ const cyclesLabel = padOrTruncate("Review Cycles:", 12);
236
+ lines.push(` ${bold(phaseLabel, useColor)} ${colorize(story.phase, ANSI.CYAN, useColor)}`);
237
+ lines.push(` ${bold(statusLabel, useColor)} ${colorize(story.statusLabel, ANSI.WHITE, useColor)}`);
238
+ lines.push(` ${bold(cyclesLabel, useColor)} ${story.reviewCycles}`);
239
+ if (story.escalationReason !== void 0) lines.push(` ${bold(padOrTruncate("Escalated:", 12), useColor)} ${colorize(story.escalationReason, ANSI.RED, useColor)}`);
240
+ lines.push("");
241
+ lines.push(" " + "─".repeat(Math.max(width - 4, 20)));
242
+ const availableLogLines = Math.max(height - lines.length - 4, 3);
243
+ const logLines = renderLogPanel({
244
+ entries: allLogs,
245
+ maxLines: Math.min(maxLogLines, availableLogLines),
246
+ useColor,
247
+ width,
248
+ filterKey: story.key
249
+ });
250
+ lines.push(...logLines);
251
+ lines.push("");
252
+ lines.push(colorize(" [Esc] Back to overview", ANSI.BRIGHT_BLACK, useColor));
253
+ return lines;
254
+ }
255
+
256
+ //#endregion
257
+ //#region src/tui/help-overlay.ts
258
+ /** All keyboard bindings shown in the help overlay. */
259
+ const KEY_BINDINGS = [
260
+ {
261
+ key: "↑ / ↓",
262
+ description: "Navigate between stories"
263
+ },
264
+ {
265
+ key: "Enter",
266
+ description: "Drill into selected story detail view"
267
+ },
268
+ {
269
+ key: "Esc",
270
+ description: "Return to overview from detail view"
271
+ },
272
+ {
273
+ key: "q",
274
+ description: "Quit TUI (pipeline completes in background)"
275
+ },
276
+ {
277
+ key: "?",
278
+ description: "Show/hide this help overlay"
279
+ }
280
+ ];
281
+ /**
282
+ * Render the help overlay.
283
+ *
284
+ * Returns an array of lines to be written to the terminal.
285
+ */
286
+ function renderHelpOverlay(options) {
287
+ const { useColor, width } = options;
288
+ const lines = [];
289
+ const boxWidth = Math.min(56, Math.max(width - 4, 30));
290
+ const horizontalBorder = "─".repeat(boxWidth - 2);
291
+ lines.push(colorize(` ┌${horizontalBorder}┐`, ANSI.CYAN, useColor));
292
+ lines.push(colorize(` │${padOrTruncate(" Keyboard Shortcuts", boxWidth - 2)}│`, ANSI.CYAN, useColor));
293
+ lines.push(colorize(` ├${horizontalBorder}┤`, ANSI.CYAN, useColor));
294
+ for (const binding of KEY_BINDINGS) {
295
+ const keyPart = bold(padOrTruncate(binding.key, 12), useColor);
296
+ const descPart = padOrTruncate(binding.description, boxWidth - 16);
297
+ lines.push(useColor ? `${ANSI.CYAN} │${ANSI.RESET} ${keyPart} ${descPart}${ANSI.CYAN}│${ANSI.RESET}` : ` │ ${keyPart} ${descPart}│`);
298
+ }
299
+ lines.push(colorize(` └${horizontalBorder}┘`, ANSI.CYAN, useColor));
300
+ lines.push(colorize(" Press ? to close", ANSI.BRIGHT_BLACK, useColor));
301
+ return lines;
302
+ }
303
+
304
+ //#endregion
305
+ //#region src/tui/app.ts
306
+ /** Minimum terminal width for TUI to render properly. */
307
+ const MIN_COLS = 80;
308
+ /** Minimum terminal height for TUI to render properly. */
309
+ const MIN_ROWS = 24;
310
+ /** Maximum log entries to keep in memory. */
311
+ const MAX_LOG_ENTRIES = 500;
312
+ function mapPhaseToLabel(phase) {
313
+ switch (phase) {
314
+ case "create-story": return "create";
315
+ case "dev-story": return "dev";
316
+ case "code-review": return "review";
317
+ case "fix": return "fix";
318
+ default: return "wait";
319
+ }
320
+ }
321
+ function mapPhaseToStatus(phase, eventStatus) {
322
+ if (eventStatus === "failed") return "failed";
323
+ if (eventStatus === "in_progress") return "in_progress";
324
+ if (eventStatus === "complete") {
325
+ if (phase === "done") return "succeeded";
326
+ return "in_progress";
327
+ }
328
+ return "pending";
329
+ }
330
+ function makeStatusLabel(phase, eventStatus, verdict) {
331
+ if (eventStatus === "failed") return "failed";
332
+ if (eventStatus === "in_progress") switch (phase) {
333
+ case "create": return "creating story...";
334
+ case "dev": return "implementing...";
335
+ case "review": return "reviewing...";
336
+ case "fix": return "fixing issues...";
337
+ default: return "in progress...";
338
+ }
339
+ if (eventStatus === "complete") switch (phase) {
340
+ case "create": return "story created";
341
+ case "dev": return "implemented";
342
+ case "review": return verdict !== void 0 ? `reviewed (${verdict})` : "reviewed";
343
+ case "fix": return "fixes applied";
344
+ default: return "complete";
345
+ }
346
+ return "queued";
347
+ }
348
+ /**
349
+ * Factory that creates a TUI application instance.
350
+ *
351
+ * @param output - Writable stream for rendering (typically process.stdout)
352
+ * @param input - Readable stream for keyboard input (typically process.stdin)
353
+ */
354
+ function createTuiApp(output, input) {
355
+ const isTTY = output.isTTY === true;
356
+ const useColor = supportsColor(isTTY);
357
+ const state = {
358
+ headerLine: "",
359
+ storyOrder: [],
360
+ stories: new Map(),
361
+ logs: [],
362
+ selectedIndex: 0,
363
+ view: "overview",
364
+ pipelineComplete: false
365
+ };
366
+ let exitResolve;
367
+ const exitPromise = new Promise((resolve) => {
368
+ exitResolve = resolve;
369
+ });
370
+ let rl;
371
+ function write(text) {
372
+ try {
373
+ output.write(text);
374
+ } catch {}
375
+ }
376
+ function checkTerminalSize() {
377
+ const { cols, rows } = getTerminalSize();
378
+ if (cols < MIN_COLS || rows < MIN_ROWS) {
379
+ write(ANSI.CLEAR_SCREEN + ANSI.HOME);
380
+ write(colorize(`Terminal too small: ${cols}x${rows} (minimum ${MIN_COLS}x${MIN_ROWS})\n`, ANSI.YELLOW, useColor));
381
+ write("Please resize your terminal.\n");
382
+ return false;
383
+ }
384
+ return true;
385
+ }
386
+ function render() {
387
+ const { cols, rows } = getTerminalSize();
388
+ if (cols < MIN_COLS || rows < MIN_ROWS) {
389
+ checkTerminalSize();
390
+ return;
391
+ }
392
+ write(ANSI.CLEAR_SCREEN + ANSI.HOME);
393
+ const lines = [];
394
+ if (state.view === "help") renderHelpView(lines, cols, rows);
395
+ else if (state.view === "detail") renderDetailViewLayout(lines, cols, rows);
396
+ else renderOverviewLayout(lines, cols, rows);
397
+ for (const line of lines) write(line + "\n");
398
+ }
399
+ function renderOverviewLayout(lines, cols, rows) {
400
+ lines.push(bold(colorize(" substrate run --tui", ANSI.BRIGHT_WHITE, useColor), useColor));
401
+ if (state.headerLine) lines.push(colorize(` ${state.headerLine}`, ANSI.BRIGHT_BLACK, useColor));
402
+ lines.push("");
403
+ const storyPanelHeight = Math.max(Math.floor(rows * .4), 6);
404
+ const storyPanelLines = renderStoryPanel({
405
+ stories: Array.from(state.storyOrder).map((k) => state.stories.get(k)).filter((s) => s !== void 0),
406
+ selectedIndex: state.selectedIndex,
407
+ useColor,
408
+ width: cols
409
+ });
410
+ lines.push(...storyPanelLines.slice(0, storyPanelHeight));
411
+ lines.push("");
412
+ lines.push(" " + "─".repeat(Math.max(cols - 4, 20)));
413
+ lines.push("");
414
+ const usedLines = lines.length + 3;
415
+ const logPanelHeight = Math.max(rows - usedLines, 3);
416
+ const logLines = renderLogPanel({
417
+ entries: state.logs,
418
+ maxLines: logPanelHeight,
419
+ useColor,
420
+ width: cols
421
+ });
422
+ lines.push(...logLines);
423
+ lines.push("");
424
+ const footerParts = [
425
+ colorize("[↑↓] Navigate", ANSI.BRIGHT_BLACK, useColor),
426
+ colorize("[Enter] Details", ANSI.BRIGHT_BLACK, useColor),
427
+ colorize("[q] Quit", ANSI.BRIGHT_BLACK, useColor),
428
+ colorize("[?] Help", ANSI.BRIGHT_BLACK, useColor)
429
+ ];
430
+ if (state.pipelineComplete) lines.push(colorize(" Pipeline complete. Press q to exit.", ANSI.GREEN, useColor));
431
+ lines.push(" " + footerParts.join(" "));
432
+ }
433
+ function renderDetailViewLayout(lines, cols, rows) {
434
+ const selectedKey = state.storyOrder[state.selectedIndex];
435
+ const story = selectedKey !== void 0 ? state.stories.get(selectedKey) : void 0;
436
+ if (story === void 0) {
437
+ lines.push(" No story selected. Press Esc to go back.");
438
+ return;
439
+ }
440
+ const detailLines = renderDetailView({
441
+ story,
442
+ allLogs: state.logs,
443
+ maxLogLines: Math.max(rows - 10, 5),
444
+ useColor,
445
+ width: cols,
446
+ height: rows
447
+ });
448
+ lines.push(...detailLines);
449
+ }
450
+ function renderHelpView(lines, cols, _rows) {
451
+ lines.push(bold(colorize(" substrate run --tui", ANSI.BRIGHT_WHITE, useColor), useColor));
452
+ lines.push("");
453
+ const helpLines = renderHelpOverlay({
454
+ useColor,
455
+ width: cols
456
+ });
457
+ lines.push(...helpLines);
458
+ }
459
+ function setupKeyboard() {
460
+ if (input.isTTY === true) try {
461
+ input.setRawMode(true);
462
+ } catch {}
463
+ rl = readline.createInterface({
464
+ input,
465
+ terminal: false
466
+ });
467
+ readline.emitKeypressEvents(input);
468
+ const stdin = input;
469
+ const onKeypress = (chunk, key) => {
470
+ if (key === void 0) return;
471
+ handleKeypress(key);
472
+ };
473
+ stdin.on("keypress", onKeypress);
474
+ rl.on("close", () => {
475
+ exit();
476
+ });
477
+ }
478
+ function handleKeypress(key) {
479
+ if (key.ctrl === true && key.name === "c") {
480
+ exit();
481
+ return;
482
+ }
483
+ switch (key.name) {
484
+ case "q":
485
+ exit();
486
+ return;
487
+ case "up":
488
+ if (state.view === "overview") {
489
+ state.selectedIndex = Math.max(0, state.selectedIndex - 1);
490
+ render();
491
+ }
492
+ break;
493
+ case "down":
494
+ if (state.view === "overview") {
495
+ state.selectedIndex = Math.min(Math.max(0, state.storyOrder.length - 1), state.selectedIndex + 1);
496
+ render();
497
+ }
498
+ break;
499
+ case "return":
500
+ case "enter":
501
+ if (state.view === "overview" && state.storyOrder.length > 0) {
502
+ state.view = "detail";
503
+ render();
504
+ }
505
+ break;
506
+ case "escape":
507
+ if (state.view === "detail" || state.view === "help") {
508
+ state.view = "overview";
509
+ render();
510
+ }
511
+ break;
512
+ case "?":
513
+ state.view = state.view === "help" ? "overview" : "help";
514
+ render();
515
+ break;
516
+ default:
517
+ if (key.sequence === "?") {
518
+ state.view = state.view === "help" ? "overview" : "help";
519
+ render();
520
+ }
521
+ break;
522
+ }
523
+ }
524
+ function onResize() {
525
+ render();
526
+ }
527
+ function init() {
528
+ write(ANSI.ALT_SCREEN_ENTER);
529
+ write(ANSI.HIDE_CURSOR);
530
+ setupKeyboard();
531
+ const resizeEmitter = typeof output.on === "function" ? output : process.stdout;
532
+ resizeEmitter.on("resize", onResize);
533
+ render();
534
+ }
535
+ function exit() {
536
+ cleanup();
537
+ if (exitResolve !== void 0) {
538
+ exitResolve();
539
+ exitResolve = void 0;
540
+ }
541
+ }
542
+ function cleanup() {
543
+ write(ANSI.SHOW_CURSOR);
544
+ write(ANSI.ALT_SCREEN_EXIT);
545
+ const resizeEmitter = typeof output.on === "function" ? output : process.stdout;
546
+ resizeEmitter.off("resize", onResize);
547
+ if (rl !== void 0) {
548
+ try {
549
+ rl.close();
550
+ } catch {}
551
+ rl = void 0;
552
+ }
553
+ if (input.isTTY === true) try {
554
+ input.setRawMode(false);
555
+ } catch {}
556
+ }
557
+ function handleEvent(event) {
558
+ switch (event.type) {
559
+ case "pipeline:start":
560
+ state.headerLine = `${event.stories.length} stories, concurrency ${event.concurrency}, run ${event.run_id.slice(0, 8)}...`;
561
+ for (const key of event.stories) {
562
+ state.storyOrder.push(key);
563
+ state.stories.set(key, {
564
+ key,
565
+ phase: "wait",
566
+ status: "pending",
567
+ statusLabel: "queued",
568
+ reviewCycles: 0
569
+ });
570
+ }
571
+ break;
572
+ case "story:phase": {
573
+ let story = state.stories.get(event.key);
574
+ if (story === void 0) {
575
+ state.storyOrder.push(event.key);
576
+ story = {
577
+ key: event.key,
578
+ phase: "wait",
579
+ status: "pending",
580
+ statusLabel: "queued",
581
+ reviewCycles: 0
582
+ };
583
+ state.stories.set(event.key, story);
584
+ }
585
+ const phaseLabel = mapPhaseToLabel(event.phase);
586
+ story.phase = phaseLabel;
587
+ story.status = mapPhaseToStatus(phaseLabel, event.status);
588
+ story.statusLabel = makeStatusLabel(phaseLabel, event.status, event.verdict);
589
+ break;
590
+ }
591
+ case "story:done": {
592
+ let story = state.stories.get(event.key);
593
+ if (story === void 0) {
594
+ state.storyOrder.push(event.key);
595
+ story = {
596
+ key: event.key,
597
+ phase: "done",
598
+ status: event.result === "success" ? "succeeded" : "failed",
599
+ statusLabel: event.result === "success" ? "SHIP_IT" : "FAILED",
600
+ reviewCycles: event.review_cycles
601
+ };
602
+ state.stories.set(event.key, story);
603
+ } else {
604
+ story.phase = event.result === "success" ? "done" : "failed";
605
+ story.status = event.result === "success" ? "succeeded" : "failed";
606
+ const cycleWord = event.review_cycles === 1 ? "cycle" : "cycles";
607
+ story.statusLabel = event.result === "success" ? `SHIP_IT (${event.review_cycles} ${cycleWord})` : "FAILED";
608
+ story.reviewCycles = event.review_cycles;
609
+ }
610
+ break;
611
+ }
612
+ case "story:escalation": {
613
+ let story = state.stories.get(event.key);
614
+ if (story === void 0) {
615
+ state.storyOrder.push(event.key);
616
+ story = {
617
+ key: event.key,
618
+ phase: "escalated",
619
+ status: "escalated",
620
+ statusLabel: `ESCALATED — ${event.reason}`,
621
+ reviewCycles: event.cycles,
622
+ escalationReason: event.reason
623
+ };
624
+ state.stories.set(event.key, story);
625
+ } else {
626
+ story.phase = "escalated";
627
+ story.status = "escalated";
628
+ story.statusLabel = `ESCALATED — ${event.reason}`;
629
+ story.reviewCycles = event.cycles;
630
+ story.escalationReason = event.reason;
631
+ }
632
+ break;
633
+ }
634
+ case "story:warn": {
635
+ const logEntry = {
636
+ ts: event.ts,
637
+ key: event.key,
638
+ msg: `[WARN] ${event.msg}`,
639
+ level: "warn"
640
+ };
641
+ state.logs.push(logEntry);
642
+ if (state.logs.length > MAX_LOG_ENTRIES) state.logs.splice(0, state.logs.length - MAX_LOG_ENTRIES);
643
+ break;
644
+ }
645
+ case "story:log": {
646
+ const logEntry = {
647
+ ts: event.ts,
648
+ key: event.key,
649
+ msg: event.msg,
650
+ level: "log"
651
+ };
652
+ state.logs.push(logEntry);
653
+ if (state.logs.length > MAX_LOG_ENTRIES) state.logs.splice(0, state.logs.length - MAX_LOG_ENTRIES);
654
+ break;
655
+ }
656
+ case "pipeline:complete":
657
+ state.pipelineComplete = true;
658
+ state.completionStats = {
659
+ succeeded: event.succeeded,
660
+ failed: event.failed,
661
+ escalated: event.escalated
662
+ };
663
+ setTimeout(() => {
664
+ if (exitResolve !== void 0) render();
665
+ }, 500);
666
+ break;
667
+ default: break;
668
+ }
669
+ render();
670
+ }
671
+ init();
672
+ return {
673
+ handleEvent,
674
+ cleanup,
675
+ waitForExit: () => exitPromise
676
+ };
677
+ }
678
+ /**
679
+ * Check whether the TUI can run in the current environment.
680
+ *
681
+ * Returns true if stdout is a TTY, false otherwise.
682
+ * If false, the caller should print a warning and use default output.
683
+ */
684
+ function isTuiCapable() {
685
+ return process.stdout.isTTY === true;
686
+ }
687
+ /**
688
+ * Print the non-TTY fallback warning message.
689
+ */
690
+ function printNonTtyWarning() {
691
+ process.stderr.write("TUI requires an interactive terminal. Falling back to default output.\n");
692
+ }
693
+
694
+ //#endregion
695
+ //#region src/core/event-bus.ts
696
+ /**
697
+ * Concrete implementation of TypedEventBus backed by Node.js EventEmitter.
698
+ *
699
+ * @example
700
+ * const bus = new TypedEventBusImpl()
701
+ * bus.on('task:complete', ({ taskId, result }) => {
702
+ * console.log(`Task ${taskId} finished`)
703
+ * })
704
+ * bus.emit('task:complete', { taskId: 'abc', result: { exitCode: 0 } })
705
+ */
706
+ var TypedEventBusImpl = class {
707
+ _emitter;
708
+ constructor() {
709
+ this._emitter = new EventEmitter();
710
+ this._emitter.setMaxListeners(100);
711
+ }
712
+ emit(event, payload) {
713
+ this._emitter.emit(event, payload);
714
+ }
715
+ on(event, handler) {
716
+ this._emitter.on(event, handler);
717
+ }
718
+ off(event, handler) {
719
+ this._emitter.off(event, handler);
720
+ }
721
+ };
722
+ /**
723
+ * Create a new TypedEventBus instance.
724
+ *
725
+ * @example
726
+ * const bus = createEventBus()
727
+ */
728
+ function createEventBus() {
729
+ return new TypedEventBusImpl();
730
+ }
731
+
732
+ //#endregion
733
+ export { createEventBus, createTuiApp, isTuiCapable, printNonTtyWarning };
734
+ //# sourceMappingURL=event-bus-CAvDMst7.js.map