The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`, a module constant. Rebinding one left the other on `ctrl-a`, so the two escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`) moved on one client and not the other. `GET /api/info` now carries `leader`. The web binds that when it arrives and rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command bar render the configured chord; an absent or empty field — an older daemon, or an unreachable `/api/info` — leaves it on `ctrl-a`. Chose the existing `[tui].leader` served as-is over a new shared key: one knob, no precedence rules between two spellings, and no migration for operators who already set it. The name is a wart — it is the leader for every client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md` say so.
128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
|
|
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
|
|
|
beforeEach(() => {
|
|
installHelpKeybind();
|
|
});
|
|
|
|
afterEach(() => {
|
|
closeHelp();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
function press(key: string, init: KeyboardEventInit = {}): void {
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
|
|
}
|
|
|
|
function rows(): string[] {
|
|
return [...document.querySelectorAll(".help-row")].map((r) =>
|
|
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
|
|
);
|
|
}
|
|
|
|
describe("help overlay", () => {
|
|
it("renders exactly the commands the registry has bound, grouped", () => {
|
|
openHelp();
|
|
const groups = groupedCommands();
|
|
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
|
groups.map((g) => g.title),
|
|
);
|
|
expect(rows()).toEqual(
|
|
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
|
|
);
|
|
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
|
|
groups.flatMap((g) => g.items.map((c) => c.label)),
|
|
);
|
|
});
|
|
|
|
it("names each row by its command id, so a row can't drift from its command", () => {
|
|
openHelp();
|
|
expect([...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId))
|
|
.toEqual(publishedCommands().map((c) => c.id));
|
|
});
|
|
|
|
// #159: the leader comes from the daemon, so the overlay must render the
|
|
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
|
|
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
|
|
const bound = defineCommands(
|
|
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
|
|
"ctrl-b",
|
|
);
|
|
publishCommands(bound);
|
|
try {
|
|
openHelp();
|
|
const keysOf = (id: string) =>
|
|
[
|
|
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
|
|
.querySelectorAll("kbd"),
|
|
]
|
|
.map((k) => k.textContent)
|
|
.join(" ");
|
|
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
|
|
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
|
|
} finally {
|
|
// Leave the registry as the rest of the suite expects it.
|
|
publishCommands(
|
|
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("opens on ? and closes on a second ?", () => {
|
|
press("?", { shiftKey: true });
|
|
expect(isHelpOpen()).toBe(true);
|
|
press("?", { shiftKey: true });
|
|
expect(isHelpOpen()).toBe(false);
|
|
});
|
|
|
|
it("closes on Escape, on the close button, and on a click outside the panel", () => {
|
|
openHelp();
|
|
press("Escape");
|
|
expect(isHelpOpen()).toBe(false);
|
|
|
|
openHelp();
|
|
(document.querySelector(".help-close") as HTMLButtonElement).click();
|
|
expect(isHelpOpen()).toBe(false);
|
|
|
|
openHelp();
|
|
(document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
|
|
new MouseEvent("click", { bubbles: true }),
|
|
);
|
|
expect(isHelpOpen()).toBe(false);
|
|
});
|
|
|
|
it("stays open for a click inside the panel", () => {
|
|
openHelp();
|
|
(document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
|
|
new MouseEvent("click", { bubbles: true }),
|
|
);
|
|
expect(isHelpOpen()).toBe(true);
|
|
});
|
|
|
|
it("opening twice leaves one overlay", () => {
|
|
openHelp();
|
|
openHelp();
|
|
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
|
});
|
|
|
|
it("ignores ? while typing and when a non-shift modifier is held", () => {
|
|
const input = document.createElement("input");
|
|
document.body.appendChild(input);
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true }));
|
|
expect(isHelpOpen()).toBe(false);
|
|
|
|
press("?", { ctrlKey: true });
|
|
expect(isHelpOpen()).toBe(false);
|
|
});
|
|
|
|
it("installing the keybind twice still toggles once", () => {
|
|
installHelpKeybind();
|
|
press("?", { shiftKey: true });
|
|
expect(isHelpOpen()).toBe(true);
|
|
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
|
});
|
|
});
|