Escape unfocusing the pane made it impossible to send a literal Esc into the attached session. Removes pane.focusList and its Escape binding, and rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History shortcut) to leader-h, consistent with the other leader escape hatches. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
COMMAND_SPECS,
|
|
CommandSet,
|
|
commandSpecs,
|
|
DEFAULT_LEADER,
|
|
canonicalizeKey,
|
|
controlByte,
|
|
defineCommands,
|
|
isBarTypingTarget,
|
|
isTerminalTextarea,
|
|
isTypingTarget,
|
|
keyCaps,
|
|
publishedCommands,
|
|
type Command,
|
|
} from "./commands.js";
|
|
|
|
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
|
return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init });
|
|
}
|
|
|
|
function el(html: string): HTMLElement {
|
|
const d = document.createElement("div");
|
|
d.innerHTML = html;
|
|
return d.firstElementChild as HTMLElement;
|
|
}
|
|
|
|
/** Dispatch on a target, so `isTypingTarget` sees something other than null. */
|
|
function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean {
|
|
const e = key(k, init);
|
|
Object.defineProperty(e, "target", { value: target });
|
|
return set.handle(e);
|
|
}
|
|
|
|
describe("command specs", () => {
|
|
it("has a unique id and at least one chord per command", () => {
|
|
const ids = COMMAND_SPECS.map((s) => s.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
for (const spec of COMMAND_SPECS) {
|
|
expect(spec.keys.length).toBeGreaterThan(0);
|
|
expect(spec.label.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("binds each chord once within a scope", () => {
|
|
const seen = new Set<string>();
|
|
for (const spec of COMMAND_SPECS) {
|
|
for (const k of spec.keys) {
|
|
const slot = `${spec.scope}:${k}`;
|
|
expect(seen.has(slot)).toBe(false);
|
|
seen.add(slot);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects a handler for an id that isn't in the registry", () => {
|
|
expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/);
|
|
});
|
|
|
|
it("carries every spec field onto the defined command", () => {
|
|
const [cmd] = defineCommands({ "spawn.open": () => {} });
|
|
expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" });
|
|
});
|
|
});
|
|
|
|
describe("canonicalizeKey", () => {
|
|
it("spells keys the way the TUI's InputKey::canonical does", () => {
|
|
expect(canonicalizeKey(key("n"))).toBe("n");
|
|
expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N");
|
|
expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?");
|
|
expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a");
|
|
expect(canonicalizeKey(key("Escape"))).toBe("esc");
|
|
expect(canonicalizeKey(key("Enter"))).toBe("enter");
|
|
expect(canonicalizeKey(key("Tab"))).toBe("tab");
|
|
expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab");
|
|
expect(canonicalizeKey(key("ArrowDown"))).toBe("down");
|
|
});
|
|
|
|
it("folds a modified letter to lower case with an explicit shift-", () => {
|
|
expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
|
expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
|
});
|
|
|
|
it("has no canonical form for a modifier pressed alone", () => {
|
|
expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull();
|
|
expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("keyCaps", () => {
|
|
it("renders one cap per modifier and key", () => {
|
|
expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]);
|
|
expect(keyCaps("enter")).toEqual(["Enter"]);
|
|
expect(keyCaps("j")).toEqual(["j"]);
|
|
expect(keyCaps("N")).toEqual(["N"]);
|
|
expect(keyCaps("?")).toEqual(["?"]);
|
|
expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]);
|
|
});
|
|
});
|
|
|
|
describe("CommandSet", () => {
|
|
it("runs the command bound to a chord, and swallows the browser default", () => {
|
|
const run = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
|
const e = key("n");
|
|
expect(set.handle(e)).toBe(true);
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
expect(e.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("leaves an unbound key alone", () => {
|
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
|
const e = key("q");
|
|
expect(set.handle(e)).toBe(false);
|
|
expect(e.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("treats a modified key as a different binding", () => {
|
|
const run = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
|
expect(set.handle(key("n", { ctrlKey: true }))).toBe(false);
|
|
expect(set.handle(key("n", { metaKey: true }))).toBe(false);
|
|
expect(run).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runs every alias of a command", () => {
|
|
const run = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "list.next": run }));
|
|
set.handle(key("j"));
|
|
set.handle(key("ArrowDown"));
|
|
expect(run).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
|
|
const plain = vi.fn();
|
|
const chorded = vi.fn();
|
|
const set = new CommandSet(
|
|
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
|
|
{ leader: DEFAULT_LEADER },
|
|
);
|
|
const input = el("<input>");
|
|
expect(pressOn(set, input, "n")).toBe(false);
|
|
expect(plain).not.toHaveBeenCalled();
|
|
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
|
|
// left for the pane's shell/app instead.
|
|
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
|
|
pressOn(set, terminal, "a", { ctrlKey: true });
|
|
expect(pressOn(set, terminal, "h")).toBe(true);
|
|
expect(chorded).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves a plain Escape for the pane instead of binding it", () => {
|
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
|
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
|
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the leader", () => {
|
|
it("defaults to the TUI's ctrl-a", () => {
|
|
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
|
});
|
|
|
|
// #159: the leader is the operator's, served on `/api/info`. Every chord
|
|
// behind it is built from it, so a rebound leader carries its escape hatches
|
|
// with it instead of leaving them on a stale ctrl-a.
|
|
it("builds the chords behind it from the configured leader", () => {
|
|
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
|
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
|
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
|
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
|
|
// Commands that do not hang off the leader are untouched.
|
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
|
});
|
|
|
|
it("dispatches a configured leader's chord and not the default's", () => {
|
|
const literalCtrlK = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
|
|
leader: "ctrl-b",
|
|
});
|
|
set.handle(key("b", { ctrlKey: true }));
|
|
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
|
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
|
|
|
// The old leader is an ordinary key again: it holds nothing, and the chord
|
|
// that used to follow it runs nothing.
|
|
set.handle(key("a", { ctrlKey: true }));
|
|
set.handle(key("k", { ctrlKey: true }));
|
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("sends the configured leader's own byte for the doubled chord", () => {
|
|
expect(controlByte("ctrl-b")).toBe("\x02");
|
|
});
|
|
|
|
it("holds the leader as a partial chord instead of selecting the page", () => {
|
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
|
leader: DEFAULT_LEADER,
|
|
});
|
|
const e = key("a", { ctrlKey: true });
|
|
expect(set.handle(e)).toBe(true);
|
|
expect(e.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("swallows the key after the leader rather than running its plain binding", () => {
|
|
const run = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
|
leader: DEFAULT_LEADER,
|
|
});
|
|
set.handle(key("a", { ctrlKey: true }));
|
|
expect(set.handle(key("n"))).toBe(true);
|
|
expect(run).not.toHaveBeenCalled();
|
|
// The next key is an ordinary one again.
|
|
set.handle(key("n"));
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("forgets a held leader once the chord timeout passes", () => {
|
|
const run = vi.fn();
|
|
let now = 0;
|
|
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
|
leader: DEFAULT_LEADER,
|
|
chordTimeoutMs: 1000,
|
|
now: () => now,
|
|
});
|
|
set.handle(key("a", { ctrlKey: true }));
|
|
now = 1500;
|
|
set.handle(key("n"));
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves select-all alone in a real form field", () => {
|
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
|
leader: DEFAULT_LEADER,
|
|
});
|
|
const input = el("<input>");
|
|
const e = key("a", { ctrlKey: true });
|
|
Object.defineProperty(e, "target", { value: input });
|
|
expect(set.handle(e)).toBe(false);
|
|
expect(e.defaultPrevented).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("publishing", () => {
|
|
it("lists a constructed set's commands, replacing an id rather than doubling it", () => {
|
|
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
|
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
|
const ids = publishedCommands().map((c) => c.id);
|
|
expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]);
|
|
});
|
|
|
|
it("keeps published commands in registry order", () => {
|
|
const order = COMMAND_SPECS.map((s) => s.id);
|
|
const ids = publishedCommands().map((c) => c.id);
|
|
expect(ids).toEqual(order.filter((id) => ids.includes(id)));
|
|
});
|
|
});
|
|
|
|
describe("isTypingTarget", () => {
|
|
it("is true for form fields and editable nodes, false otherwise", () => {
|
|
expect(isTypingTarget(el("<input>"))).toBe(true);
|
|
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
|
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
|
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
|
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
|
expect(isTypingTarget(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isTerminalTextarea", () => {
|
|
it("recognises xterm's offscreen textarea, and nothing else", () => {
|
|
expect(isTerminalTextarea(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(true);
|
|
expect(isTerminalTextarea(el("<textarea></textarea>"))).toBe(false);
|
|
expect(isTerminalTextarea(el("<input>"))).toBe(false);
|
|
expect(isTerminalTextarea(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isBarTypingTarget", () => {
|
|
it("holds the bar's keys out of real form fields", () => {
|
|
expect(isBarTypingTarget(el("<input>"))).toBe(true);
|
|
expect(isBarTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
|
});
|
|
|
|
it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => {
|
|
expect(isBarTypingTarget(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(false);
|
|
expect(isBarTypingTarget(el("<button></button>"))).toBe(false);
|
|
expect(isBarTypingTarget(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("ctrl-k over a focused pane (#152)", () => {
|
|
const terminal = () => el('<textarea class="xterm-helper-textarea"></textarea>');
|
|
|
|
/** The global listener's routing: the bar's set only sees a key that is not
|
|
* aimed at a real form field. */
|
|
function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) {
|
|
if (isBarTypingTarget(target)) return false;
|
|
return pressOn(set, target, k, init);
|
|
}
|
|
|
|
it("opens the bar when the terminal has the keyboard", () => {
|
|
const open = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
|
expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true);
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves the spawn form's inputs their own Ctrl-K", () => {
|
|
const open = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
|
expect(route(set, el("<input>"), "k", { ctrlKey: true })).toBe(false);
|
|
expect(open).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends the literal 0x0B after the leader, without opening the bar", () => {
|
|
const open = vi.fn();
|
|
const literal = vi.fn();
|
|
const bar = new CommandSet(defineCommands({ "bar.open": open }));
|
|
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), {
|
|
leader: DEFAULT_LEADER,
|
|
});
|
|
const target = terminal();
|
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
|
const e = key("k", { ctrlKey: true });
|
|
Object.defineProperty(e, "target", { value: target });
|
|
expect(set.handle(e)).toBe(true);
|
|
expect(literal).toHaveBeenCalledTimes(1);
|
|
// The leader's set consumed the key, so the bar's set never sees it.
|
|
expect(open).not.toHaveBeenCalled();
|
|
expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("binds the escape hatch behind the default leader", () => {
|
|
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK");
|
|
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();
|
|
});
|
|
});
|