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 },
);