The global listener already ran in the capture phase, but deferred to isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats that textarea as not a typing target, while real form fields — the spawn form's inputs — keep their own Ctrl-K. Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused pane through the leader chord the command set already resolves, so readline's kill-to-end-of-line stays reachable. The pool grows a sendToFocused for it, over a new AttachConnection.sendInput. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
COMMAND_SPECS,
|
|
CommandSet,
|
|
DEFAULT_LEADER,
|
|
canonicalizeKey,
|
|
defineCommands,
|
|
isBarTypingTarget,
|
|
isTerminalTextarea,
|
|
isTypingTarget,
|
|
keyCaps,
|
|
publishedCommands,
|
|
} 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 chorded ones live", () => {
|
|
const plain = vi.fn();
|
|
const chorded = vi.fn();
|
|
const set = new CommandSet(
|
|
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
|
);
|
|
const input = el("<input>");
|
|
expect(pressOn(set, input, "n")).toBe(false);
|
|
expect(plain).not.toHaveBeenCalled();
|
|
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
|
expect(chorded).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps pane commands live inside the terminal's textarea", () => {
|
|
const run = vi.fn();
|
|
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
|
preventDefault: false,
|
|
});
|
|
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
|
const e = key("Escape");
|
|
Object.defineProperty(e, "target", { value: textarea });
|
|
expect(set.handle(e)).toBe(true);
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
// xterm is told to swallow by the return value, not by the browser default.
|
|
expect(e.defaultPrevented).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the leader", () => {
|
|
it("defaults to the TUI's ctrl-a", () => {
|
|
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
|
});
|
|
|
|
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`]);
|
|
});
|
|
});
|