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:
co-authored by
Claude Sonnet 5
parent
151438094e
commit
331039df11
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user