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