Skip to content

Commit 0f668e6

Browse files
authored
fix(ui): exit cleanly on Ctrl-C (#292)
1 parent f8bae97 commit 0f668e6

4 files changed

Lines changed: 112 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ All notable user-visible changes to Hunk are documented in this file.
1616

1717
### Fixed
1818

19+
- Fixed Ctrl-C in the live TUI so it exits through Hunk's full shutdown path instead of only destroying the renderer.
20+
1921
## [0.12.0-beta.1] - 2026-05-10
2022

2123
### Added

src/core/jobControl.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import type { KeyEvent } from "@opentui/core";
3-
import { installJobControlSuspendSupport } from "./jobControl";
3+
import { installJobControlInterruptSupport, installJobControlSuspendSupport } from "./jobControl";
44

55
function createTestKey(overrides: Partial<KeyEvent> = {}) {
66
let defaultPrevented = false;
@@ -105,6 +105,58 @@ function createSignalHarness() {
105105
};
106106
}
107107

108+
describe("installJobControlInterruptSupport", () => {
109+
test("routes Ctrl-C through the provided shutdown callback", () => {
110+
const renderer = createMockRenderer();
111+
let interruptCalls = 0;
112+
113+
installJobControlInterruptSupport(renderer, () => {
114+
interruptCalls += 1;
115+
});
116+
117+
const ctrlC = createTestKey({ ctrl: true, name: "c" });
118+
renderer.emitKeypress(ctrlC);
119+
120+
expect(ctrlC.defaultPrevented).toBe(true);
121+
expect(ctrlC.propagationStopped).toBe(true);
122+
expect(interruptCalls).toBe(1);
123+
});
124+
125+
test("ignores non-Ctrl-C keys and removes its listener on dispose", () => {
126+
const renderer = createMockRenderer();
127+
let interruptCalls = 0;
128+
const support = installJobControlInterruptSupport(renderer, () => {
129+
interruptCalls += 1;
130+
});
131+
132+
renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" }));
133+
expect(interruptCalls).toBe(0);
134+
135+
support.dispose();
136+
expect(renderer.keypressListeners.size).toBe(0);
137+
138+
renderer.emitKeypress(createTestKey({ ctrl: true, name: "c" }));
139+
expect(interruptCalls).toBe(0);
140+
});
141+
142+
test("ignores Ctrl-C after the renderer has already been destroyed", () => {
143+
const renderer = createMockRenderer();
144+
let interruptCalls = 0;
145+
146+
installJobControlInterruptSupport(renderer, () => {
147+
interruptCalls += 1;
148+
});
149+
150+
renderer.isDestroyed = true;
151+
const ctrlC = createTestKey({ ctrl: true, name: "c" });
152+
renderer.emitKeypress(ctrlC);
153+
154+
expect(ctrlC.defaultPrevented).toBe(false);
155+
expect(ctrlC.propagationStopped).toBe(false);
156+
expect(interruptCalls).toBe(0);
157+
});
158+
});
159+
108160
describe("installJobControlSuspendSupport", () => {
109161
test("does not install keypress listeners on Windows", () => {
110162
const renderer = createMockRenderer();

src/core/jobControl.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,48 @@ export interface JobControlSuspendSupport {
2525
dispose: () => void;
2626
}
2727

28+
export interface JobControlInterruptSupport {
29+
/** Remove listeners installed for the lifetime of one app renderer. */
30+
dispose: () => void;
31+
}
32+
33+
/** Match the parsed Ctrl-C shortcut that should quit the app in raw mode. */
34+
function isCtrlC(key: KeyEvent) {
35+
return key.ctrl && !key.meta && !key.shift && key.name === "c";
36+
}
37+
2838
/** Match the parsed Ctrl-Z shortcut that opencode binds to its terminal suspend command. */
2939
function isCtrlZ(key: KeyEvent) {
3040
return key.ctrl && !key.meta && !key.shift && key.name === "z";
3141
}
3242

43+
/** Install Ctrl-C handling that routes through the app's full shutdown path. */
44+
export function installJobControlInterruptSupport(
45+
renderer: Pick<JobControlRenderer, "isDestroyed" | "keyInput">,
46+
onInterrupt: () => void,
47+
): JobControlInterruptSupport {
48+
let disposed = false;
49+
50+
const keypressListener: KeypressListener = (key) => {
51+
if (disposed || renderer.isDestroyed || !isCtrlC(key)) {
52+
return;
53+
}
54+
55+
key.preventDefault();
56+
key.stopPropagation();
57+
onInterrupt();
58+
};
59+
60+
renderer.keyInput.on("keypress", keypressListener);
61+
62+
return {
63+
dispose: () => {
64+
disposed = true;
65+
renderer.keyInput.off("keypress", keypressListener);
66+
},
67+
};
68+
}
69+
3370
/**
3471
* Install Ctrl-Z job-control suspend support for OpenTUI raw-mode input.
3572
*

src/main.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
import { createCliRenderer } from "@opentui/core";
44
import { createRoot } from "@opentui/react";
55
import { formatCliError } from "./core/errors";
6-
import { installJobControlSuspendSupport } from "./core/jobControl";
6+
import {
7+
installJobControlInterruptSupport,
8+
installJobControlSuspendSupport,
9+
type JobControlInterruptSupport,
10+
type JobControlSuspendSupport,
11+
} from "./core/jobControl";
712
import { pagePlainText } from "./core/pager";
813
import { shutdownSession } from "./core/shutdown";
914
import { renderStaticDiffPager } from "./ui/staticDiffPager";
@@ -79,15 +84,17 @@ async function main() {
7984
hasControllingTerminal: Boolean(controllingTerminal),
8085
}),
8186
useAlternateScreen: true,
82-
exitOnCtrlC: true,
87+
exitOnCtrlC: false,
8388
openConsoleOnError: true,
8489
onDestroy: () => controllingTerminal?.close(),
8590
});
8691

8792
const appRenderer = renderer;
88-
const jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer);
8993
const root = createRoot(appRenderer);
94+
const shutdownSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
9095
let shuttingDown = false;
96+
let jobControlSuspendSupport: JobControlSuspendSupport = { dispose: () => undefined };
97+
let jobControlInterruptSupport: JobControlInterruptSupport = { dispose: () => undefined };
9198

9299
/** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */
93100
function shutdown() {
@@ -96,11 +103,21 @@ async function main() {
96103
}
97104

98105
shuttingDown = true;
106+
for (const signal of shutdownSignals) {
107+
process.off(signal, shutdown);
108+
}
109+
jobControlInterruptSupport.dispose();
99110
jobControlSuspendSupport.dispose();
100111
hostClient.stop();
101112
shutdownSession({ root, renderer: appRenderer });
102113
}
103114

115+
for (const signal of shutdownSignals) {
116+
process.once(signal, shutdown);
117+
}
118+
jobControlInterruptSupport = installJobControlInterruptSupport(appRenderer, shutdown);
119+
jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer);
120+
104121
// The app owns the full alternate screen session from this point on.
105122
root.render(
106123
<AppHost

0 commit comments

Comments
 (0)