From 66d2034f103b7ef1eba2baa79965597c0981445c Mon Sep 17 00:00:00 2001 From: Jake Massoth Date: Tue, 7 Jul 2026 16:25:29 +0200 Subject: [PATCH] fix(tui): restore Ctrl+\ detach when focusing sessions with enhanced keyboard modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focusing the pinned head-chef entry (Claude Code) left Ctrl+\ unable to detach: the user was stuck in the session. Root cause is not nesting, process groups, or crossterm — it's the terminal keyboard protocol. Claude Code enables xterm modifyOtherKeys (CSI > 4 ; 2 m). zmx faithfully replays the session's saved terminal modes to the client on attach, so the mode is active the instant we attach. Under modifyOtherKeys the terminal encodes Ctrl+\ as the escape sequence CSI 27;5;92~ rather than the raw 0x1C byte that zmx's detach watches for, so zmx never detaches. An idle line cook (no such mode) detaches fine, which is why it looked intermittent. Mitigation (keeps the zmx-attach shell-out): after zmx finishes replaying the session state, disable modifyOtherKeys and pop kitty keyboard flags so Ctrl+\ reaches zmx as raw 0x1C again. Applied to both attach() and attach_raw() via run_attach_restoring_detach(). Tradeoff: the focused app loses enhanced key disambiguation until re-attach. The clean fix belongs in zmx (recognize the encoded Ctrl+\, or don't replay input-only keyboard modes to the client). Verified with a PTY harness that simulates a modifyOtherKeys terminal (encodes Ctrl+\ as Ghostty would): reproduced focused=1 detached=0 before, focused=1 detached=1 after; plain cooks and 'yeschef attach' still detach. cargo fmt/clippy(pedantic)/test and the e2e suite all pass. --- DEVELOPMENT.md | 12 +++++++ src/backend/real.rs | 78 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a413c99..0000ad0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -149,6 +149,18 @@ separators) so the derived name stays a clean zmx session id. missing session yields an empty list, not an error. zmx exposes no per-session active/dead state, so a finished ticket's session simply disappears — it surfaces as "gone" in `status`, never "dead". +- **`Ctrl+\` detach vs. enhanced keyboard protocols.** zmx detaches when it reads the + raw byte `0x1C` (`Ctrl+\`). A focused session running a full-screen app that enables + an *enhanced keyboard protocol* — xterm "modifyOtherKeys" (`CSI > 4 ; 2 m`) or the + kitty keyboard protocol — makes the terminal encode `Ctrl+\` as an escape sequence + (`CSI 27 ; 5 ; 92 ~`) instead of `0x1C`, and `zmx attach` replays that mode to the + client on attach — so zmx never sees its detach byte and `Ctrl+\` silently does + nothing. This bit the head-chef entry (Claude Code enables modifyOtherKeys) while + idle line cooks detached fine. `run_attach_restoring_detach` in `backend/real.rs` + mitigates it by disabling those modes right after zmx's replay so `Ctrl+\` reaches + zmx raw again (tradeoff: the app loses enhanced key disambiguation while focused). + The clean fix belongs in zmx — its detach handler should also recognize the encoded + `Ctrl+\`, or not replay input-only keyboard modes to the client. ## Home directory diff --git a/src/backend/real.rs b/src/backend/real.rs index 67f764b..4221d59 100644 --- a/src/backend/real.rs +++ b/src/backend/real.rs @@ -432,16 +432,11 @@ impl ZmxBackend for RealZmxBackend { // feature's demo from inside a yeschef-managed session. Clearing it // (and the analogous prefix var) makes the explicit target always // win, regardless of the environment `attach` is invoked from. - let status = Command::new("zmx") - .env_remove("ZMX_SESSION") + let mut cmd = Command::new("zmx"); + cmd.env_remove("ZMX_SESSION") .env_remove("ZMX_SESSION_PREFIX") - .args(["attach", &id]) - .status() - .context("failed to run 'zmx attach'")?; - if !status.success() { - bail!("zmx attach failed for '{id}'"); - } - Ok(()) + .args(["attach", &id]); + run_attach_restoring_detach(cmd).with_context(|| format!("zmx attach failed for '{id}'")) } // ---- Bare-session (raw id) operations -------------------------------- @@ -493,19 +488,66 @@ impl ZmxBackend for RealZmxBackend { // The same `ZMX_SESSION`/`ZMX_SESSION_PREFIX` clearing as `attach`: a // caller inside its own zmx session would otherwise have `zmx attach` // ignore `id` and reattach to the caller's own session instead. - let status = Command::new("zmx") - .env_remove("ZMX_SESSION") + let mut cmd = Command::new("zmx"); + cmd.env_remove("ZMX_SESSION") .env_remove("ZMX_SESSION_PREFIX") - .args(["attach", id]) - .status() - .context("failed to run 'zmx attach' for a bare session")?; - if !status.success() { - bail!("zmx attach failed for bare session '{id}'"); - } - Ok(()) + .args(["attach", id]); + run_attach_restoring_detach(cmd) + .with_context(|| format!("zmx attach failed for bare session '{id}'")) } } +/// Run `zmx attach` (spawned from the given prepared `Command`) and, once it has +/// taken over the terminal, restore zmx's `Ctrl+\` detach key. +/// +/// # Why this exists +/// +/// zmx detaches when it reads the raw byte `0x1C` (`Ctrl+\`) from the terminal. +/// But a session running a full-screen app that enables an *enhanced keyboard +/// protocol* — xterm "modifyOtherKeys" (`CSI > 4 ; 2 m`) or the kitty keyboard +/// protocol — makes the terminal encode `Ctrl+\` as an escape sequence +/// (`CSI 27 ; 5 ; 92 ~`) instead of the raw `0x1C` byte. `zmx attach` faithfully +/// replays the session's saved terminal modes to our terminal on attach, so the +/// mode is active the moment we attach, and zmx never sees its detach byte — +/// `Ctrl+\` silently does nothing and the user is stuck. This bites when +/// focusing the head chef's Claude Code session (Claude Code enables +/// modifyOtherKeys), while an idle line cook detaches fine. +/// +/// # What we do +/// +/// After zmx has replayed the session state, we write the sequences that turn +/// those enhanced keyboard modes back off, so `Ctrl+\` reaches zmx as raw +/// `0x1C` again. The short sleep lets zmx's replay land first (otherwise we'd +/// disable the mode before the replay re-enables it). +/// +/// # Tradeoff / proper fix +/// +/// While focused, the app loses enhanced key disambiguation (e.g. it can't tell +/// `Shift+Enter` from `Enter`) until you leave and re-attach — an acceptable +/// price for being able to detach at all. The clean fix belongs in zmx itself: +/// its detach handler should also recognize the encoded `Ctrl+\` (or not replay +/// input-only keyboard modes to the client). This is a yeschef-side mitigation. +fn run_attach_restoring_detach(mut cmd: Command) -> Result<()> { + use std::io::Write; + + let mut child = cmd.spawn().context("failed to run 'zmx attach'")?; + + // Let zmx finish replaying the session's saved terminal state (which + // re-enables the enhanced keyboard mode) before we turn it back off. + std::thread::sleep(std::time::Duration::from_millis(200)); + // `CSI > 4 ; 0 m` disables xterm modifyOtherKeys; `CSI < u` pops any kitty + // keyboard flags. Both are no-ops if the mode was never enabled. + let mut out = std::io::stdout(); + let _ = out.write_all(b"\x1b[>4;0m\x1b[ String {