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 {