Skip to content

Commit 7b7794a

Browse files
authored
fix: support Ctrl-Z job-control suspend (#269)
1 parent b3bb1b0 commit 7b7794a

3 files changed

Lines changed: 330 additions & 2 deletions

File tree

src/core/jobControl.test.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { KeyEvent } from "@opentui/core";
3+
import { installJobControlSuspendSupport } from "./jobControl";
4+
5+
function createTestKey(overrides: Partial<KeyEvent> = {}) {
6+
let defaultPrevented = false;
7+
let propagationStopped = false;
8+
9+
return {
10+
ctrl: false,
11+
get defaultPrevented() {
12+
return defaultPrevented;
13+
},
14+
meta: false,
15+
name: "z",
16+
get propagationStopped() {
17+
return propagationStopped;
18+
},
19+
shift: false,
20+
preventDefault() {
21+
defaultPrevented = true;
22+
},
23+
stopPropagation() {
24+
propagationStopped = true;
25+
},
26+
...overrides,
27+
} as KeyEvent;
28+
}
29+
30+
function createMockRenderer() {
31+
const keypressListeners = new Set<(key: KeyEvent) => void>();
32+
33+
return {
34+
isDestroyed: false,
35+
keyInput: {
36+
off(_event: "keypress", listener: (key: KeyEvent) => void) {
37+
keypressListeners.delete(listener);
38+
},
39+
on(_event: "keypress", listener: (key: KeyEvent) => void) {
40+
keypressListeners.add(listener);
41+
},
42+
},
43+
keypressListeners,
44+
resumeCalls: 0,
45+
suspendCalls: 0,
46+
emitKeypress(key: KeyEvent) {
47+
for (const listener of keypressListeners) {
48+
listener(key);
49+
}
50+
},
51+
resume() {
52+
this.resumeCalls += 1;
53+
},
54+
suspend() {
55+
this.suspendCalls += 1;
56+
},
57+
};
58+
}
59+
60+
function createSignalHarness() {
61+
const listeners = new Map<NodeJS.Signals, Set<() => void>>();
62+
const onceWrappers = new Map<() => void, () => void>();
63+
const removed: NodeJS.Signals[] = [];
64+
65+
return {
66+
emit(signal: NodeJS.Signals) {
67+
const signalListeners = listeners.get(signal);
68+
if (!signalListeners) {
69+
return;
70+
}
71+
72+
const snapshot = Array.from(signalListeners);
73+
for (const listener of snapshot) {
74+
listener();
75+
}
76+
},
77+
listenerCount(signal: NodeJS.Signals) {
78+
return listeners.get(signal)?.size ?? 0;
79+
},
80+
off(signal: NodeJS.Signals, listener: () => void) {
81+
removed.push(signal);
82+
listeners.get(signal)?.delete(listener);
83+
const wrapped = onceWrappers.get(listener);
84+
if (wrapped) {
85+
listeners.get(signal)?.delete(wrapped);
86+
onceWrappers.delete(listener);
87+
}
88+
},
89+
once(signal: NodeJS.Signals, listener: () => void) {
90+
const wrapped = () => {
91+
listeners.get(signal)?.delete(wrapped);
92+
onceWrappers.delete(listener);
93+
listener();
94+
};
95+
onceWrappers.set(listener, wrapped);
96+
97+
let signalListeners = listeners.get(signal);
98+
if (!signalListeners) {
99+
signalListeners = new Set();
100+
listeners.set(signal, signalListeners);
101+
}
102+
signalListeners.add(wrapped);
103+
},
104+
removed,
105+
};
106+
}
107+
108+
describe("installJobControlSuspendSupport", () => {
109+
test("does not install keypress listeners on Windows", () => {
110+
const renderer = createMockRenderer();
111+
112+
installJobControlSuspendSupport(renderer, {
113+
platform: "win32",
114+
});
115+
116+
expect(renderer.keypressListeners.size).toBe(0);
117+
});
118+
119+
test("ignores keys other than Ctrl-Z", () => {
120+
const renderer = createMockRenderer();
121+
const sentSignals: NodeJS.Signals[] = [];
122+
123+
installJobControlSuspendSupport(renderer, {
124+
kill: (_pid, signal) => sentSignals.push(signal),
125+
platform: "linux",
126+
});
127+
128+
const plainZ = createTestKey({ name: "z" });
129+
renderer.emitKeypress(plainZ);
130+
131+
expect(plainZ.defaultPrevented).toBe(false);
132+
expect(renderer.suspendCalls).toBe(0);
133+
expect(sentSignals).toEqual([]);
134+
});
135+
136+
test("suspends the foreground process group on Ctrl-Z and resumes on SIGCONT", () => {
137+
const renderer = createMockRenderer();
138+
const signals = createSignalHarness();
139+
const sentSignals: Array<{ pid: number; signal: NodeJS.Signals }> = [];
140+
141+
installJobControlSuspendSupport(renderer, {
142+
kill: (pid, signal) => sentSignals.push({ pid, signal }),
143+
off: signals.off,
144+
once: signals.once,
145+
platform: "linux",
146+
});
147+
148+
const ctrlZ = createTestKey({ ctrl: true, name: "z" });
149+
renderer.emitKeypress(ctrlZ);
150+
expect(ctrlZ.defaultPrevented).toBe(true);
151+
expect(ctrlZ.propagationStopped).toBe(true);
152+
expect(renderer.suspendCalls).toBe(1);
153+
expect(signals.listenerCount("SIGCONT")).toBe(1);
154+
expect(sentSignals).toEqual([{ pid: 0, signal: "SIGTSTP" }]);
155+
156+
signals.emit("SIGCONT");
157+
expect(renderer.resumeCalls).toBe(1);
158+
expect(signals.listenerCount("SIGCONT")).toBe(0);
159+
});
160+
161+
test("does not resume a destroyed renderer after SIGCONT", () => {
162+
const renderer = createMockRenderer();
163+
const signals = createSignalHarness();
164+
165+
installJobControlSuspendSupport(renderer, {
166+
kill: () => undefined,
167+
off: signals.off,
168+
once: signals.once,
169+
platform: "linux",
170+
});
171+
172+
renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" }));
173+
renderer.isDestroyed = true;
174+
signals.emit("SIGCONT");
175+
176+
expect(renderer.suspendCalls).toBe(1);
177+
expect(renderer.resumeCalls).toBe(0);
178+
});
179+
180+
test("restores the renderer if SIGTSTP cannot be sent", () => {
181+
const renderer = createMockRenderer();
182+
const signals = createSignalHarness();
183+
184+
installJobControlSuspendSupport(renderer, {
185+
kill: () => {
186+
throw new Error("unsupported signal");
187+
},
188+
off: signals.off,
189+
once: signals.once,
190+
platform: "linux",
191+
});
192+
193+
renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" }));
194+
expect(renderer.suspendCalls).toBe(1);
195+
expect(renderer.resumeCalls).toBe(1);
196+
expect(signals.listenerCount("SIGCONT")).toBe(0);
197+
});
198+
199+
test("dispose removes the keypress listener and pending SIGCONT listener", () => {
200+
const renderer = createMockRenderer();
201+
const signals = createSignalHarness();
202+
203+
const support = installJobControlSuspendSupport(renderer, {
204+
kill: () => undefined,
205+
off: signals.off,
206+
once: signals.once,
207+
platform: "linux",
208+
});
209+
210+
renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" }));
211+
support.dispose();
212+
213+
expect(renderer.keypressListeners.size).toBe(0);
214+
expect(signals.listenerCount("SIGCONT")).toBe(0);
215+
216+
renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" }));
217+
expect(renderer.suspendCalls).toBe(1);
218+
});
219+
});

src/core/jobControl.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { CliRenderer, KeyEvent } from "@opentui/core";
2+
3+
type SignalListener = () => void;
4+
type KeypressListener = (key: KeyEvent) => void;
5+
6+
type JobControlRenderer = Pick<CliRenderer, "isDestroyed" | "resume" | "suspend"> & {
7+
keyInput: {
8+
off: (event: "keypress", listener: KeypressListener) => unknown;
9+
on: (event: "keypress", listener: KeypressListener) => unknown;
10+
};
11+
};
12+
13+
/** Test seams for installing process-level Unix job-control signal handling. */
14+
export interface JobControlSuspendDeps {
15+
kill?: (pid: number, signal: NodeJS.Signals) => unknown;
16+
off?: (signal: NodeJS.Signals, listener: SignalListener) => unknown;
17+
once?: (signal: NodeJS.Signals, listener: SignalListener) => unknown;
18+
platform?: NodeJS.Platform | string;
19+
/** Signal target passed to process.kill; defaults to 0 for the foreground process group. */
20+
pid?: number;
21+
}
22+
23+
export interface JobControlSuspendSupport {
24+
/** Remove listeners installed for the lifetime of one app renderer. */
25+
dispose: () => void;
26+
}
27+
28+
/** Match the parsed Ctrl-Z shortcut that opencode binds to its terminal suspend command. */
29+
function isCtrlZ(key: KeyEvent) {
30+
return key.ctrl && !key.meta && !key.shift && key.name === "z";
31+
}
32+
33+
/**
34+
* Install Ctrl-Z job-control suspend support for OpenTUI raw-mode input.
35+
*
36+
* OpenTUI receives Ctrl-Z as a parsed keypress instead of letting the terminal driver turn it into
37+
* SIGTSTP. Match the common TUI pattern used by apps like opencode: treat Ctrl-Z as an app command,
38+
* ask OpenTUI to restore the terminal, then send SIGTSTP to the foreground process group so the
39+
* shell can manage Hunk as a normal suspended job. SIGCONT resumes the renderer after `fg`.
40+
*/
41+
export function installJobControlSuspendSupport(
42+
renderer: JobControlRenderer,
43+
deps: JobControlSuspendDeps = {},
44+
): JobControlSuspendSupport {
45+
const platform = deps.platform ?? process.platform;
46+
if (platform === "win32") {
47+
return { dispose: () => undefined };
48+
}
49+
50+
const kill = deps.kill ?? process.kill.bind(process);
51+
const off = deps.off ?? process.off.bind(process);
52+
const once = deps.once ?? process.once.bind(process);
53+
const pid = deps.pid ?? 0;
54+
let disposed = false;
55+
let resumeOnContinue: SignalListener | null = null;
56+
57+
const clearPendingContinue = () => {
58+
if (resumeOnContinue) {
59+
off("SIGCONT", resumeOnContinue);
60+
resumeOnContinue = null;
61+
}
62+
};
63+
64+
const suspend = () => {
65+
resumeOnContinue = () => {
66+
resumeOnContinue = null;
67+
if (!renderer.isDestroyed) {
68+
renderer.resume();
69+
}
70+
};
71+
72+
renderer.suspend();
73+
once("SIGCONT", resumeOnContinue);
74+
75+
try {
76+
kill(pid, "SIGTSTP");
77+
} catch {
78+
// If the platform/runtime refuses SIGTSTP, leave the app usable instead of half-suspended.
79+
clearPendingContinue();
80+
if (!renderer.isDestroyed) {
81+
renderer.resume();
82+
}
83+
}
84+
};
85+
86+
const keypressListener: KeypressListener = (key) => {
87+
if (disposed || renderer.isDestroyed || !isCtrlZ(key)) {
88+
return;
89+
}
90+
91+
key.preventDefault();
92+
key.stopPropagation();
93+
suspend();
94+
};
95+
96+
renderer.keyInput.on("keypress", keypressListener);
97+
98+
return {
99+
dispose: () => {
100+
disposed = true;
101+
clearPendingContinue();
102+
renderer.keyInput.off("keypress", keypressListener);
103+
},
104+
};
105+
}

src/main.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { createCliRenderer } from "@opentui/core";
44
import { createRoot } from "@opentui/react";
55
import { formatCliError } from "./core/errors";
6+
import { installJobControlSuspendSupport } from "./core/jobControl";
67
import { pagePlainText } from "./core/pager";
78
import { shutdownSession } from "./core/shutdown";
89
import { prepareStartupPlan } from "./core/startup";
@@ -72,7 +73,9 @@ async function main() {
7273
onDestroy: () => controllingTerminal?.close(),
7374
});
7475

75-
const root = createRoot(renderer);
76+
const appRenderer = renderer;
77+
const jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer);
78+
const root = createRoot(appRenderer);
7679
let shuttingDown = false;
7780

7881
/** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */
@@ -82,8 +85,9 @@ async function main() {
8285
}
8386

8487
shuttingDown = true;
88+
jobControlSuspendSupport.dispose();
8589
hostClient.stop();
86-
shutdownSession({ root, renderer });
90+
shutdownSession({ root, renderer: appRenderer });
8791
}
8892

8993
// The app owns the full alternate screen session from this point on.

0 commit comments

Comments
 (0)