Skip to content
Open
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
14 changes: 13 additions & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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({
Expand Down
60 changes: 60 additions & 0 deletions packages/tui/test/app-lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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()
}
})
Loading