From 331039df111d501c3f6c398a1257f820e0b52b3a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 18:14:11 +0100 Subject: [PATCH] fix(maestro): leader twice sends one literal leader byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctrl-a is consumed as the leader on every press, so it never reaches the shell and readline's beginning-of-line was gone with no way back — unlike Ctrl-K, which already has an escape hatch. Fix it the way tmux does: pressing the leader twice sends the literal leader byte to the focused pane and clears the pending chord, on both web and TUI, built from the configured leader so a rebound leader carries it. Co-Authored-By: Claude Sonnet 5 --- crates/maestro-tui/src/action.rs | 6 ++++ crates/maestro-tui/src/app.rs | 52 +++++++++++++++++++++++++++ crates/maestro-tui/src/config.rs | 37 +++++++++++++++++++ docs/COMMAND-BAR.md | 11 ++++-- web/src/commands.test.ts | 61 ++++++++++++++++++++++++++++++++ web/src/commands.ts | 16 +++++++++ web/src/main.ts | 7 +++- 7 files changed, 187 insertions(+), 3 deletions(-) diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 25e8e70..32ad7d5 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -60,6 +60,10 @@ pub enum Action { /// byte to the focused pane instead of opening the command bar, giving /// readline's kill-to-end-of-line back. SendLiteralCtrlK, + /// Escape hatch (#158): the leader pressed twice sends one literal + /// leader byte to the focused pane and drops the pending chord, the way + /// tmux resolves a doubled prefix key. + SendLiteralLeader, ToggleSidebar, ShowHelp, Quit, @@ -103,6 +107,7 @@ impl Action { Action::RenameHighlighted => "Rename selected session".into(), Action::OpenCommandBar => "Go to a session".into(), Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(), + Action::SendLiteralLeader => "Send literal leader key to session".into(), Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ShowHelp => "Show help".into(), Action::Quit => "Quit Maestro".into(), @@ -131,6 +136,7 @@ impl Action { "RenameHighlighted" => return Ok(Action::RenameHighlighted), "OpenCommandBar" => return Ok(Action::OpenCommandBar), "SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK), + "SendLiteralLeader" => return Ok(Action::SendLiteralLeader), "ToggleSidebar" => return Ok(Action::ToggleSidebar), "ShowHelp" => return Ok(Action::ShowHelp), "Quit" => return Ok(Action::Quit), diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index f283bd3..22a26ec 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -565,6 +565,12 @@ impl App { Action::SendLiteralCtrlK => { effects.extend(self.forward_to_session(&InputKey::ctrl_char('k'))); } + // Escape hatch (#158): the leader's own byte, built from the + // configured leader rather than a hardcoded key. + Action::SendLiteralLeader => { + let leader = self.cfg.leader.clone(); + effects.extend(self.forward_to_session(&leader)); + } Action::OpenSpawnModal => { self.open_spawn_modal(); effects.push(Effect::LoadDraft); @@ -3068,6 +3074,52 @@ mod tests { assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert"); } + /// Leader pressed twice sends one literal leader byte, tmux-style, and + /// clears the pending chord rather than leaving Insert (#158) — the + /// default leader, `ctrl-a`. + #[tokio::test] + async fn leader_twice_sends_the_literal_leader_byte_and_stays_in_insert() { + let (app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ctrl_key_event('a'), ctrl_key_event('a')], + ) + .await; + assert_eq!(sent, vec![vec![0x01]]); + assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left"); + } + + /// The escape hatch is built from the configured leader, not a hardcoded + /// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#158). + #[tokio::test] + async fn a_rebound_leader_still_reaches_the_literal_leader_escape() { + let section = TuiSection { + leader: Some("ctrl-b".into()), + ..Default::default() + }; + let (app, sent) = + drive_insert_sequence(§ion, &[ctrl_key_event('b'), ctrl_key_event('b')]).await; + assert_eq!(sent, vec![vec![0x02]]); + assert_eq!(app.state.mode, Mode::Insert); + } + + /// After the literal-leader escape fires, the chord is gone — a further + /// leader press starts a fresh chord rather than resolving as a + /// three-deep sequence (#158). + #[tokio::test] + async fn leader_twice_then_ctrl_k_is_a_fresh_chord() { + let (_app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ + ctrl_key_event('a'), + ctrl_key_event('a'), + ctrl_key_event('a'), + ctrl_key_event('k'), + ], + ) + .await; + assert_eq!(sent, vec![vec![0x01], vec![0x0b]]); + } + #[test] fn pane_scrollback_moves_and_user_input_returns_to_live_output() { use crate::session_task::{SessionHandle, SessionState}; diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 3362c23..5df8f50 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -155,6 +155,16 @@ impl TuiConfig { "SendLiteralCtrlK", ) .expect("leader canonical form is valid by construction"); + // Leader, leader → the literal-leader escape hatch (#158), tmux-style: + // pressing it twice sends the leader byte itself. Built from the same + // leader spec, so rebinding leader rebinds this chord too. + keymap + .set( + Mode::Insert, + &format!("{} {}", leader.canonical(), leader.canonical()), + "SendLiteralLeader", + ) + .expect("leader canonical form is valid by construction"); for (input, action) in §ion.keymap_normal { keymap.set(Mode::Normal, input, action).map_err(|source| { @@ -314,6 +324,33 @@ mod tests { assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None); } + /// The literal-leader escape hatch (#158) is chorded off the leader + /// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution. + #[test] + fn default_leader_escapes_a_literal_leader() { + let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), + Some(Action::SendLiteralLeader), + ); + } + + /// Rebinding the leader must rebind the literal-leader chord with it, + /// not just the Insert escape and the Ctrl-K chord — proving it too is + /// built from the configured leader rather than a hardcoded `ctrl-a`. + #[test] + fn rebinding_leader_moves_the_literal_leader_chord() { + let mut section = TuiSection::default(); + section.leader = Some("ctrl-b".into()); + let cfg = TuiConfig::load(§ion).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"), + Some(Action::SendLiteralLeader), + ); + // The old leader's chord is gone along with the rest of its bindings. + assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None); + } + #[test] fn user_normal_override_replaces_default() { let mut section = TuiSection::default(); diff --git a/docs/COMMAND-BAR.md b/docs/COMMAND-BAR.md index bbf1b5e..2934ea5 100644 --- a/docs/COMMAND-BAR.md +++ b/docs/COMMAND-BAR.md @@ -45,8 +45,15 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web `preventDefault`s it, since selecting all of a terminal pane does nothing useful. -Only Ctrl-K gets an escape hatch. No other control character is intercepted, so -none of them needs one. +The leader itself needs the same kind of way back: holding it consumes every +press, on both clients, even before anything is bound behind it. Pressing it +twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the +focused pane and clears the pending chord, the way tmux resolves a doubled +prefix key. This chord is also built from the configured leader, so a +rebound leader carries its own escape hatch with it. + +Only Ctrl-K and the leader itself get an escape hatch. No other control +character is intercepted, so none of them needs one. ### Focus round-trip diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts index 5202ab5..63dc0da 100644 --- a/web/src/commands.test.ts +++ b/web/src/commands.test.ts @@ -6,12 +6,14 @@ import { CommandSet, DEFAULT_LEADER, canonicalizeKey, + controlByte, defineCommands, isBarTypingTarget, isTerminalTextarea, isTypingTarget, keyCaps, publishedCommands, + type Command, } from "./commands.js"; function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent { @@ -307,3 +309,62 @@ describe("ctrl-k over a focused pane (#152)", () => { expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]); }); }); + +describe("leader pressed twice (#158)", () => { + it("sends one literal leader byte and clears the pending chord", () => { + const literal = vi.fn(); + const run = vi.fn(); + const set = new CommandSet( + defineCommands({ "pane.literalLeader": literal, "spawn.open": run }), + { leader: DEFAULT_LEADER }, + ); + const target = el(''); + expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); + expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); + expect(literal).toHaveBeenCalledTimes(1); + // The chord is cleared, not still held: the next key is ordinary again. + expect(set.handle(key("n"))).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("binds the escape hatch behind the default leader", () => { + const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader"); + expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]); + }); + + // Not just the default: the chord is resolved from whatever leader this + // set was constructed with, so a rebound leader carries the escape hatch + // with it — the same proof #156 wrote for the TUI. The web has no config + // for this yet (#159), but the mechanism must not be hardcoded to ctrl-a. + it("follows a rebound leader, not just the default", () => { + const literal = vi.fn(); + const rebound: Command = { + id: "pane.literalLeader", + label: "Send a literal leader key to the pane", + group: "Pane", + scope: "global", + keys: ["ctrl-b ctrl-b"], + run: literal, + }; + const set = new CommandSet([rebound], { leader: "ctrl-b" }); + const target = el(''); + expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true); + expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true); + expect(literal).toHaveBeenCalledTimes(1); + // The old leader no longer does anything special. + expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false); + }); +}); + +describe("controlByte", () => { + it("computes the raw control byte from a plain ctrl chord", () => { + expect(controlByte("ctrl-a")).toBe("\x01"); + expect(controlByte("ctrl-k")).toBe("\x0b"); + }); + + it("rejects anything that isn't a single plain ctrl chord", () => { + expect(() => controlByte("ctrl-shift-a")).toThrow(); + expect(() => controlByte("a")).toThrow(); + expect(() => controlByte("ctrl-a ctrl-a")).toThrow(); + }); +}); diff --git a/web/src/commands.ts b/web/src/commands.ts index 1d8ff61..2c8e12b 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -67,6 +67,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ keys: [`${DEFAULT_LEADER} ctrl-k`], scope: "global", }, + { + id: "pane.literalLeader", + label: "Send a literal leader key to the pane", + group: "Pane", + keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`], + scope: "global", + }, { id: "focus.toggle", label: "Move the keyboard between the session list and the pane", @@ -227,6 +234,15 @@ export function canonicalizeKey(e: KeyboardEvent): string | null { return out + e.key.toLowerCase(); } +/** The raw control byte for a canonical `ctrl-` chord — `ctrl-a` is + * `0x01` — mirroring the TUI's `encode_key`. Built from the chord string + * itself so a different leader sends its own byte, not a hardcoded one. */ +export function controlByte(chord: string): string { + const m = /^ctrl-([a-z])$/.exec(chord); + if (!m) throw new Error(`not a plain ctrl chord: ${chord}`); + return String.fromCharCode(m[1].charCodeAt(0) - 96); +} + const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/; /** True when a chord carries a modifier or more than one key. Plain single-key diff --git a/web/src/main.ts b/web/src/main.ts index 6811186..0ffc989 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,7 @@ import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen } from "./help.js"; -import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js"; +import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js"; import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; @@ -456,6 +456,11 @@ const focusKeys = new CommandSet( // 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the // bar's, so the chord never opens the bar. "pane.literalCtrlK": () => pool.sendToFocused("\x0b"), + // The leader itself, pressed twice, tmux-style: sends one literal leader + // byte and clears the pending chord instead of resolving as a dead chord + // (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a + // hardcoded byte, so a different leader would send its own. + "pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)), }), { leader: DEFAULT_LEADER }, );