From 48766eb88a143d4d0f225898e681f947df2f2603 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 17 Jun 2026 01:21:03 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/utils/title-generator.ts | 8 +- packages/tui/CHANGELOG.md | 1 + packages/tui/src/terminal-capabilities.ts | 4 +- packages/tui/src/terminal.ts | 19 +++- .../test/emergency-restore-altscreen.test.ts | 13 ++- packages/tui/test/issue-2034-repro.test.ts | 6 ++ packages/tui/test/notifications.test.ts | 7 ++ .../test/process-terminal-headless.test.ts | 96 +++++++++++++++++++ .../test/process-terminal-render-harness.ts | 14 ++- packages/tui/test/render-stress-harness.ts | 7 ++ packages/tui/test/terminal-appearance.test.ts | 9 ++ 12 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 packages/tui/test/process-terminal-headless.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b0dc9bada..d352479be 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index c7a11a6c6..59866f87f 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -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"); } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 09ca6237c..35fd66858 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 8881e0395..8c44619a3 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -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)); } } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 83bb0a0db..ec3fd2ff4 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -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 { + 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) { diff --git a/packages/tui/test/emergency-restore-altscreen.test.ts b/packages/tui/test/emergency-restore-altscreen.test.ts index 4f00d77fd..a93b97eb3 100644 --- a/packages/tui/test/emergency-restore-altscreen.test.ts +++ b/packages/tui/test/emergency-restore-altscreen.test.ts @@ -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); diff --git a/packages/tui/test/issue-2034-repro.test.ts b/packages/tui/test/issue-2034-repro.test.ts index d82f3ef5f..bf8b489db 100644 --- a/packages/tui/test/issue-2034-repro.test.ts +++ b/packages/tui/test/issue-2034-repro.test.ts @@ -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"); diff --git a/packages/tui/test/notifications.test.ts b/packages/tui/test/notifications.test.ts index acafc3b64..388e3ece0 100644 --- a/packages/tui/test/notifications.test.ts +++ b/packages/tui/test/notifications.test.ts @@ -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); diff --git a/packages/tui/test/process-terminal-headless.test.ts b/packages/tui/test/process-terminal-headless.test.ts new file mode 100644 index 000000000..d7acd9126 --- /dev/null +++ b/packages/tui/test/process-terminal-headless.test.ts @@ -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)[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); + } + }); +}); diff --git a/packages/tui/test/process-terminal-render-harness.ts b/packages/tui/test/process-terminal-render-harness.ts index 29f119782..0b547cd7c 100644 --- a/packages/tui/test/process-terminal-render-harness.ts +++ b/packages/tui/test/process-terminal-render-harness.ts @@ -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); diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index d108c0f5c..4c9a02778 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -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 { }); 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 { tui.addChild(component); try { + previousHeadless = setTerminalHeadless(false); tui.start(); await scheduler.drain(drainTarget); @@ -4082,6 +4088,7 @@ export async function runNoReflowResizeNotificationRegression(): Promise { restoreOwnProperty(process.stdin, "pause", stdinPause); restoreOwnProperty(process.stdout, "write", stdoutWrite); restoreOwnProperty(process, "kill", processKill); + setTerminalHeadless(previousHeadless); } }); }); diff --git a/packages/tui/test/terminal-appearance.test.ts b/packages/tui/test/terminal-appearance.test.ts index 0de9c635e..ebc56e614 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -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);