From c29e010afbd9ae6bdd7d06d8f406615a0389418e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 23:15:55 -0700 Subject: [PATCH 1/2] feat(tui): route runtime notices through the console overlay --- docs/TUI.md | 12 ++- src/tui/harness.ts | 9 ++ src/tui/product-host.ts | 9 ++ src/tui/runtime-notices-console.test.ts | 104 ++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/tui/runtime-notices-console.test.ts diff --git a/docs/TUI.md b/docs/TUI.md index 2556d9c25..cd3e2775a 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -1042,7 +1042,12 @@ before any other Corbits code executes: the TUI holds the alternate screen for the rest of the process, and anything landing on the real terminal mid-frame corrupts the paint. Anything the operator must actually see goes through the transcript (`appendStreamRow`) or a chrome notice — never a log -line. +line. Confirmation flashes stay on the status row and are also mirrored with +`console.info` into OpenTUI's Console overlay (`consoleMode: "console-overlay"`, +the production renderer default). The overlay is an in-frame observation +surface: it stays hidden unless `SHOW_CONSOLE` is set, and it is not a +durability surface. The file sink remains mandatory, because a log line emitted +before a renderer exists has nowhere else to go. ## Test-harness blind spots @@ -1068,6 +1073,11 @@ terminal. It cannot observe: - **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and OSC 52 fallback are exercised with mocked spawn functions in tests; no test round-trips through a real `pbcopy`/`xclip`/terminal clipboard. +- **A shown Console overlay.** Headless suites can enable + `consoleMode: "console-overlay"` and read `renderer.console.getCachedLogs()` + while the overlay stays hidden. That pins that a mirrored flash was captured + and that a hidden console adds no rows to `captureCharFrame`. It does not + show whether a real terminal paints the overlay when `SHOW_CONSOLE` is set. - **Terminal-owned text selection.** Native drag-select only exists once DEC mouse reporting is off and a real terminal emulator is running; there is no terminal emulator in the test harness to select text in. OpenTUI diff --git a/src/tui/harness.ts b/src/tui/harness.ts index 468d0175a..ddd5e10c1 100644 --- a/src/tui/harness.ts +++ b/src/tui/harness.ts @@ -20,6 +20,7 @@ import { type MockInput, type MockMouse, type TestRenderer, + type TestRendererOptions, type TestRendererSetup, } from "@opentui/core/testing"; @@ -31,6 +32,10 @@ export interface HarnessOptions { * Defaults to **false** so interrupt-shape tests do not kill the process. */ readonly exitOnCtrlC?: boolean; + /** OpenTUI console overlay. The test renderer defaults to `"disabled"`. */ + readonly consoleMode?: TestRendererOptions["consoleMode"]; + /** Injectable clock (for example `ManualClock`) forwarded to the renderer. */ + readonly clock?: TestRendererOptions["clock"]; } /** Fixture: permission choices for overlay suites (moved out of overlays.ts). */ @@ -144,6 +149,10 @@ export async function createHarness( width, height, exitOnCtrlC, + ...(opts.consoleMode !== undefined + ? { consoleMode: opts.consoleMode } + : {}), + ...(opts.clock !== undefined ? { clock: opts.clock } : {}), }); const pressKey = (name: NamedKey | KeyInput, mods?: KeyModifiers): void => { diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 9c986ee4b..72851874d 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -507,6 +507,15 @@ export async function mountProductHost( return; } setStatusFlash(shell, notice.text, { ttlMs: RUNTIME_FLASH_MS }); + // The status flash stays the operator-visible confirmation. The console + // overlay is the durable observation surface (hidden unless SHOW_CONSOLE). + // Headless tests leave consoleMode disabled, so this must not call the + // real console and paint the test runner. + if (renderer.consoleMode === "console-overlay") { + // Intentional: TerminalConsole captures console.* into the overlay. + // eslint-disable-next-line no-console + console.info(notice.text); + } } function onHook(event: unknown): void { diff --git a/src/tui/runtime-notices-console.test.ts b/src/tui/runtime-notices-console.test.ts new file mode 100644 index 000000000..697152de7 --- /dev/null +++ b/src/tui/runtime-notices-console.test.ts @@ -0,0 +1,104 @@ +/** + * Flash notices stay on the status row and are mirrored into the OpenTUI + * console overlay. Rows stay on the transcript and are not mirrored. + * The overlay stays hidden, so the mirror must not add frame rows. + */ +import { EventEmitter } from "node:events"; +import { describe, expect, test } from "bun:test"; +import { ManualClock } from "@opentui/core/testing"; + +import { createHarness } from "./harness.js"; +import { mountProductHost } from "./product-host.js"; + +const cleanHook = { + type: "hook.updated", + hook: { + id: "fmt", + name: "format", + type: "shell", + path: "/hooks/format.sh", + enabled: true, + lastFiredAt: 1, + lastKind: "postTurn", + lastExitStatus: { code: 0, signal: null, stderr: "" }, + }, +}; + +const failingHook = { + type: "hook.updated", + hook: { + ...cleanHook.hook, + lastExitStatus: { code: 2, signal: null, stderr: "prettier not found" }, + }, +}; + +describe("runtime notice console mirror", () => { + test("a flash stays on the status row and in the hidden console cache", async () => { + const clock = new ManualClock(); + const harness = await createHarness({ + width: 100, + height: 30, + consoleMode: "console-overlay", + clock, + }); + const emitter = new EventEmitter(); + const host = await mountProductHost({ + title: "test-session", + eventEmitter: emitter, + send: () => undefined, + interrupt: () => undefined, + deliver: () => undefined, + createRenderer: async () => harness.renderer, + }); + try { + expect(harness.renderer.consoleMode).toBe("console-overlay"); + harness.renderer.console.clear(); + emitter.emit("hook", cleanHook); + await harness.renderOnce(); + const frame = harness.captureCharFrame(); + expect(frame).toContain("format"); + expect(frame).not.toContain("Console"); + expect(host.shell.streamLog).toEqual([]); + expect(host.shell.statusFlash).toContain("format"); + expect(harness.renderer.console.getCachedLogs()).toContain( + "hook format ran", + ); + clock.advance(0); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("a row notice stays on the transcript and is not mirrored", async () => { + const harness = await createHarness({ + width: 100, + height: 30, + consoleMode: "console-overlay", + clock: new ManualClock(), + }); + const emitter = new EventEmitter(); + const host = await mountProductHost({ + title: "test-session", + eventEmitter: emitter, + send: () => undefined, + interrupt: () => undefined, + deliver: () => undefined, + createRenderer: async () => harness.renderer, + }); + try { + harness.renderer.console.clear(); + emitter.emit("hook", failingHook); + await harness.renderOnce(); + const frame = harness.captureCharFrame(); + expect(frame).toMatch(/format[^\n]*\b2\b/); + expect(frame).not.toContain("Console"); + expect(harness.renderer.console.getCachedLogs()).not.toContain( + "prettier not found", + ); + } finally { + host.dispose(); + harness.destroy(); + } + }); +}); From c7838532d6782037f51702c62a02d917f04dc690 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 23:15:56 -0700 Subject: [PATCH 2/2] test(tui): drive kill-ring yank through mock keys --- src/tui/prompt-kill-ring.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/tui/prompt-kill-ring.test.ts b/src/tui/prompt-kill-ring.test.ts index 684787523..3545de869 100644 --- a/src/tui/prompt-kill-ring.test.ts +++ b/src/tui/prompt-kill-ring.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; +import { withTestRenderer } from "./harness"; +import { createAppShell } from "./shell/index"; import { beginYank, breakKillSequence, @@ -95,3 +97,20 @@ describe("killedTextForward / killedTextBackward", () => { expect(killedTextBackward("hello", 3, 3)).toBe(""); }); }); + +describe("prompt kill ring chords", () => { + test("ctrl+u then ctrl+y yanks the killed line back through mock keys", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + await h.mockInput.typeText("hello world"); + expect(shell.prompt.value).toBe("hello world"); + h.pressKey("u", { ctrl: true }); + expect(shell.prompt.value).toBe(""); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("hello world"); + }); + }); +});