Skip to content

Commit 301e383

Browse files
Merge pull request #1450 from corbitsdev/cl-10641-console-overlay-notices
feat(tui): route runtime notices through the console overlay
2 parents 0cf1c32 + c783853 commit 301e383

5 files changed

Lines changed: 152 additions & 1 deletion

File tree

‎docs/TUI.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1042,7 +1042,12 @@ before any other Corbits code executes: the TUI holds the alternate screen
10421042
for the rest of the process, and anything landing on the real terminal
10431043
mid-frame corrupts the paint. Anything the operator must actually see goes
10441044
through the transcript (`appendStreamRow`) or a chrome notice — never a log
1045-
line.
1045+
line. Confirmation flashes stay on the status row and are also mirrored with
1046+
`console.info` into OpenTUI's Console overlay (`consoleMode: "console-overlay"`,
1047+
the production renderer default). The overlay is an in-frame observation
1048+
surface: it stays hidden unless `SHOW_CONSOLE` is set, and it is not a
1049+
durability surface. The file sink remains mandatory, because a log line emitted
1050+
before a renderer exists has nowhere else to go.
10461051

10471052
## Test-harness blind spots
10481053

@@ -1068,6 +1073,11 @@ terminal. It cannot observe:
10681073
- **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and
10691074
OSC 52 fallback are exercised with mocked spawn functions in tests; no
10701075
test round-trips through a real `pbcopy`/`xclip`/terminal clipboard.
1076+
- **A shown Console overlay.** Headless suites can enable
1077+
`consoleMode: "console-overlay"` and read `renderer.console.getCachedLogs()`
1078+
while the overlay stays hidden. That pins that a mirrored flash was captured
1079+
and that a hidden console adds no rows to `captureCharFrame`. It does not
1080+
show whether a real terminal paints the overlay when `SHOW_CONSOLE` is set.
10711081
- **Terminal-owned text selection.** Native drag-select only exists once DEC
10721082
mouse reporting is off and a real terminal emulator is running; there is
10731083
no terminal emulator in the test harness to select text in. OpenTUI

‎src/tui/harness.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type MockInput,
2121
type MockMouse,
2222
type TestRenderer,
23+
type TestRendererOptions,
2324
type TestRendererSetup,
2425
} from "@opentui/core/testing";
2526

@@ -31,6 +32,10 @@ export interface HarnessOptions {
3132
* Defaults to **false** so interrupt-shape tests do not kill the process.
3233
*/
3334
readonly exitOnCtrlC?: boolean;
35+
/** OpenTUI console overlay. The test renderer defaults to `"disabled"`. */
36+
readonly consoleMode?: TestRendererOptions["consoleMode"];
37+
/** Injectable clock (for example `ManualClock`) forwarded to the renderer. */
38+
readonly clock?: TestRendererOptions["clock"];
3439
}
3540

3641
/** Fixture: permission choices for overlay suites (moved out of overlays.ts). */
@@ -144,6 +149,10 @@ export async function createHarness(
144149
width,
145150
height,
146151
exitOnCtrlC,
152+
...(opts.consoleMode !== undefined
153+
? { consoleMode: opts.consoleMode }
154+
: {}),
155+
...(opts.clock !== undefined ? { clock: opts.clock } : {}),
147156
});
148157

149158
const pressKey = (name: NamedKey | KeyInput, mods?: KeyModifiers): void => {

‎src/tui/product-host.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,15 @@ export async function mountProductHost(
552552
return;
553553
}
554554
setStatusFlash(shell, notice.text, { ttlMs: RUNTIME_FLASH_MS });
555+
// The status flash stays the operator-visible confirmation. The console
556+
// overlay is the durable observation surface (hidden unless SHOW_CONSOLE).
557+
// Headless tests leave consoleMode disabled, so this must not call the
558+
// real console and paint the test runner.
559+
if (renderer.consoleMode === "console-overlay") {
560+
// Intentional: TerminalConsole captures console.* into the overlay.
561+
// eslint-disable-next-line no-console
562+
console.info(notice.text);
563+
}
555564
}
556565

557566
function onHook(event: unknown): void {

‎src/tui/prompt-kill-ring.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import { defined } from "../../testkit/defined.js";
3+
import { withTestRenderer } from "./harness";
4+
import { createAppShell } from "./shell/index";
35
import {
46
beginYank,
57
breakKillSequence,
@@ -95,3 +97,20 @@ describe("killedTextForward / killedTextBackward", () => {
9597
expect(killedTextBackward("hello", 3, 3)).toBe("");
9698
});
9799
});
100+
101+
describe("prompt kill ring chords", () => {
102+
test("ctrl+u then ctrl+y yanks the killed line back through mock keys", async () => {
103+
await withTestRenderer(async (h) => {
104+
const shell = createAppShell(h.renderer, {
105+
terminal: { columns: 80, rows: 24 },
106+
run: "idle",
107+
});
108+
await h.mockInput.typeText("hello world");
109+
expect(shell.prompt.value).toBe("hello world");
110+
h.pressKey("u", { ctrl: true });
111+
expect(shell.prompt.value).toBe("");
112+
h.pressKey("y", { ctrl: true });
113+
expect(shell.prompt.value).toBe("hello world");
114+
});
115+
});
116+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Flash notices stay on the status row and are mirrored into the OpenTUI
3+
* console overlay. Rows stay on the transcript and are not mirrored.
4+
* The overlay stays hidden, so the mirror must not add frame rows.
5+
*/
6+
import { EventEmitter } from "node:events";
7+
import { describe, expect, test } from "bun:test";
8+
import { ManualClock } from "@opentui/core/testing";
9+
10+
import { createHarness } from "./harness.js";
11+
import { mountProductHost } from "./product-host.js";
12+
13+
const cleanHook = {
14+
type: "hook.updated",
15+
hook: {
16+
id: "fmt",
17+
name: "format",
18+
type: "shell",
19+
path: "/hooks/format.sh",
20+
enabled: true,
21+
lastFiredAt: 1,
22+
lastKind: "postTurn",
23+
lastExitStatus: { code: 0, signal: null, stderr: "" },
24+
},
25+
};
26+
27+
const failingHook = {
28+
type: "hook.updated",
29+
hook: {
30+
...cleanHook.hook,
31+
lastExitStatus: { code: 2, signal: null, stderr: "prettier not found" },
32+
},
33+
};
34+
35+
describe("runtime notice console mirror", () => {
36+
test("a flash stays on the status row and in the hidden console cache", async () => {
37+
const clock = new ManualClock();
38+
const harness = await createHarness({
39+
width: 100,
40+
height: 30,
41+
consoleMode: "console-overlay",
42+
clock,
43+
});
44+
const emitter = new EventEmitter();
45+
const host = await mountProductHost({
46+
title: "test-session",
47+
eventEmitter: emitter,
48+
send: () => undefined,
49+
interrupt: () => undefined,
50+
deliver: () => undefined,
51+
createRenderer: async () => harness.renderer,
52+
});
53+
try {
54+
expect(harness.renderer.consoleMode).toBe("console-overlay");
55+
harness.renderer.console.clear();
56+
emitter.emit("hook", cleanHook);
57+
await harness.renderOnce();
58+
const frame = harness.captureCharFrame();
59+
expect(frame).toContain("format");
60+
expect(frame).not.toContain("Console");
61+
expect(host.shell.streamLog).toEqual([]);
62+
expect(host.shell.statusFlash).toContain("format");
63+
expect(harness.renderer.console.getCachedLogs()).toContain(
64+
"hook format ran",
65+
);
66+
clock.advance(0);
67+
} finally {
68+
host.dispose();
69+
harness.destroy();
70+
}
71+
});
72+
73+
test("a row notice stays on the transcript and is not mirrored", async () => {
74+
const harness = await createHarness({
75+
width: 100,
76+
height: 30,
77+
consoleMode: "console-overlay",
78+
clock: new ManualClock(),
79+
});
80+
const emitter = new EventEmitter();
81+
const host = await mountProductHost({
82+
title: "test-session",
83+
eventEmitter: emitter,
84+
send: () => undefined,
85+
interrupt: () => undefined,
86+
deliver: () => undefined,
87+
createRenderer: async () => harness.renderer,
88+
});
89+
try {
90+
harness.renderer.console.clear();
91+
emitter.emit("hook", failingHook);
92+
await harness.renderOnce();
93+
const frame = harness.captureCharFrame();
94+
expect(frame).toMatch(/format[^\n]*\b2\b/);
95+
expect(frame).not.toContain("Console");
96+
expect(harness.renderer.console.getCachedLogs()).not.toContain(
97+
"prettier not found",
98+
);
99+
} finally {
100+
host.dispose();
101+
harness.destroy();
102+
}
103+
});
104+
});

0 commit comments

Comments
 (0)