fix(maestro): leader twice sends one literal leader byte

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 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-03 18:14:11 +01:00
co-authored by Claude Sonnet 5
parent 151438094e
commit 331039df11
7 changed files with 187 additions and 3 deletions
+61
View File
@@ -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('<textarea class="xterm-helper-textarea"></textarea>');
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('<textarea class="xterm-helper-textarea"></textarea>');
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();
});
});
+16
View File
@@ -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-<letter>` 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
+6 -1
View File
@@ -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 },
);