diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 17a9a554c2e4..0acf1d9b8f41 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -79,7 +79,7 @@ import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" -import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" +import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" @@ -180,6 +180,18 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { + // Keep ENABLE_PROCESSED_INPUT disabled for the renderer's lifetime. + // Without the guard, runtimes/ConPTY can re-apply the flag, which turns + // Ctrl+C into a CTRL_C_EVENT and causes Windows Terminal to reset the + // tab title. Install before the renderer is created so the console mode + // is correct from the first frame (matches pre-refactor ordering). + const unguard = yield* Effect.acquireRelease( + Effect.sync(() => win32InstallCtrlCGuard()), + (release) => + Effect.sync(() => { + if (typeof release === "function") release() + }), + ) const renderer = yield* Effect.acquireRelease( Effect.tryPromise(() => createCliRenderer({ diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d3983ae8e04b..58cd01f9cc1f 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -125,3 +125,63 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { mock.restore() } }) + +test("installs win32 Ctrl+C guard for renderer lifetime and releases it on shutdown", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + + const win32 = await import("../src/terminal-win32") + let installCalls = 0 + let unhookCalls = 0 + mock.module("../src/terminal-win32", () => ({ + ...win32, + win32InstallCtrlCGuard: () => { + installCalls++ + return () => { + unhookCalls++ + } + }, + })) + + const listeners = new Set(process.listeners("SIGHUP")) + const events = createEventSource() + const calls = createFetch() + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: {}, + pluginHost: { + async start() { + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(Global.defaultLayer)), + ) + await ready + expect(installCalls).toBe(1) + expect(unhookCalls).toBe(0) + + process.emit("SIGHUP") + await task + + expect(setup.renderer.isDestroyed).toBe(true) + expect(unhookCalls).toBe(1) + expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +})