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