From 0cedf05d13c8c386a2639c98efb5f61efac08107 Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 10 Sep 2026 11:00:39 -0500 Subject: [PATCH] fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/web/public/panels-ui.js | 21 +++++++++++++++++++++ src/web/public/terminal-ui.js | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index e1f0e8e1..b3b6fbf9 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -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(() => { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 734b9610..b07d52e7 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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)