@alchemy.run/test 0.0.0-stage → 2.0.0-beta.85

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 (119) hide show
  1. package/LICENSE +201 -0
  2. package/bin/alchemy-test.ts +2 -0
  3. package/lib/Alchemy.d.ts +78 -0
  4. package/lib/Alchemy.d.ts.map +1 -0
  5. package/lib/Alchemy.js +194 -0
  6. package/lib/Alchemy.js.map +1 -0
  7. package/lib/Api.d.ts +134 -0
  8. package/lib/Api.d.ts.map +1 -0
  9. package/lib/Api.js +245 -0
  10. package/lib/Api.js.map +1 -0
  11. package/lib/Bun.d.ts +67 -0
  12. package/lib/Bun.d.ts.map +1 -0
  13. package/lib/Bun.js +143 -0
  14. package/lib/Bun.js.map +1 -0
  15. package/lib/Capture.d.ts +10 -0
  16. package/lib/Capture.d.ts.map +1 -0
  17. package/lib/Capture.js +68 -0
  18. package/lib/Capture.js.map +1 -0
  19. package/lib/Core.d.ts +287 -0
  20. package/lib/Core.d.ts.map +1 -0
  21. package/lib/Core.js +429 -0
  22. package/lib/Core.js.map +1 -0
  23. package/lib/DevCli.d.ts +47 -0
  24. package/lib/DevCli.d.ts.map +1 -0
  25. package/lib/DevCli.js +181 -0
  26. package/lib/DevCli.js.map +1 -0
  27. package/lib/Expect.d.ts +84 -0
  28. package/lib/Expect.d.ts.map +1 -0
  29. package/lib/Expect.js +518 -0
  30. package/lib/Expect.js.map +1 -0
  31. package/lib/FileLog.d.ts +27 -0
  32. package/lib/FileLog.d.ts.map +1 -0
  33. package/lib/FileLog.js +136 -0
  34. package/lib/FileLog.js.map +1 -0
  35. package/lib/Http.d.ts +109 -0
  36. package/lib/Http.d.ts.map +1 -0
  37. package/lib/Http.js +123 -0
  38. package/lib/Http.js.map +1 -0
  39. package/lib/Model.d.ts +98 -0
  40. package/lib/Model.d.ts.map +1 -0
  41. package/lib/Model.js +59 -0
  42. package/lib/Model.js.map +1 -0
  43. package/lib/PlainReporter.d.ts +19 -0
  44. package/lib/PlainReporter.d.ts.map +1 -0
  45. package/lib/PlainReporter.js +348 -0
  46. package/lib/PlainReporter.js.map +1 -0
  47. package/lib/Plan.d.ts +19 -0
  48. package/lib/Plan.d.ts.map +1 -0
  49. package/lib/Plan.js +32 -0
  50. package/lib/Plan.js.map +1 -0
  51. package/lib/Registry.d.ts +34 -0
  52. package/lib/Registry.d.ts.map +1 -0
  53. package/lib/Registry.js +80 -0
  54. package/lib/Registry.js.map +1 -0
  55. package/lib/Reporter.d.ts +160 -0
  56. package/lib/Reporter.d.ts.map +1 -0
  57. package/lib/Reporter.js +9 -0
  58. package/lib/Reporter.js.map +1 -0
  59. package/lib/Runner.d.ts +51 -0
  60. package/lib/Runner.d.ts.map +1 -0
  61. package/lib/Runner.js +1052 -0
  62. package/lib/Runner.js.map +1 -0
  63. package/lib/StrayOutput.d.ts +8 -0
  64. package/lib/StrayOutput.d.ts.map +1 -0
  65. package/lib/StrayOutput.js +259 -0
  66. package/lib/StrayOutput.js.map +1 -0
  67. package/lib/Tags.d.ts +17 -0
  68. package/lib/Tags.d.ts.map +1 -0
  69. package/lib/Tags.js +84 -0
  70. package/lib/Tags.js.map +1 -0
  71. package/lib/TestState.d.ts +12 -0
  72. package/lib/TestState.d.ts.map +1 -0
  73. package/lib/TestState.js +15 -0
  74. package/lib/TestState.js.map +1 -0
  75. package/lib/Tui.d.ts +4 -0
  76. package/lib/Tui.d.ts.map +1 -0
  77. package/lib/Tui.js +1137 -0
  78. package/lib/Tui.js.map +1 -0
  79. package/lib/Vitest.d.ts +58 -0
  80. package/lib/Vitest.d.ts.map +1 -0
  81. package/lib/Vitest.js +132 -0
  82. package/lib/Vitest.js.map +1 -0
  83. package/lib/WorktreeStage.d.ts +17 -0
  84. package/lib/WorktreeStage.d.ts.map +1 -0
  85. package/lib/WorktreeStage.js +48 -0
  86. package/lib/WorktreeStage.js.map +1 -0
  87. package/lib/cli.d.ts +2 -0
  88. package/lib/cli.d.ts.map +1 -0
  89. package/lib/cli.js +222 -0
  90. package/lib/cli.js.map +1 -0
  91. package/lib/index.d.ts +14 -0
  92. package/lib/index.d.ts.map +1 -0
  93. package/lib/index.js +12 -0
  94. package/lib/index.js.map +1 -0
  95. package/package.json +88 -3
  96. package/src/Alchemy.ts +335 -0
  97. package/src/Api.ts +521 -0
  98. package/src/Bun.ts +256 -0
  99. package/src/Capture.ts +72 -0
  100. package/src/Core.ts +872 -0
  101. package/src/DevCli.ts +229 -0
  102. package/src/Expect.ts +763 -0
  103. package/src/FileLog.ts +158 -0
  104. package/src/Http.ts +201 -0
  105. package/src/Model.ts +160 -0
  106. package/src/PlainReporter.ts +426 -0
  107. package/src/Plan.ts +45 -0
  108. package/src/Registry.ts +101 -0
  109. package/src/Reporter.ts +171 -0
  110. package/src/Runner.ts +1341 -0
  111. package/src/StrayOutput.ts +283 -0
  112. package/src/Tags.ts +100 -0
  113. package/src/TestState.ts +35 -0
  114. package/src/Tui.ts +1272 -0
  115. package/src/Vitest.ts +239 -0
  116. package/src/WorktreeStage.ts +45 -0
  117. package/src/cli.ts +330 -0
  118. package/src/index.ts +57 -0
  119. package/README.md +0 -3
package/src/Tui.ts ADDED
@@ -0,0 +1,1272 @@
1
+ /**
2
+ * Interactive opentui reporter — a k9s-style live view of the run.
3
+ *
4
+ * Layout:
5
+ * header — run stats (pass/fail/running/queued, elapsed, import progress)
6
+ * list — files with their tests nested inside (expand with enter / l)
7
+ * detail — Enter on a test opens its error + captured output
8
+ * footer — key hints / filter input / status toggles
9
+ *
10
+ * Keys (vim-style):
11
+ * j/k or arrows move one line space / ^d ^u / ^f ^b page / half page
12
+ * gg / G top / bottom / live type-to-filter
13
+ * enter open test / toggle h / l collapse / expand file
14
+ * e / c expand / collapse all visible files
15
+ * esc back / clear filter p f n s toggle pass/fail/pending/skipped
16
+ * r retry test / file R retry all failed tests
17
+ * x kill running test
18
+ * y copy error+output q quit
19
+ *
20
+ * Mouse: the wheel scrolls the viewable content without moving the selected
21
+ * line; clicking a row selects it. Drag-to-select text works in the detail
22
+ * pane (enter) and copies to the clipboard on release (OSC52 + pbcopy);
23
+ * list rows are not text-selectable — they're a recycled pool, and an
24
+ * anchored selection highlight would leave residue as rows repaint (use `y`
25
+ * to copy a row's details instead).
26
+ *
27
+ * Colors follow the terminal theme: default foreground/background
28
+ * everywhere, ANSI palette colors only for status glyphs, and a subtle
29
+ * theme-aware background for the selected row (neovim-style).
30
+ *
31
+ * Performance: the list is a fixed pool of Text rows (one per terminal
32
+ * line) painted from the visible window only — updates are O(window), never
33
+ * O(total tests).
34
+ */
35
+ import {
36
+ BoxRenderable,
37
+ bg,
38
+ CliRenderEvents,
39
+ createCliRenderer,
40
+ dim,
41
+ green,
42
+ red,
43
+ ScrollBoxRenderable,
44
+ strikethrough,
45
+ StyledText,
46
+ stringToStyledText,
47
+ TextRenderable,
48
+ yellow,
49
+ type CliRenderer,
50
+ type KeyEvent,
51
+ type MouseEvent,
52
+ type Selection,
53
+ type TextChunk,
54
+ } from "@opentui/core";
55
+ import * as Effect from "effect/Effect";
56
+ import * as Layer from "effect/Layer";
57
+ import type { LogEntry } from "./Model.ts";
58
+ import {
59
+ Reporter,
60
+ type RunController,
61
+ type RunSummary,
62
+ type TestEvent,
63
+ type TestMeta,
64
+ type TestResult,
65
+ } from "./Reporter.ts";
66
+ import { captureStrayOutput } from "./StrayOutput.ts";
67
+
68
+ type Status = "queued" | "running" | "pass" | "fail" | "skip" | "todo";
69
+
70
+ /** Visibility toggle groups (`p` / `f` / `n` / `s`). */
71
+ type StatusGroup = "pass" | "fail" | "pending" | "skipped";
72
+
73
+ const groupOf = (status: Status): StatusGroup => {
74
+ switch (status) {
75
+ case "pass":
76
+ return "pass";
77
+ case "fail":
78
+ return "fail";
79
+ case "queued":
80
+ case "running":
81
+ return "pending";
82
+ case "skip":
83
+ case "todo":
84
+ return "skipped";
85
+ }
86
+ };
87
+
88
+ const GLYPH: Record<Status, string> = {
89
+ queued: "·",
90
+ running: "◐",
91
+ pass: "✓",
92
+ fail: "✗",
93
+ skip: "↓",
94
+ todo: "○",
95
+ };
96
+
97
+ /** ANSI palette chunk for a status glyph — follows the terminal theme. */
98
+ const statusChunk = (status: Status, text: string): TextChunk => {
99
+ switch (status) {
100
+ case "pass":
101
+ return green(text);
102
+ case "fail":
103
+ return red(text);
104
+ case "running":
105
+ case "todo":
106
+ // todo is warning-colored: unimplemented coverage must not blend in.
107
+ return yellow(text);
108
+ case "queued":
109
+ case "skip":
110
+ return dim(text);
111
+ }
112
+ };
113
+
114
+ const formatDuration = (ms: number): string =>
115
+ ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
116
+
117
+ // Captured output is replayed verbatim (ANSI is stripped at capture time).
118
+ const formatLogs = (logs: ReadonlyArray<LogEntry>): string =>
119
+ logs.map((log) => log.message).join("\n");
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Model: files with nested tests
123
+ // ---------------------------------------------------------------------------
124
+
125
+ interface Entry {
126
+ readonly meta: TestMeta;
127
+ readonly file: FileNode;
128
+ status: Status;
129
+ result?: TestResult;
130
+ /** Set on TestStart; drives the live elapsed timer on running rows. */
131
+ startedAt?: number;
132
+ /** LIVE captured-output buffer while running (runner appends in place). */
133
+ liveLogs?: ReadonlyArray<LogEntry>;
134
+ /** Cached plain-text row label — recomputed only on status/result change. */
135
+ label: string;
136
+ }
137
+
138
+ interface FileNode {
139
+ readonly file: string;
140
+ readonly tests: Array<Entry>;
141
+ readonly counts: Record<Status, number>;
142
+ /** Manual expand/collapse state; collapsed until deliberately expanded. */
143
+ expanded: boolean | undefined;
144
+ /** FileStart seen — the file's fiber is executing. */
145
+ started: boolean;
146
+ /** Currently-running file hook ("deploying…" feedback), if any. */
147
+ hook: "beforeAll" | "afterAll" | undefined;
148
+ hookStartedAt: number;
149
+ }
150
+
151
+ type Node =
152
+ | {
153
+ readonly kind: "file";
154
+ readonly node: FileNode;
155
+ /** Aggregate status of the tests that survived the active filters. */
156
+ readonly status: Status;
157
+ }
158
+ | { readonly kind: "test"; readonly entry: Entry };
159
+
160
+ const makeCounts = (): Record<Status, number> => ({
161
+ queued: 0,
162
+ running: 0,
163
+ pass: 0,
164
+ fail: 0,
165
+ skip: 0,
166
+ todo: 0,
167
+ });
168
+
169
+ const entryLabel = (entry: Entry): string =>
170
+ `${entry.meta.titlePath.join(" > ")}` +
171
+ (entry.result !== undefined && entry.status !== "queued" && entry.status !== "running"
172
+ ? ` (${formatDuration(entry.result.durationMs)})`
173
+ : "");
174
+
175
+ const fileSummary = (node: FileNode): string => {
176
+ const c = node.counts;
177
+ const parts = [
178
+ ...(c.fail > 0 ? [`${c.fail} failed`] : []),
179
+ ...(c.running > 0 ? [`${c.running} running`] : []),
180
+ `${c.pass}/${node.tests.length - c.skip - c.todo} passed`,
181
+ ...(c.skip > 0 ? [`${c.skip} skipped`] : []),
182
+ ...(c.todo > 0 ? [`${c.todo} todo`] : []),
183
+ ];
184
+ return parts.join(" · ");
185
+ };
186
+
187
+ const visibleFileStatus = (node: FileNode, tests: ReadonlyArray<Entry>): Status => {
188
+ if (tests.some((test) => test.status === "fail")) return "fail";
189
+ if (
190
+ node.hook !== undefined &&
191
+ tests.some((test) => test.status === "running" || test.status === "queued")
192
+ ) {
193
+ return "running";
194
+ }
195
+ if (tests.some((test) => test.status === "running")) return "running";
196
+ if (tests.some((test) => test.status === "queued")) return "queued";
197
+ if (tests.some((test) => test.status === "pass")) return "pass";
198
+ if (tests.some((test) => test.status === "todo")) return "todo";
199
+ return "skip";
200
+ };
201
+
202
+ /** Why a file's tests are still gray — surfaced on the file row. */
203
+ const filePhase = (node: FileNode): string | undefined => {
204
+ if (node.hook !== undefined) {
205
+ return `${node.hook} running (${formatDuration(Date.now() - node.hookStartedAt)})…`;
206
+ }
207
+ if (!node.started && node.counts.queued > 0) {
208
+ return "waiting for a worker slot…";
209
+ }
210
+ return undefined;
211
+ };
212
+
213
+ class TuiState {
214
+ readonly byId = new Map<string, Entry>();
215
+ readonly files = new Map<string, FileNode>();
216
+ readonly fileOrder: Array<FileNode> = [];
217
+ readonly fileLogs = new Map<string, ReadonlyArray<LogEntry>>();
218
+ /** Independent status groups; only failed and pending show by default. */
219
+ readonly show: Record<StatusGroup, boolean> = {
220
+ pass: false,
221
+ fail: true,
222
+ pending: true,
223
+ skipped: false,
224
+ };
225
+ /** Active filter query ("" = none). Applied live on every keystroke. */
226
+ filter = "";
227
+ /** True while the footer is capturing filter keystrokes. */
228
+ filterInput = false;
229
+ detailOpen = false;
230
+ selectedIndex = 0;
231
+ topIndex = 0;
232
+ /**
233
+ * When true the viewport tracks the selection (keyboard navigation).
234
+ * Mouse-wheel scrolling detaches it; the next keyboard move re-attaches.
235
+ */
236
+ viewportFollows = true;
237
+ summary: RunSummary | undefined;
238
+ controller: RunController | undefined;
239
+ startedAt = Date.now();
240
+ collectTotal = 0;
241
+ collectDone = 0;
242
+ dirty = true;
243
+ readonly counts = makeCounts();
244
+
245
+ fileNode(file: string): FileNode {
246
+ let node = this.files.get(file);
247
+ if (node === undefined) {
248
+ node = {
249
+ file,
250
+ tests: [],
251
+ counts: makeCounts(),
252
+ expanded: undefined,
253
+ started: false,
254
+ hook: undefined,
255
+ hookStartedAt: 0,
256
+ };
257
+ this.files.set(file, node);
258
+ this.fileOrder.push(node);
259
+ }
260
+ return node;
261
+ }
262
+
263
+ upsert(meta: TestMeta, status: Status, result?: TestResult): void {
264
+ let entry = this.byId.get(meta.id);
265
+ if (entry === undefined) {
266
+ const file = this.fileNode(meta.file);
267
+ entry = { meta, file, status, label: "" };
268
+ this.byId.set(meta.id, entry);
269
+ file.tests.push(entry);
270
+ } else {
271
+ this.counts[entry.status]--;
272
+ entry.file.counts[entry.status]--;
273
+ }
274
+ this.counts[status]++;
275
+ entry.file.counts[status]++;
276
+ entry.status = status;
277
+ if (status === "running") {
278
+ // (Re)started — retries included: drop the stale result so the row
279
+ // shows a fresh live timer instead of the previous run's duration.
280
+ entry.result = undefined;
281
+ entry.startedAt = Date.now();
282
+ }
283
+ if (result !== undefined) entry.result = result;
284
+ entry.label = entryLabel(entry);
285
+ this.dirty = true;
286
+ }
287
+
288
+ allGroupsShown(): boolean {
289
+ return this.show.pass && this.show.fail && this.show.pending && this.show.skipped;
290
+ }
291
+
292
+ /**
293
+ * Toggle a status group on/off, independently of the others (pressing `p`
294
+ * hides passing tests and NOTHING else). Hiding every group is allowed —
295
+ * the list is simply empty and the footer's struck-through toggle bar
296
+ * shows why.
297
+ */
298
+ toggleGroup(group: StatusGroup): void {
299
+ this.show[group] = !this.show[group];
300
+ this.dirty = true;
301
+ }
302
+
303
+ private matches(entry: Entry): boolean {
304
+ if (!this.show[groupOf(entry.status)]) return false;
305
+ if (this.filter === "") return true;
306
+ const haystack = `${entry.meta.file} ${entry.meta.titlePath.join(" ")}`.toLowerCase();
307
+ // Every whitespace-separated word must match somewhere (AND semantics).
308
+ return this.filter
309
+ .toLowerCase()
310
+ .split(/\s+/)
311
+ .filter((w) => w !== "")
312
+ .every((word) => haystack.includes(word));
313
+ }
314
+
315
+ isExpanded(node: FileNode): boolean {
316
+ // Expansion is ALWAYS a deliberate choice (enter / l) — failures don't
317
+ // auto-expand their file (the red glyph + count already flag it), and
318
+ // filtering narrows WHICH files are listed without expanding them.
319
+ return node.expanded === true;
320
+ }
321
+
322
+ /** Flattened visible tree: file rows with expanded tests nested inside. */
323
+ visible(): Array<Node> {
324
+ const out: Array<Node> = [];
325
+ for (const node of this.fileOrder) {
326
+ const tests = node.tests.filter((t) => this.matches(t));
327
+ if (tests.length === 0) continue;
328
+ const expanded = this.isExpanded(node);
329
+ out.push({ kind: "file", node, status: visibleFileStatus(node, tests) });
330
+ if (expanded) {
331
+ for (const entry of tests) out.push({ kind: "test", entry });
332
+ }
333
+ }
334
+ return out;
335
+ }
336
+
337
+ visibleTestCount(): number {
338
+ let count = 0;
339
+ for (const node of this.visible()) {
340
+ if (node.kind === "test") count++;
341
+ }
342
+ return count;
343
+ }
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Clipboard
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /** OSC52 first (works over ssh in modern terminals), then a system tool. */
351
+ const copyToClipboard = (renderer: CliRenderer, text: string): boolean => {
352
+ let copied = false;
353
+ try {
354
+ copied = renderer.copyToClipboardOSC52(text);
355
+ } catch {
356
+ copied = false;
357
+ }
358
+ try {
359
+ const cmd =
360
+ process.platform === "darwin"
361
+ ? ["pbcopy"]
362
+ : process.platform === "win32"
363
+ ? ["clip"]
364
+ : ["xclip", "-selection", "clipboard"];
365
+ const proc = Bun.spawn(cmd, {
366
+ stdin: "pipe",
367
+ stdout: "ignore",
368
+ stderr: "ignore",
369
+ });
370
+ proc.stdin.write(text);
371
+ proc.stdin.end();
372
+ copied = true;
373
+ } catch {
374
+ // no system clipboard tool — OSC52 result stands
375
+ }
376
+ return copied;
377
+ };
378
+
379
+ const detailContent = (state: TuiState, entry: Entry): string => {
380
+ const { meta, result } = entry;
381
+ const running = entry.status === "running";
382
+ const lines: Array<string> = [
383
+ `${meta.file} > ${meta.titlePath.join(" > ")}`,
384
+ `status: ${entry.status}${
385
+ running && entry.startedAt !== undefined
386
+ ? ` elapsed: ${formatDuration(Date.now() - entry.startedAt)}`
387
+ : ""
388
+ }${
389
+ !running && result !== undefined
390
+ ? ` duration: ${formatDuration(result.durationMs)} retries: ${result.retries}`
391
+ : ""
392
+ }`,
393
+ "",
394
+ ];
395
+ if (!running && result?.error !== undefined) {
396
+ lines.push("── error ──", result.error, "");
397
+ }
398
+ // Finished tests show their result logs; running tests tail the LIVE
399
+ // buffer the runner appends to.
400
+ const logs = running ? entry.liveLogs : result?.logs;
401
+ if (logs !== undefined && logs.length > 0) {
402
+ lines.push("── captured output ──", formatLogs(logs), "");
403
+ }
404
+ const hookLogs = state.fileLogs.get(meta.file);
405
+ if (hookLogs !== undefined && hookLogs.length > 0) {
406
+ lines.push("── file hooks (deploy/destroy) ──", formatLogs(hookLogs));
407
+ }
408
+ return lines.join("\n");
409
+ };
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // TUI
413
+ // ---------------------------------------------------------------------------
414
+
415
+ interface Tui {
416
+ readonly renderer: CliRenderer;
417
+ readonly state: TuiState;
418
+ readonly refresh: () => void;
419
+ readonly quit: Promise<void>;
420
+ readonly dispose: () => void;
421
+ }
422
+
423
+ const FOOTER_HINTS =
424
+ "j/k · enter open · / filter · h/l fold · e/c all · r retry · R retry failed · x kill · y copy · q quit";
425
+
426
+ const makeTui = async (logFile: string): Promise<Tui> => {
427
+ const state = new TuiState();
428
+ /** True once the renderer is torn down — no further writes are allowed. */
429
+ let disposed = false;
430
+
431
+ // Ctrl+C is handled by US (exitOnCtrlC: false): opentui's built-in handler
432
+ // destroys the renderer while our flush timer may still be queued, which
433
+ // crashed with "TextBuffer is destroyed". We dispose first, then exit.
434
+ const hadWindow = "window" in globalThis;
435
+ const renderer = await createCliRenderer({
436
+ exitOnCtrlC: false,
437
+ // Console capture is owned by StrayOutput's global patch (diverted into
438
+ // the run log) — don't let opentui's overlay re-patch it afterwards.
439
+ consoleMode: "disabled",
440
+ });
441
+ // opentui's CliRenderer constructor does `global.window = {}` to hang
442
+ // `requestAnimationFrame` on it and never reads it back. The tests share
443
+ // this process, and browser-detecting code (emscripten loaders such as
444
+ // pglite under @prisma/dev) treats `typeof window === "object"` as "in a
445
+ // browser" and then dereferences `window.location`. Restore the plain-bun
446
+ // globals a non-TUI run has.
447
+ if (!hadWindow) delete (globalThis as { window?: unknown }).window;
448
+ const selectionBackground = (): string =>
449
+ renderer.themeMode === "light" ? "#d0d0d0" : "#303030";
450
+
451
+ // Divert stray JS-level stdout/stderr writes into the run log while the
452
+ // TUI owns the screen: anything that bypasses the per-test Effect Console
453
+ // (third-party bridges writing to the streams directly, e.g. miniflare
454
+ // forwarding workerd's console) would corrupt the alternate screen.
455
+ // Installed AFTER the renderer is created — opentui captures the real
456
+ // stream methods at construction and renders through its native library,
457
+ // so the diversion cannot affect the TUI itself.
458
+ const restoreStreams = captureStrayOutput(logFile);
459
+
460
+ // No explicit colors on chrome — match the terminal's own theme.
461
+ const header = new TextRenderable(renderer, {
462
+ id: "header",
463
+ content: "alchemy-test — collecting…",
464
+ height: 1,
465
+ width: "100%",
466
+ });
467
+
468
+ const list = new BoxRenderable(renderer, {
469
+ id: "list",
470
+ width: "100%",
471
+ flexGrow: 1,
472
+ flexDirection: "column",
473
+ });
474
+
475
+ interface Row {
476
+ readonly text: TextRenderable;
477
+ /** Cache key of the last-applied content/style. */
478
+ key: string;
479
+ }
480
+ let rows: Array<Row> = [];
481
+
482
+ const rebuildRowPool = (): void => {
483
+ for (const row of rows) list.remove(row.text);
484
+ rows = [];
485
+ const count = Math.max(renderer.terminalHeight - 2, 1);
486
+ for (let i = 0; i < count; i++) {
487
+ const index = i;
488
+ const text = new TextRenderable(renderer, {
489
+ id: `list-row-${i}`,
490
+ width: "100%",
491
+ height: 1,
492
+ content: "",
493
+ // NOT selectable: rows are a recycled pool, and opentui anchors
494
+ // text-selection highlights on the renderable — a drag would leave
495
+ // highlight residue behind as row content repaints under it. Copy
496
+ // from the list with `y`; free-form text selection lives in the
497
+ // detail pane (enter), whose content is stable.
498
+ selectable: false,
499
+ // Wheel scrolls the viewable content without moving the selection;
500
+ // a click selects the row under the cursor.
501
+ onMouse: (event: MouseEvent) => {
502
+ if (event.type === "scroll" && event.scroll !== undefined) {
503
+ scrollViewport(event.scroll.direction === "up" ? -3 : 3);
504
+ }
505
+ },
506
+ onMouseDown: () => selectRow(index),
507
+ });
508
+ rows.push({ text, key: "" });
509
+ list.add(text);
510
+ }
511
+ };
512
+
513
+ const detail = new ScrollBoxRenderable(renderer, {
514
+ id: "detail",
515
+ width: "100%",
516
+ flexGrow: 1,
517
+ visible: false,
518
+ // Tail semantics: pinned to the bottom while content grows (a running
519
+ // test's live output); scrolling up detaches, scrolling back re-pins.
520
+ stickyScroll: true,
521
+ stickyStart: "bottom",
522
+ });
523
+ const detailText = new TextRenderable(renderer, {
524
+ id: "detail-text",
525
+ content: "",
526
+ selectable: true,
527
+ });
528
+ detail.add(detailText);
529
+ /** Content of the open detail pane (for `y` copy). */
530
+ let detailRaw = "";
531
+ /** Entry shown in the detail pane, refreshed live while it's running. */
532
+ let detailEntry: Entry | undefined;
533
+
534
+ const footer = new TextRenderable(renderer, {
535
+ id: "footer",
536
+ content: FOOTER_HINTS,
537
+ height: 1,
538
+ width: "100%",
539
+ });
540
+
541
+ const root = new BoxRenderable(renderer, {
542
+ id: "root",
543
+ width: "100%",
544
+ height: "100%",
545
+ flexDirection: "column",
546
+ });
547
+ root.add(header);
548
+ root.add(list);
549
+ root.add(detail);
550
+ root.add(footer);
551
+ renderer.root.add(root);
552
+ rebuildRowPool();
553
+ renderer.on(CliRenderEvents.RESIZE, () => {
554
+ if (disposed) return;
555
+ rebuildRowPool();
556
+ renderList();
557
+ });
558
+
559
+ /**
560
+ * Drop any active text selection. The list is a recycled pool of row
561
+ * renderables, so a lingering selection highlight would stick to screen
562
+ * positions while the content underneath changes (ghost highlights after
563
+ * a click, glyphs staying inverted through repaints).
564
+ */
565
+ const clearTextSelection = (): void => {
566
+ if (!disposed && renderer.hasSelection) renderer.clearSelection();
567
+ };
568
+
569
+ // Drag-selected text is copied automatically on release (the terminal's
570
+ // own CMD+C can't see the TUI's selection).
571
+ renderer.on(CliRenderEvents.SELECTION, (selection: Selection | null | undefined) => {
572
+ if (disposed || selection == null) return;
573
+ if (selection.isDragging) return;
574
+ const text = selection.getSelectedText();
575
+ // A plain click anchors a zero-width selection — nothing to copy.
576
+ if (text.trim().length > 1 && copyToClipboard(renderer, text)) {
577
+ flash("selection copied ✓");
578
+ }
579
+ // Clear either way: the copy already happened, and a selection left
580
+ // behind turns into ghost highlighting as the row pool repaints under
581
+ // it. Deferred — this event is emitted from inside finishSelection,
582
+ // which still touches the selection after the emit.
583
+ setTimeout(clearTextSelection, 0);
584
+ });
585
+
586
+ /**
587
+ * Full-width run of spaces appended to single-line chrome (header/footer):
588
+ * with transparent backgrounds, a shorter repaint would otherwise leave
589
+ * the previous content's tail cells on screen. Overflow is clipped.
590
+ */
591
+ const padLine = (): string => " ".repeat(renderer.terminalWidth);
592
+
593
+ /** Transient footer message (e.g. "copied"); reverts on the next tick. */
594
+ let flashUntil = 0;
595
+ const flash = (message: string): void => {
596
+ flashUntil = Date.now() + 1500;
597
+ footer.content = stringToStyledText(` ${message}${padLine()}`);
598
+ };
599
+
600
+ const updateFooter = (): void => {
601
+ if (Date.now() < flashUntil) return;
602
+ if (state.filterInput) {
603
+ footer.content = stringToStyledText(
604
+ ` /${state.filter}█ ${state.visibleTestCount()} matches (enter close · esc clear)${padLine()}`,
605
+ );
606
+ return;
607
+ }
608
+ // Always-visible toggle bar: each group shows its hotkey and current
609
+ // state — bright when shown, dim + struck-through when hidden.
610
+ const toggles = (
611
+ [
612
+ ["pass", "p:pass"],
613
+ ["fail", "f:fail"],
614
+ ["pending", "n:pending"],
615
+ ["skipped", "s:skipped"],
616
+ ] as Array<[StatusGroup, string]>
617
+ ).flatMap(([group, label], index) => [
618
+ ...(index > 0 ? [dim(" ")] : []),
619
+ state.show[group] ? stringToStyledText(label).chunks[0]! : strikethrough(dim(label)),
620
+ ]);
621
+ // Toggle states lead (before the hints) so they're never clipped on
622
+ // narrow terminals — the footer truncates at the terminal width.
623
+ const filter = state.filter !== "" ? ` │ filter: ${state.filter}` : "";
624
+ footer.content = new StyledText([
625
+ dim(" show: "),
626
+ ...toggles,
627
+ dim(`${filter} │ ${FOOTER_HINTS}${padLine()}`),
628
+ ]);
629
+ };
630
+
631
+ const updateHeader = (): void => {
632
+ const counts = state.counts;
633
+ const elapsed = formatDuration(state.summary?.durationMs ?? Date.now() - state.startedAt);
634
+ const done = state.summary !== undefined;
635
+ const collecting =
636
+ !done && state.collectDone < state.collectTotal
637
+ ? ` │ importing ${state.collectDone}/${state.collectTotal} files`
638
+ : "";
639
+ header.content = new StyledText([
640
+ dim(" alchemy-test "),
641
+ green(`${GLYPH.pass} ${counts.pass}`),
642
+ dim(" "),
643
+ red(`${GLYPH.fail} ${counts.fail}`),
644
+ dim(" "),
645
+ yellow(`${GLYPH.running} ${counts.running}`),
646
+ dim(` ${GLYPH.queued} ${counts.queued}`),
647
+ ...(counts.skip > 0 ? [dim(` ${GLYPH.skip} ${counts.skip}`)] : []),
648
+ dim(` │ ${elapsed}${collecting}`),
649
+ ...(state.summary?.plan
650
+ ? [
651
+ dim(
652
+ ` │ ${state.summary.plan.found} found, ${state.summary.plan.excluded} excluded by plan`,
653
+ ),
654
+ ]
655
+ : []),
656
+ ...(done ? [dim(" │ DONE — press q to quit")] : []),
657
+ dim(padLine()),
658
+ ]);
659
+ };
660
+
661
+ /**
662
+ * Paint the visible window into the row pool. O(window): a row's content
663
+ * is only written when its cache key changed since the last paint.
664
+ */
665
+ const renderList = (): void => {
666
+ if (state.detailOpen || disposed) return;
667
+ const nodes = state.visible();
668
+ const max = Math.max(nodes.length - 1, 0);
669
+ state.selectedIndex = Math.min(state.selectedIndex, max);
670
+ if (state.viewportFollows) {
671
+ if (state.selectedIndex < state.topIndex) {
672
+ state.topIndex = state.selectedIndex;
673
+ } else if (state.selectedIndex >= state.topIndex + rows.length) {
674
+ state.topIndex = state.selectedIndex - rows.length + 1;
675
+ }
676
+ }
677
+ state.topIndex = Math.max(0, Math.min(state.topIndex, Math.max(nodes.length - rows.length, 0)));
678
+
679
+ const width = renderer.terminalWidth;
680
+ for (let i = 0; i < rows.length; i++) {
681
+ const row = rows[i]!;
682
+ const node = nodes[state.topIndex + i];
683
+ const isSelected = node !== undefined && state.topIndex + i === state.selectedIndex;
684
+
685
+ if (node === undefined) {
686
+ // Empty content emits no cells, so it does not erase whatever this
687
+ // recycled screen row painted previously. Repaint the entire width.
688
+ const emptyKey = `empty|${width}`;
689
+ if (row.key !== emptyKey) {
690
+ row.key = emptyKey;
691
+ row.text.content = " ".repeat(width);
692
+ }
693
+ continue;
694
+ }
695
+
696
+ let head: string;
697
+ let status: Status;
698
+ let rest: string;
699
+ if (node.kind === "file") {
700
+ const expanded = state.isExpanded(node.node);
701
+ status = node.status;
702
+ head = ` ${expanded ? "▾" : "▸"} ${GLYPH[status]} `;
703
+ const phase = filePhase(node.node);
704
+ rest =
705
+ `${node.node.file} ${fileSummary(node.node)}` +
706
+ (phase !== undefined ? ` — ${phase}` : "");
707
+ } else {
708
+ status = node.entry.status;
709
+ head = ` ${GLYPH[status]} `;
710
+ rest = node.entry.label;
711
+ // Live elapsed timer while running (resets on retry).
712
+ if (status === "running" && node.entry.startedAt !== undefined) {
713
+ rest += ` (${formatDuration(Date.now() - node.entry.startedAt)}…)`;
714
+ }
715
+ }
716
+ // Clip to the terminal width AND pad to it — with transparent
717
+ // backgrounds a shorter repaint would otherwise leave the previous
718
+ // content's tail cells on screen.
719
+ const restWidth = Math.max(width - head.length, 0);
720
+ rest = rest.slice(0, restWidth).padEnd(restWidth, " ");
721
+
722
+ const key = `${isSelected ? "S" : "."}|${status}|${head}|${rest}`;
723
+ if (row.key === key) continue;
724
+ row.key = key;
725
+ const chunks = [statusChunk(status, head)];
726
+ if (rest !== "") {
727
+ // Only the glyph is colored; the label uses the terminal's default
728
+ // foreground (skipped rows are dimmed, todos are warning-yellow).
729
+ if (status === "skip") {
730
+ chunks.push(dim(rest));
731
+ } else if (status === "todo") {
732
+ chunks.push(yellow(rest));
733
+ } else {
734
+ chunks.push(...stringToStyledText(rest).chunks);
735
+ }
736
+ }
737
+ // Use an explicit background instead of terminal inverse video.
738
+ // OpenTUI's cell diff can leave inverse attributes behind when the
739
+ // selection moves upward; a concrete background has symmetric paint
740
+ // and clear operations in both navigation directions.
741
+ row.text.content = new StyledText(
742
+ isSelected ? chunks.map(bg(selectionBackground())) : chunks,
743
+ );
744
+ }
745
+ };
746
+
747
+ const moveSelection = (delta: number): void => {
748
+ const nodes = state.visible();
749
+ if (nodes.length === 0) return;
750
+ state.viewportFollows = true;
751
+ state.selectedIndex = Math.max(0, Math.min(state.selectedIndex + delta, nodes.length - 1));
752
+ renderList();
753
+ };
754
+
755
+ /** Scroll the viewable content without moving the selection (wheel). */
756
+ const scrollViewport = (delta: number): void => {
757
+ const nodes = state.visible();
758
+ if (nodes.length === 0) return;
759
+ state.viewportFollows = false;
760
+ state.topIndex = Math.max(
761
+ 0,
762
+ Math.min(state.topIndex + delta, Math.max(nodes.length - rows.length, 0)),
763
+ );
764
+ renderList();
765
+ };
766
+
767
+ /** Click on a visible row selects it (without scrolling). */
768
+ const selectRow = (rowIndex: number): void => {
769
+ const nodes = state.visible();
770
+ const index = state.topIndex + rowIndex;
771
+ if (index >= nodes.length) return;
772
+ state.selectedIndex = index;
773
+ renderList();
774
+ };
775
+
776
+ const refresh = (): void => {
777
+ if (disposed) return;
778
+ updateHeader();
779
+ updateFooter();
780
+ // Keep `dirty` set while the detail pane hides the list, so the pending
781
+ // repaint happens when the list becomes visible again.
782
+ if (state.dirty && !state.detailOpen) {
783
+ state.dirty = false;
784
+ renderList();
785
+ }
786
+ };
787
+
788
+ const selectedNode = (): Node | undefined => state.visible()[state.selectedIndex];
789
+
790
+ const setExpanded = (node: FileNode, expanded: boolean): void => {
791
+ node.expanded = expanded;
792
+ state.dirty = true;
793
+ renderList();
794
+ };
795
+
796
+ const setAllVisibleExpanded = (expanded: boolean): void => {
797
+ const selected = selectedNode();
798
+ const visibleFiles = new Set(
799
+ state.visible().map((node) => (node.kind === "file" ? node.node : node.entry.file)),
800
+ );
801
+ for (const file of visibleFiles) file.expanded = expanded;
802
+
803
+ const nodes = state.visible();
804
+ const selectedFile = selected?.kind === "file" ? selected.node : selected?.entry.file;
805
+ const selectedIndex = nodes.findIndex((node) =>
806
+ expanded && selected?.kind === "test"
807
+ ? node.kind === "test" && node.entry === selected.entry
808
+ : node.kind === "file" && node.node === selectedFile,
809
+ );
810
+ state.selectedIndex = Math.max(selectedIndex, 0);
811
+ state.dirty = true;
812
+ renderList();
813
+ };
814
+
815
+ const previewContent = (entry: Entry): string =>
816
+ `${detailContent(state, entry)}\nOutput previews are bounded. Full log: ${logFile}\n`;
817
+
818
+ /** Re-render the open detail pane (no-op when its content is unchanged). */
819
+ const refreshDetail = (): void => {
820
+ if (detailEntry === undefined) return;
821
+ const raw = previewContent(detailEntry);
822
+ if (raw === detailRaw) return;
823
+ detailRaw = raw;
824
+ detailText.content = stringToStyledText(raw);
825
+ };
826
+
827
+ const openDetail = (entry: Entry): void => {
828
+ detailEntry = entry;
829
+ detailRaw = previewContent(entry);
830
+ detailText.content = stringToStyledText(detailRaw);
831
+ state.detailOpen = true;
832
+ list.visible = false;
833
+ detail.visible = true;
834
+ detail.focus();
835
+ // Running test → tail the output (sticky keeps it pinned as it grows);
836
+ // finished test → start at the top where the error is.
837
+ detail.scrollTo(entry.status === "running" ? 1_000_000_000 : 0);
838
+ };
839
+
840
+ const closeDetail = (): void => {
841
+ state.detailOpen = false;
842
+ detailEntry = undefined;
843
+ detail.visible = false;
844
+ list.visible = true;
845
+ renderList();
846
+ refresh();
847
+ };
848
+
849
+ const copySelection = (): void => {
850
+ let text: string | undefined;
851
+ if (state.detailOpen) {
852
+ text = detailRaw;
853
+ } else {
854
+ const node = selectedNode();
855
+ if (node?.kind === "test") {
856
+ text = detailContent(state, node.entry);
857
+ } else if (node?.kind === "file") {
858
+ // Copy every failure of the file plus its hook logs.
859
+ text = node.node.tests
860
+ .filter((t) => t.status === "fail")
861
+ .map((t) => detailContent(state, t))
862
+ .join("\n\n");
863
+ if (text === "" && node.node.tests.length > 0) {
864
+ text = detailContent(state, node.node.tests[0]!);
865
+ }
866
+ }
867
+ }
868
+ if (text === undefined || text === "") return;
869
+ flash(copyToClipboard(renderer, text) ? "copied to clipboard ✓" : "copy failed");
870
+ };
871
+
872
+ const retrySelection = (): void => {
873
+ const controller = state.controller;
874
+ if (controller === undefined) return;
875
+ const node = state.detailOpen ? undefined : selectedNode();
876
+ if (node === undefined) return;
877
+ if (node.kind === "test") {
878
+ if (node.entry.status === "running" || node.entry.status === "queued") {
879
+ flash("still running — x to kill");
880
+ return;
881
+ }
882
+ controller.retryTest(node.entry.meta.id);
883
+ flash(`retrying ${node.entry.meta.name}`);
884
+ } else {
885
+ controller.retryFile(node.node.file);
886
+ flash(`retrying ${node.node.file}`);
887
+ }
888
+ };
889
+
890
+ const retryFailures = (): void => {
891
+ const controller = state.controller;
892
+ if (controller === undefined) return;
893
+ const failed = [...state.byId.values()].filter((entry) => entry.status === "fail");
894
+ for (const entry of failed) controller.retryTest(entry.meta.id);
895
+ flash(
896
+ failed.length === 0
897
+ ? "no failed tests to retry"
898
+ : `retrying ${failed.length} failed test${failed.length === 1 ? "" : "s"}`,
899
+ );
900
+ };
901
+
902
+ const killSelection = (): void => {
903
+ const controller = state.controller;
904
+ if (controller === undefined) return;
905
+ const node = state.detailOpen ? undefined : selectedNode();
906
+ if (node === undefined) return;
907
+ if (node.kind === "test") {
908
+ if (node.entry.status !== "running") {
909
+ flash("not running — nothing to kill");
910
+ return;
911
+ }
912
+ controller.killTest(node.entry.meta.id);
913
+ flash(`killing ${node.entry.meta.name}`);
914
+ } else {
915
+ for (const entry of node.node.tests) {
916
+ if (entry.status === "running") controller.killTest(entry.meta.id);
917
+ }
918
+ flash(`killing running tests in ${node.node.file}`);
919
+ }
920
+ };
921
+
922
+ let resolveQuit!: () => void;
923
+ const quit = new Promise<void>((resolve) => {
924
+ resolveQuit = resolve;
925
+ });
926
+
927
+ const onFilterKey = (key: KeyEvent): void => {
928
+ switch (key.name) {
929
+ case "escape":
930
+ state.filter = "";
931
+ state.filterInput = false;
932
+ break;
933
+ case "return":
934
+ case "enter":
935
+ state.filterInput = false;
936
+ break;
937
+ case "backspace":
938
+ state.filter = state.filter.slice(0, -1);
939
+ break;
940
+ default: {
941
+ // Printable, single-character inputs extend the query; the list
942
+ // re-filters on every keystroke.
943
+ const seq = key.sequence ?? "";
944
+ if (seq.length === 1 && !key.ctrl && !key.meta && seq >= " ") {
945
+ state.filter += seq;
946
+ } else {
947
+ return;
948
+ }
949
+ }
950
+ }
951
+ state.selectedIndex = 0;
952
+ state.topIndex = 0;
953
+ state.dirty = true;
954
+ refresh();
955
+ };
956
+
957
+ // Vim `gg` prefix: true after a bare `g`, cleared by any other key.
958
+ let pendingG = false;
959
+
960
+ renderer.keyInput.on("keypress", (key: KeyEvent) => {
961
+ // Ctrl+C: tear the TUI down BEFORE exiting so no queued timer callback
962
+ // touches a destroyed renderer ("TextBuffer is destroyed").
963
+ if (key.ctrl && key.name === "c") {
964
+ dispose();
965
+ process.exit(130);
966
+ }
967
+ if (disposed) return;
968
+ // Keyboard input takes over from the mouse: drop any live text
969
+ // selection so its highlight can't linger over rows that repaint.
970
+ clearTextSelection();
971
+ if (state.filterInput && !state.detailOpen) {
972
+ onFilterKey(key);
973
+ return;
974
+ }
975
+ switch (key.name) {
976
+ case "q":
977
+ resolveQuit();
978
+ return;
979
+ case "escape":
980
+ if (state.detailOpen) {
981
+ closeDetail();
982
+ } else if (state.filter !== "") {
983
+ state.filter = "";
984
+ state.dirty = true;
985
+ refresh();
986
+ }
987
+ return;
988
+ case "y":
989
+ copySelection();
990
+ return;
991
+ case "r":
992
+ if (key.shift) {
993
+ retryFailures();
994
+ } else {
995
+ retrySelection();
996
+ }
997
+ return;
998
+ case "x":
999
+ killSelection();
1000
+ return;
1001
+ }
1002
+ if (state.detailOpen) return; // ScrollBox handles its own keys
1003
+
1004
+ // gg — jump to top (vim)
1005
+ if (key.name === "g" && !key.shift && !key.ctrl) {
1006
+ if (pendingG) {
1007
+ pendingG = false;
1008
+ moveSelection(-Number.MAX_SAFE_INTEGER);
1009
+ } else {
1010
+ pendingG = true;
1011
+ }
1012
+ return;
1013
+ }
1014
+ pendingG = false;
1015
+
1016
+ // vim paging: ctrl-d/u half page, ctrl-f/b full page
1017
+ if (key.ctrl) {
1018
+ switch (key.name) {
1019
+ case "d":
1020
+ moveSelection(Math.max(Math.floor(rows.length / 2), 1));
1021
+ return;
1022
+ case "u":
1023
+ moveSelection(-Math.max(Math.floor(rows.length / 2), 1));
1024
+ return;
1025
+ case "f":
1026
+ moveSelection(rows.length);
1027
+ return;
1028
+ case "b":
1029
+ moveSelection(-rows.length);
1030
+ return;
1031
+ }
1032
+ return;
1033
+ }
1034
+ // G — jump to bottom (vim)
1035
+ if (key.name === "g" && key.shift) {
1036
+ moveSelection(Number.MAX_SAFE_INTEGER);
1037
+ return;
1038
+ }
1039
+ switch (key.name) {
1040
+ case "/":
1041
+ state.filterInput = true;
1042
+ refresh();
1043
+ return;
1044
+ case "space":
1045
+ case " ":
1046
+ // space pages forward (less/vim style)
1047
+ moveSelection(rows.length);
1048
+ return;
1049
+ case "return":
1050
+ case "enter": {
1051
+ const node = selectedNode();
1052
+ if (node === undefined) return;
1053
+ if (node.kind === "file") {
1054
+ setExpanded(node.node, !state.isExpanded(node.node));
1055
+ } else {
1056
+ openDetail(node.entry);
1057
+ }
1058
+ return;
1059
+ }
1060
+ case "left":
1061
+ case "h": {
1062
+ const node = selectedNode();
1063
+ if (node === undefined) return;
1064
+ // On a test row, collapse the parent file and land on it.
1065
+ const target = node.kind === "file" ? node.node : node.entry.file;
1066
+ setExpanded(target, false);
1067
+ const index = state.visible().findIndex((n) => n.kind === "file" && n.node === target);
1068
+ if (index >= 0) {
1069
+ state.selectedIndex = index;
1070
+ renderList();
1071
+ }
1072
+ return;
1073
+ }
1074
+ case "right":
1075
+ case "l": {
1076
+ const node = selectedNode();
1077
+ if (node?.kind === "file") setExpanded(node.node, true);
1078
+ return;
1079
+ }
1080
+ case "e":
1081
+ setAllVisibleExpanded(true);
1082
+ return;
1083
+ case "c":
1084
+ setAllVisibleExpanded(false);
1085
+ return;
1086
+ case "up":
1087
+ case "k":
1088
+ moveSelection(-1);
1089
+ return;
1090
+ case "down":
1091
+ case "j":
1092
+ moveSelection(1);
1093
+ return;
1094
+ case "pageup":
1095
+ moveSelection(-rows.length);
1096
+ return;
1097
+ case "pagedown":
1098
+ moveSelection(rows.length);
1099
+ return;
1100
+ case "home":
1101
+ moveSelection(-Number.MAX_SAFE_INTEGER);
1102
+ return;
1103
+ case "end":
1104
+ moveSelection(Number.MAX_SAFE_INTEGER);
1105
+ return;
1106
+ // Independent status-group visibility toggles: each key flips ONLY its
1107
+ // own group. Hiding all four leaves the list empty (no auto-reset).
1108
+ case "p":
1109
+ state.toggleGroup("pass");
1110
+ break;
1111
+ case "f":
1112
+ state.toggleGroup("fail");
1113
+ break;
1114
+ case "n":
1115
+ state.toggleGroup("pending");
1116
+ break;
1117
+ case "s":
1118
+ state.toggleGroup("skipped");
1119
+ break;
1120
+ default:
1121
+ return;
1122
+ }
1123
+ state.selectedIndex = 0;
1124
+ state.topIndex = 0;
1125
+ refresh();
1126
+ });
1127
+
1128
+ // Single flush loop: repaints the visible window at most 10x/s and ONLY
1129
+ // when test data changed; otherwise it just ticks the elapsed-time header
1130
+ // while the run is live.
1131
+ const interval = setInterval(() => {
1132
+ if (disposed) return;
1133
+ // Tail the open detail pane while its test is running.
1134
+ if (state.detailOpen) refreshDetail();
1135
+ // Live elapsed timers: hook phases on file rows and running test rows.
1136
+ if (state.counts.running > 0) state.dirty = true;
1137
+ for (const node of state.fileOrder) {
1138
+ if (node.hook !== undefined) state.dirty = true;
1139
+ }
1140
+ if (state.dirty) {
1141
+ refresh();
1142
+ } else if (state.summary === undefined || Date.now() < flashUntil + 100) {
1143
+ updateHeader();
1144
+ updateFooter();
1145
+ }
1146
+ }, 100);
1147
+
1148
+ const dispose = (): void => {
1149
+ if (disposed) return;
1150
+ disposed = true;
1151
+ clearInterval(interval);
1152
+ restoreStreams();
1153
+ try {
1154
+ renderer.destroy();
1155
+ } catch {
1156
+ // Never let teardown mask the run's real outcome.
1157
+ }
1158
+ };
1159
+
1160
+ return {
1161
+ renderer,
1162
+ state,
1163
+ refresh: () => {
1164
+ if (!disposed) refresh();
1165
+ },
1166
+ quit,
1167
+ dispose,
1168
+ };
1169
+ };
1170
+
1171
+ // ---------------------------------------------------------------------------
1172
+ // Events
1173
+ // ---------------------------------------------------------------------------
1174
+
1175
+ const onEvent = (tui: Tui, event: TestEvent): void => {
1176
+ const { state } = tui;
1177
+ switch (event._tag) {
1178
+ case "CollectStart":
1179
+ state.collectTotal = event.files.length;
1180
+ break;
1181
+ case "FileCollected":
1182
+ state.collectDone++;
1183
+ break;
1184
+ case "RunStart":
1185
+ state.startedAt = Date.now();
1186
+ for (const meta of event.tests) state.upsert(meta, "queued");
1187
+ break;
1188
+ case "FileStart":
1189
+ state.fileNode(event.file).started = true;
1190
+ // Live hook-log buffer — grows in place as deploy/destroy run.
1191
+ if (event.logs !== undefined) {
1192
+ state.fileLogs.set(event.file, event.logs);
1193
+ }
1194
+ break;
1195
+ case "HookStart": {
1196
+ const node = state.fileNode(event.file);
1197
+ node.hook = event.hook;
1198
+ node.hookStartedAt = Date.now();
1199
+ break;
1200
+ }
1201
+ case "HookEnd":
1202
+ state.fileNode(event.file).hook = undefined;
1203
+ break;
1204
+ case "TestStart": {
1205
+ state.upsert(event.test, "running");
1206
+ const entry = state.byId.get(event.test.id);
1207
+ if (entry !== undefined) entry.liveLogs = event.logs;
1208
+ break;
1209
+ }
1210
+ case "TestLocked":
1211
+ break;
1212
+ case "TestEnd":
1213
+ state.upsert(event.test, event.result.status, event.result);
1214
+ break;
1215
+ case "FileEnd": {
1216
+ const node = state.fileNode(event.file);
1217
+ node.hook = undefined;
1218
+ state.fileLogs.set(event.file, event.logs);
1219
+ if (event.error !== undefined) {
1220
+ // Surface import/hook failures as a synthetic failed entry.
1221
+ state.upsert(
1222
+ {
1223
+ id: `${event.file} > [file]`,
1224
+ file: event.file,
1225
+ titlePath: ["[file]"],
1226
+ name: "[file]",
1227
+ tags: [],
1228
+ optInTags: [],
1229
+ },
1230
+ "fail",
1231
+ {
1232
+ status: "fail",
1233
+ durationMs: 0,
1234
+ error: event.error,
1235
+ logs: event.logs,
1236
+ retries: 0,
1237
+ },
1238
+ );
1239
+ }
1240
+ break;
1241
+ }
1242
+ case "RunEnd":
1243
+ state.summary = event.summary;
1244
+ // Final state should render immediately, not on the next tick.
1245
+ state.dirty = true;
1246
+ tui.refresh();
1247
+ return;
1248
+ default:
1249
+ break;
1250
+ }
1251
+ // Everything else just marks the state dirty; the flush interval batches
1252
+ // repaints (a burst of TestEnd events must not repaint per event).
1253
+ state.dirty = true;
1254
+ };
1255
+
1256
+ export const TuiReporter = (logFile: string): Layer.Layer<Reporter> =>
1257
+ Layer.effect(Reporter)(
1258
+ Effect.gen(function* () {
1259
+ const tui = yield* Effect.acquireRelease(
1260
+ Effect.promise(() => makeTui(logFile)),
1261
+ (t) => Effect.sync(() => t.dispose()),
1262
+ );
1263
+ return {
1264
+ emit: (event) => Effect.sync(() => onEvent(tui, event)),
1265
+ waitForExit: () => Effect.promise(() => tui.quit),
1266
+ attachController: (controller) =>
1267
+ Effect.sync(() => {
1268
+ tui.state.controller = controller;
1269
+ }),
1270
+ };
1271
+ }),
1272
+ );