fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend

Ctrl+Z (SIGTSTP) suspends the foreground job on the pane's tty. In a plain
shell session that's the user's own job-control tool (suspend, fg back),
but in claude/omp/pi/codex/etc. sessions it stops an unattended agent loop
dead with no visible output — the same failure shape as an XOFF freeze,
just via job control instead of tty flow control. Ink-based TUIs usually
run in raw mode (ISIG off) where ^Z is inert, but that only holds once the
CLI is actually running and stays in raw mode; it's live at the shell
prompt before launch and during any raw-mode toggle.

Swallow it client-side in attachCustomKeyEventHandler, mode-gated so shell
sessions keep normal job control, mirroring the existing Ctrl+V/Ctrl+Backspace
interception pattern in the same handler. Case-insensitive key match (Caps
Lock flips ev.key to 'Z' without setting shiftKey, so a plain === 'z' check
let the exact suspend keystroke this exists to catch slip through).

Also cover subagent/teammate terminal windows (panels-ui.js's
initTeammateTerminal), which render a separate xterm instance with no
custom key handler at all and are always running an agent CLI — never a
shell — so the trap there is unconditional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
timkjr
2026-09-10 18:30:48 -05:00
co-authored by Claude Sonnet 5
parent 5b667264b4
commit 0cedf05d13
2 changed files with 44 additions and 0 deletions
+21
View File
@@ -2314,6 +2314,27 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
// running an agent CLI (Task-tool dispatched, never a plain shell), so
// unlike the main terminal's mode-gated trap this one is unconditional.
// Mirrors the main terminal's guard in terminal-ui.js's
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
terminal.attachCustomKeyEventHandler((ev) => {
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
ev.preventDefault();
return false;
}
return true;
});
// Wait for terminal renderer to fully initialize before any writes.
// xterm.js needs a few frames after open() before write() is safe.
setTimeout(() => {
+23
View File
@@ -363,6 +363,29 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): in a plain shell session this is the
// user's own job-control tool (suspend a foreground command, `fg` it back) —
// leave it alone. In every other mode (claude/omp/pi/codex/... — Ink/TUI apps
// that normally run in raw mode with ISIG off, so ^Z is usually inert there
// already) suspending the CLI stops an unattended agent loop dead with no
// visible output — the same failure shape as an XOFF freeze. Swallow it
// before xterm can send \x1a into the PTY rather than relying on every CLI's
// raw-mode state holding at every instant (startup, raw-mode toggles, etc).
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
const activeCtrlZSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (activeCtrlZSession && activeCtrlZSession.mode !== 'shell') {
ev.preventDefault();
return false;
}
}
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)