fix(tui): kept ProcessTerminal and window-title writes headless under the test runtime

- `ProcessTerminal` now captures a per-instance `#headless` from `isTerminalHeadless()` at construction and re-reads it in `start()`; when set, `#safeWrite`, `start`, `stop`, `drainInput`, and `setProgress` short-circuit, so frame paints, `start()` probes (OSC 11 / DA1 / kitty), the progress keepalive, stdin raw mode, SIGWINCH, and teardown escapes no longer reach a real terminal during `bun test` (previously `#safeWrite` only skipped on `!isTTY`, so an interactive terminal leaked stray boxes and probe queries).
- Gated the `emergencyTerminalRestore()` blind-restore branch and `TerminalInfo.sendNotification` on the headless flag, and guarded `setTerminalTitle`/`pushTerminalTitle`/`popTerminalTitle` in `title-generator.ts`.
- Opted the terminal-contract suites (terminal-appearance, notifications, emergency-restore-altscreen, issue-2034 write gate, process-terminal-render-harness, render-stress-harness) back into real I/O with `setTerminalHeadless(false)`, restoring on teardown.
- Added `process-terminal-headless.test.ts` asserting zero writes under a forced TTY when headless and real frame/probe output after opt-out.
This commit is contained in:
can1357
2026-06-17 01:21:03 +02:00
parent dcd1836d51
commit 48766eb88a
12 changed files with 175 additions and 10 deletions
+1
View File
@@ -36,6 +36,7 @@
- Fixed messaging a `parked` subagent that was restored from disk (Agent Hub scan, or a resumed/restarted session) failing with `cannot be revived (no reviver registered)` even though its transcript was intact. Such refs carry a session file but no in-memory reviver — the executor's live reviver closure dies with the spawning turn/process — so IRC sends and Agent Hub focus refused them. `AgentLifecycleManager.ensureLive` now cold-revives them through a persisted-subagent reviver factory (installed by the top-level interactive/RPC session) that rebuilds the subagent from its JSONL the way `--resume` rebuilds a session: it reopens the file and replays it through `createAgentSession`, but sources the runtime contract from a now-readable `session_init` record (`SessionManager.peekSessionInit`) so tools, system prompt, output schema, and kind are restored rather than resurrected as a default top-level session. `session_init` now also persists the effective `spawns` allowlist and read-summarization flag so a cold revive keeps the original capability surface (old files without them deny re-spawning rather than defaulting to wildcard). Isolated runs and pre-`session_init` files whose recorded workspace no longer exists stay transcript-only (`history://`).
### Fixed
- Fixed the terminal window-title OSC writes (`setTerminalTitle`/`pushTerminalTitle`/`popTerminalTitle`) leaking escape sequences to a developer's terminal during `bun test`; they now skip when the terminal is headless (the test-runtime default), matching the `ProcessTerminal` render/probe suppression so interactive-mode tests no longer paint to the real terminal
- Fixed empty CLI sessions being retained after opening `omp` and exiting without a prompt ([#2800](https://github.com/can1357/oh-my-pi/issues/2800)).
@@ -4,7 +4,7 @@
import * as path from "node:path";
import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
import { logger, prompt } from "@oh-my-pi/pi-utils";
import { isTerminalHeadless, logger, prompt } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { resolveRoleSelection } from "../config/model-resolver";
@@ -391,7 +391,7 @@ export function formatSessionTerminalTitle(sessionName: string | undefined, cwd?
* Set the terminal title using OSC 0 (sets both tab and window title). Unsupported terminals ignore it.
*/
export function setTerminalTitle(title: string): void {
if (!process.stdout.isTTY) return;
if (!process.stdout.isTTY || isTerminalHeadless()) return;
process.stdout.write(`\x1b]0;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
}
@@ -403,7 +403,7 @@ export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: s
* Save the current terminal title on terminals that support xterm window ops.
*/
export function pushTerminalTitle(): void {
if (!process.stdout.isTTY) return;
if (!process.stdout.isTTY || isTerminalHeadless()) return;
process.stdout.write("\x1b[22;2t");
}
@@ -411,6 +411,6 @@ export function pushTerminalTitle(): void {
* Restore the previously saved terminal title on terminals that support xterm window ops.
*/
export function popTerminalTitle(): void {
if (!process.stdout.isTTY) return;
if (!process.stdout.isTTY || isTerminalHeadless()) return;
process.stdout.write("\x1b[23;2t");
}
+1
View File
@@ -26,6 +26,7 @@
- Fixed inline math span detection so escaped dollars and currency-like patterns (such as `$5` and `$10`) are not converted as math
- Fixed Mermaid diagram rendering in Markdown code blocks to clip each ASCII line to content width before wrapping, preventing preformatted diagram rows from fragmenting
- Fixed fullscreen overlays losing keyboard focus to hidden prompt surfaces, which could make settings unresponsive while a background approval request was pending ([#2789](https://github.com/can1357/oh-my-pi/issues/2789)).
- Fixed `bun test` runs inside a real terminal leaking TUI output: `ProcessTerminal` now honors a headless test-runtime default, so frame paints, `start()` capability probes (OSC 11 / DA1 / kitty), the progress keepalive, notifications, and teardown escapes no longer reach the developer's terminal, and stdin raw mode is never engaged. Previously `#safeWrite` only skipped on `!process.stdout.isTTY`, so a developer running the suite in an interactive terminal saw stray status/editor boxes and probe queries. Terminal-contract suites opt back into real I/O via `setTerminalHeadless(false)`
## [16.0.2] - 2026-06-16
+2 -2
View File
@@ -1,5 +1,5 @@
import { encodeSixel } from "@oh-my-pi/pi-natives";
import { $env, isBunTestRuntime } from "@oh-my-pi/pi-utils";
import { $env, isBunTestRuntime, isTerminalHeadless } from "@oh-my-pi/pi-utils";
import {
detectKittyUnicodePlaceholdersSupport,
getKittyGraphics,
@@ -97,7 +97,7 @@ export class TerminalInfo {
}
sendNotification(message: string | TerminalNotification): void {
if (isNotificationSuppressed()) return;
if (isNotificationSuppressed() || isTerminalHeadless()) return;
process.stdout.write(this.formatNotification(message));
}
}
+17 -2
View File
@@ -1,6 +1,6 @@
import { dlopen, FFIType, ptr } from "bun:ffi";
import * as fs from "node:fs";
import { $env, isBunTestRuntime, logger } from "@oh-my-pi/pi-utils";
import { $env, isBunTestRuntime, isTerminalHeadless, logger } from "@oh-my-pi/pi-utils";
import { setKittyProtocolActive } from "./keys";
import { StdinBuffer } from "./stdin-buffer";
import { NotifyProtocol, setCellDimensions, setOsc99Supported, TERMINAL } from "./terminal-capabilities";
@@ -249,7 +249,7 @@ export function emergencyTerminalRestore(): void {
altScreenActive = false;
}
terminal.showCursor();
} else if (terminalEverStarted) {
} else if (terminalEverStarted && !isTerminalHeadless()) {
// Blind restore only if we know a terminal was started but lost track of it
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
process.stdout.write(
@@ -404,6 +404,10 @@ export class ProcessTerminal implements Terminal {
#stdinBuffer?: StdinBuffer;
#stdinDataHandler?: (data: string) => void;
#dead = false;
// Captured at construction and re-read at start(): when true, every real
// terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
// suppressed. Defaults on under `bun test` — see isTerminalHeadless().
#headless = isTerminalHeadless();
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
#stdoutErrorCleanup?: () => void;
#stdoutErrorHandler = (err: Error) => {
@@ -459,6 +463,13 @@ export class ProcessTerminal implements Terminal {
this.#inputHandler = onInput;
this.#resizeHandler = onResize;
// Headless (tests): suppress every real-terminal side effect. Skip raw
// mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
// ownership; #safeWrite is also a no-op, so frame paints and teardown
// escapes never reach the developer's terminal during `bun test`.
this.#headless = isTerminalHeadless();
if (this.#headless) return;
// Register for emergency cleanup
activeTerminal = this;
terminalEverStarted = true;
@@ -1134,6 +1145,7 @@ export class ProcessTerminal implements Terminal {
}
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
if (this.#headless) return;
if (this.#kittyProtocolActive) {
// Disable Kitty keyboard protocol first so any late key releases
// do not generate new Kitty escape sequences.
@@ -1176,6 +1188,7 @@ export class ProcessTerminal implements Terminal {
}
stop(): void {
if (this.#headless) return;
// Unregister from emergency cleanup
if (activeTerminal === this) {
activeTerminal = null;
@@ -1303,6 +1316,7 @@ export class ProcessTerminal implements Terminal {
}
#safeWrite(data: string): void {
if (this.#headless) return;
if (this.#dead) return;
// Skip control sequences when stdout isn't a TTY (piped output, tests, log
// files). They serve no purpose there and would surface as visible noise.
@@ -1385,6 +1399,7 @@ export class ProcessTerminal implements Terminal {
}
setProgress(active: boolean): void {
if (this.#headless) return;
if (active) {
this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
if (!this.#progressTimer) {
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { emergencyTerminalRestore, ProcessTerminal, setAltScreenActive } from "@oh-my-pi/pi-tui/terminal";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
// Regression coverage for the Windows shell-handoff corruption on exit:
// `emergencyTerminalRestore()` used to write DECRST 1049 ("leave alternate
@@ -16,6 +17,11 @@ import { emergencyTerminalRestore, ProcessTerminal, setAltScreenActive } from "@
const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const stdinSetRawModeDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode");
// This suite asserts the real emergencyTerminalRestore() write path, so it opts
// out of the test-default headless suppression. Restored in afterEach (not the
// helper) so the blind restore branch — gated on !isTerminalHeadless() — still
// runs while the test drives emergencyTerminalRestore() after terminal.stop().
let previousHeadless = false;
function restoreProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
@@ -48,8 +54,13 @@ function startCapturedTerminal() {
}
describe("emergencyTerminalRestore alt-screen gating", () => {
beforeEach(() => {
previousHeadless = setTerminalHeadless(false);
});
afterEach(() => {
setAltScreenActive(false);
setTerminalHeadless(previousHeadless);
vi.restoreAllMocks();
restoreProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { chunkForConPTY, ProcessTerminal } from "@oh-my-pi/pi-tui/terminal";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
// Regression test for https://github.com/can1357/oh-my-pi/issues/2034
//
@@ -161,6 +162,9 @@ describe("issue #2034: chunk large terminal writes on Windows ConPTY", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalWslDistro = Bun.env.WSL_DISTRO_NAME;
const originalWslInterop = Bun.env.WSL_INTEROP;
// This block drives the real ProcessTerminal#write path, so it opts out of
// the test-default headless suppression for the duration of each case.
let previousHeadless = false;
function setEnv(key: string, value: string | undefined): void {
if (value === undefined) delete Bun.env[key];
@@ -173,10 +177,12 @@ describe("issue #2034: chunk large terminal writes on Windows ConPTY", () => {
// Clear WSL markers by default; tests opt in.
setEnv("WSL_DISTRO_NAME", undefined);
setEnv("WSL_INTEROP", undefined);
previousHeadless = setTerminalHeadless(false);
});
afterEach(() => {
vi.restoreAllMocks();
setTerminalHeadless(previousHeadless);
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
if (stdinIsTtyDescriptor) Object.defineProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
else Reflect.deleteProperty(process.stdin, "isTTY");
+7
View File
@@ -7,6 +7,7 @@ import {
setOsc99Supported,
TERMINAL,
} from "@oh-my-pi/pi-tui/terminal-capabilities";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
@@ -54,13 +55,19 @@ function setupProcessTerminal() {
return { terminal, writes, received };
}
// setupProcessTerminal() drives the real ProcessTerminal start()/probe path, so
// these cases opt out of the test-default headless suppression.
let previousHeadless = false;
describe("terminal notifications", () => {
beforeEach(() => {
setOsc99Supported(false);
previousHeadless = setTerminalHeadless(false);
});
afterEach(() => {
vi.restoreAllMocks();
setTerminalHeadless(previousHeadless);
setOsc99Supported(false);
mutableTerminal.notifyProtocol = originalNotifyProtocol;
restoreEnv("PI_TUI_OSC99_PROBE", originalOsc99Probe);
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { ProcessTerminal } from "@oh-my-pi/pi-tui/terminal";
import { isTerminalHeadless, setTerminalHeadless } from "@oh-my-pi/pi-utils";
// Regression: running `bun test` inside a real TTY used to paint the TUI frame,
// the start() capability probes (OSC 11 / DA1 / kitty), and the editor/status
// box straight to the developer's terminal — `#safeWrite` only skipped on
// `!process.stdout.isTTY`, which is false in an interactive terminal. The
// headless default (on under the test runtime) must suppress every real
// terminal side effect regardless of isTTY, while an explicit opt-out still
// drives the real pipeline for the terminal-contract suites.
const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const stdinSetRawModeDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode");
function restoreProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(target, key, descriptor);
return;
}
delete (target as Record<string, unknown>)[key];
}
describe("ProcessTerminal headless suppression", () => {
let writes: string[];
let rawModeCalls: number;
beforeEach(() => {
writes = [];
rawModeCalls = 0;
// Force a real TTY: the exact condition under which the leak surfaced.
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", {
value: () => {
rawModeCalls++;
return process.stdin;
},
configurable: true,
});
vi.spyOn(process, "kill").mockReturnValue(true);
vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, "setEncoding").mockImplementation(() => process.stdin);
vi.spyOn(process.stdout, "write").mockImplementation(chunk => {
writes.push(typeof chunk === "string" ? chunk : chunk.toString());
return true;
});
});
afterEach(() => {
vi.restoreAllMocks();
restoreProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor);
});
it("writes nothing to a real TTY while headless (the bun-test default)", () => {
expect(isTerminalHeadless()).toBe(true);
const terminal = new ProcessTerminal();
// start() would emit OSC 11 + DA1 + kitty probes; write() a TUI frame;
// setProgress() the OSC 9;4 keepalive; stop() the teardown escapes.
terminal.start(
() => {},
() => {},
);
terminal.write("frame paint");
terminal.setProgress(true);
terminal.stop();
expect(writes).toEqual([]);
expect(rawModeCalls).toBe(0);
});
it("drives the real terminal once a suite opts out via setTerminalHeadless(false)", () => {
const previous = setTerminalHeadless(false);
const terminal = new ProcessTerminal();
try {
terminal.start(
() => {},
() => {},
);
terminal.write("frame paint");
const emitted = writes.join("");
expect(emitted).toContain("frame paint");
// start() probes the background color (OSC 11) for dark/light detection.
expect(emitted).toContain("\x1b]11;?");
} finally {
terminal.stop();
setTerminalHeadless(previous);
}
});
});
@@ -1,6 +1,7 @@
import { vi } from "bun:test";
import { type Component, TUI } from "@oh-my-pi/pi-tui";
import { ProcessTerminal } from "@oh-my-pi/pi-tui/terminal";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
// Pristine descriptors, captured once at module load. Every dispose() restores
// to these so the harness is full-suite safe across repeated create/dispose
@@ -70,6 +71,10 @@ export function createProcessTerminalRenderHarness(
initialColumns = 100,
initialRows = 30,
): ProcessTerminalRenderHarness {
// This harness exercises the real ProcessTerminal I/O pipeline, so it opts
// out of the test-default headless suppression and restores the prior value
// on dispose.
const previousHeadless = setTerminalHeadless(false);
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true });
@@ -92,7 +97,13 @@ export function createProcessTerminalRenderHarness(
const tui = new TUI(terminal);
const probe = new WidthProbe();
tui.addChild(probe);
tui.start();
try {
tui.start();
} catch (err) {
// A start() regression must not poison the worker with headless=false.
setTerminalHeadless(previousHeadless);
throw err;
}
const settle = () => Bun.sleep(SETTLE_MS);
@@ -118,6 +129,7 @@ export function createProcessTerminalRenderHarness(
},
dispose() {
tui.stop();
setTerminalHeadless(previousHeadless);
for (const spy of spies) spy.mockRestore();
for (const [target, key, descriptor] of PRISTINE) {
if (descriptor) Object.defineProperty(target, key, descriptor);
@@ -22,6 +22,7 @@ import {
visibleWidth,
wrapTextWithAnsi,
} from "@oh-my-pi/pi-tui/utils";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
import { StressRenderScheduler } from "./render-stress-scheduler";
import { VirtualTerminal } from "./virtual-terminal";
@@ -4039,6 +4040,10 @@ export async function runNoReflowResizeNotificationRegression(): Promise<void> {
});
Object.defineProperty(process, "kill", { value: () => true, configurable: true });
// Exercises the real ProcessTerminal stdin/stdout pipeline; opt out of
// the test-default headless suppression inside the try so the finally
// below always restores the prior value, even on a start()/render throw.
let previousHeadless = false;
const term = new ProcessTerminal();
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, true, { renderScheduler: scheduler });
@@ -4048,6 +4053,7 @@ export async function runNoReflowResizeNotificationRegression(): Promise<void> {
tui.addChild(component);
try {
previousHeadless = setTerminalHeadless(false);
tui.start();
await scheduler.drain(drainTarget);
@@ -4082,6 +4088,7 @@ export async function runNoReflowResizeNotificationRegression(): Promise<void> {
restoreOwnProperty(process.stdin, "pause", stdinPause);
restoreOwnProperty(process.stdout, "write", stdoutWrite);
restoreOwnProperty(process, "kill", processKill);
setTerminalHeadless(previousHeadless);
}
});
});
@@ -7,6 +7,7 @@ import {
getTerminalInfo,
setCellDimensions,
} from "@oh-my-pi/pi-tui/terminal-capabilities";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
@@ -17,6 +18,10 @@ const stdinSetRawModeDescriptor = Object.getOwnPropertyDescriptor(process.stdin,
const originalWslDistroName = Bun.env.WSL_DISTRO_NAME;
const originalWslInterop = Bun.env.WSL_INTEROP;
// These suites drive the real ProcessTerminal start()/probe pipeline, so they
// opt out of the test-default headless suppression and restore it per case.
let previousHeadless = false;
function restoreProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(target, key, descriptor);
@@ -38,11 +43,13 @@ describe("ProcessTerminal OSC 11 appearance detection", () => {
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true });
previousHeadless = setTerminalHeadless(false);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
setTerminalHeadless(previousHeadless);
restoreProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor);
@@ -449,11 +456,13 @@ describe("ProcessTerminal DECRQM + in-band resize (DEC 2026/2048)", () => {
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true });
originalCellDims = { ...getCellDimensions() };
previousHeadless = setTerminalHeadless(false);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
setTerminalHeadless(previousHeadless);
restoreProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor);