From 3baa2bceaa7f5abcb19c18756c8d204a00ec6c4c Mon Sep 17 00:00:00 2001 From: Mattias Lundell Date: Fri, 3 Jul 2026 12:26:06 +0200 Subject: [PATCH] fix(apps): restore terminal on `apps shell` exit (stop clobbering the shell) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exiting `opper apps shell` left the local terminal in a broken input mode: every keypress produced CSI-u byte soup (";1:3u") and zsh spat "command not found". Cause: restore() only did setRawMode(false). The remote PTY (ttyd + whatever ran in it) enables terminal modes via output bytes we forward to stdout — most painfully the kitty keyboard protocol — and nothing ever turned them back off, so they persisted after disconnect. restore() now also writes the matching "disable" sequences (pop kitty keyboard flags, bracketed paste off, mouse reporting off, show cursor, normal keypad) — all no-ops on modes that weren't set. Made it idempotent and also run it on exit/SIGINT/SIGTERM/SIGHUP, so an abrupt disconnect (not just a clean one) restores the terminal too. Found by hitting the clobber live after an `apps shell` session. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/apps-shell.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/commands/apps-shell.ts b/src/commands/apps-shell.ts index a505480..d12b1e6 100644 --- a/src/commands/apps-shell.ts +++ b/src/commands/apps-shell.ts @@ -41,19 +41,48 @@ export async function appsShellCommand(opts: AppsShellOptions): Promise { }); let raw = false; + let restored = false; const enterRaw = () => { if (!raw) { stdin.setRawMode(true); raw = true; } }; + // Undo everything the remote PTY (ttyd + whatever ran in it) may have turned + // on, so it doesn't persist after we disconnect and clobber the user's shell. + // setRawMode(false) alone is not enough: the remote enables terminal modes via + // output bytes we forwarded to stdout — most painfully the kitty keyboard + // protocol, which then turns every keypress into CSI-u byte soup (";1:3u"). + // These are all "disable" sequences and are no-ops on modes that weren't set. const restore = () => { + if (restored) return; + restored = true; if (raw) { stdin.setRawMode(false); raw = false; } + if (stdout.isTTY) { + stdout.write( + "\x1b[", // normal keypad (DECPNM) + ); + } stdin.pause(); }; + // Safety net: the .finally() below restores on a normal disconnect, but an + // abrupt exit (Ctrl-\, SIGTERM, terminal hang-up) would otherwise strand the + // terminal in the broken mode. restore() is idempotent and sync-safe. + const onExitSignal = () => { + restore(); + process.exit(0); + }; + process.once("exit", restore); + process.once("SIGINT", onExitSignal); + process.once("SIGTERM", onExitSignal); + process.once("SIGHUP", onExitSignal); const onStdin = (chunk: Buffer) => { // INPUT frame: command byte + raw bytes (binary-safe, so Ctrl chars