Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/tui/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type MockInput,
type MockMouse,
type TestRenderer,
type TestRendererOptions,
type TestRendererSetup,
} from "@opentui/core/testing";

Expand All @@ -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). */
Expand Down Expand Up @@ -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 => {
Expand Down
9 changes: 9 additions & 0 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions src/tui/prompt-kill-ring.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
});
});
});
104 changes: 104 additions & 0 deletions src/tui/runtime-notices-console.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading