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>
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
// The web client's command registry (#148). One entry per command — a stable
|
|
// id, a plain-language label, the key chord it answers to, and a `run()` — so
|
|
// there is exactly one list of what the client can do. The `?` overlay renders
|
|
// from it (`help.ts`), and every keybinding is dispatched through it; a command
|
|
// added here is bound and documented in the same edit.
|
|
//
|
|
// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical`
|
|
// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those
|
|
// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets
|
|
// docs/COMMAND-BAR.md describe one key model for both clients.
|
|
|
|
/** Where a command's keys are listened for. `global` is the window, `list` the
|
|
* sidebar's session tree, `pane` the attached terminal, `overlay` the help
|
|
* dialog while it is up. */
|
|
export type CommandScope = "global" | "list" | "pane" | "overlay";
|
|
|
|
export interface CommandSpec {
|
|
/** Stable identifier. Never rendered; safe to bind config and tests to. */
|
|
id: string;
|
|
/** Plain-language label — what the `?` overlay and (later) the command bar
|
|
* show. */
|
|
label: string;
|
|
/** Help-overlay section. Groups render in the order they first appear here. */
|
|
group: string;
|
|
/** Canonical chords that run it. The first is the one shown to the operator;
|
|
* the rest are aliases (`j` and `down`). */
|
|
keys: string[];
|
|
scope: CommandScope;
|
|
}
|
|
|
|
export interface Command extends CommandSpec {
|
|
run: () => void;
|
|
}
|
|
|
|
/** Leader used when the daemon does not name one — an older daemon, or an
|
|
* unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
|
|
* is select-all in a browser, so a set that owns the leader
|
|
* `preventDefault`s it. */
|
|
export const DEFAULT_LEADER = "ctrl-a";
|
|
|
|
/** Every command the web client has, for one leader. The single list: labels,
|
|
* chords and grouping live here and nowhere else. Chords behind the leader are
|
|
* built from it (#159), so the configured leader — served on `/api/info` as
|
|
* `leader` — is what the overlay renders and the sets dispatch. */
|
|
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
|
|
return SPECS.map(({ leaderKeys, ...spec }) =>
|
|
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
|
|
);
|
|
}
|
|
|
|
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
|
|
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
|
|
|
|
const SPECS: readonly SpecSource[] = [
|
|
{
|
|
id: "spawn.open",
|
|
label: "Open the spawn form",
|
|
group: "Sessions",
|
|
keys: ["n"],
|
|
scope: "global",
|
|
},
|
|
{
|
|
id: "spawn.fastForward",
|
|
label: "Spawn a session with every default resolved",
|
|
group: "Sessions",
|
|
keys: ["N"],
|
|
scope: "global",
|
|
},
|
|
{
|
|
id: "bar.open",
|
|
label: "Open the command bar",
|
|
group: "Sessions",
|
|
keys: ["ctrl-k"],
|
|
scope: "global",
|
|
},
|
|
{
|
|
id: "pane.literalCtrlK",
|
|
label: "Send a literal Ctrl-K to the pane",
|
|
group: "Pane",
|
|
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
|
leaderKeys: (leader) => [`${leader} ctrl-k`],
|
|
scope: "global",
|
|
},
|
|
{
|
|
id: "pane.literalLeader",
|
|
label: "Send a literal leader key to the pane",
|
|
group: "Pane",
|
|
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
|
leaderKeys: (leader) => [`${leader} ${leader}`],
|
|
scope: "global",
|
|
},
|
|
{
|
|
id: "focus.toggle",
|
|
label: "Move the keyboard between the session list and the pane",
|
|
group: "Session list",
|
|
keys: [`${DEFAULT_LEADER} h`],
|
|
leaderKeys: (leader) => [`${leader} h`],
|
|
scope: "global",
|
|
},
|
|
{
|
|
id: "list.next",
|
|
label: "Highlight the next session",
|
|
group: "Session list",
|
|
keys: ["j", "down"],
|
|
scope: "list",
|
|
},
|
|
{
|
|
id: "list.prev",
|
|
label: "Highlight the previous session",
|
|
group: "Session list",
|
|
keys: ["k", "up"],
|
|
scope: "list",
|
|
},
|
|
{
|
|
id: "list.activate",
|
|
label: "Open the highlighted session, or expand a folder",
|
|
group: "Session list",
|
|
keys: ["enter"],
|
|
scope: "list",
|
|
},
|
|
{
|
|
id: "list.toggleFolder",
|
|
label: "Collapse or expand the highlighted folder",
|
|
group: "Session list",
|
|
keys: ["c"],
|
|
scope: "list",
|
|
},
|
|
{
|
|
id: "list.rename",
|
|
label: "Rename the highlighted session",
|
|
group: "Session list",
|
|
keys: ["r"],
|
|
scope: "list",
|
|
},
|
|
{
|
|
id: "list.kill",
|
|
label: "Kill the highlighted session",
|
|
group: "Session list",
|
|
keys: ["x"],
|
|
scope: "list",
|
|
},
|
|
{
|
|
id: "list.focusPane",
|
|
label: "Send the keyboard to the pane",
|
|
group: "Session list",
|
|
keys: ["tab"],
|
|
scope: "list",
|
|
},
|
|
{
|
|
id: "help.toggle",
|
|
label: "Show or hide this help",
|
|
group: "Help",
|
|
keys: ["?"],
|
|
scope: "global",
|
|
},
|
|
{
|
|
id: "help.close",
|
|
label: "Close the help overlay or an open menu",
|
|
group: "Help",
|
|
keys: ["esc"],
|
|
scope: "overlay",
|
|
},
|
|
{
|
|
id: "debug.toggleHud",
|
|
label: "Toggle the debug HUD",
|
|
group: "Debug",
|
|
keys: ["ctrl-shift-d"],
|
|
scope: "global",
|
|
},
|
|
];
|
|
|
|
/** The command list under the default leader. Iteration order everywhere else
|
|
* (the help overlay, the bar) comes from here. */
|
|
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
|
|
|
|
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
|
* test failure rather than a key that silently does nothing. `leader` picks
|
|
* the chords behind the leader; omit it for a set that has none. */
|
|
export function defineCommands(
|
|
handlers: Record<string, () => void>,
|
|
leader: string = DEFAULT_LEADER,
|
|
): Command[] {
|
|
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
|
|
return Object.entries(handlers).map(([id, run]) => {
|
|
const spec = byId.get(id);
|
|
if (!spec) throw new Error(`unknown command id: ${id}`);
|
|
return { ...spec, run };
|
|
});
|
|
}
|
|
|
|
// ---- what the help overlay renders ----
|
|
|
|
const published = new Map<string, Command>();
|
|
|
|
/** Record commands as bound. Re-registering an id replaces it, so a second
|
|
* sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */
|
|
export function publishCommands(cmds: Command[]): void {
|
|
for (const c of cmds) published.set(c.id, c);
|
|
}
|
|
|
|
/** Every bound command, in `COMMAND_SPECS` order. */
|
|
export function publishedCommands(): Command[] {
|
|
return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined);
|
|
}
|
|
|
|
// ---- key canonicalisation ----
|
|
|
|
const NAMED: Record<string, string> = {
|
|
Escape: "esc",
|
|
Enter: "enter",
|
|
Tab: "tab",
|
|
Backspace: "backspace",
|
|
ArrowUp: "up",
|
|
ArrowDown: "down",
|
|
ArrowLeft: "left",
|
|
ArrowRight: "right",
|
|
Home: "home",
|
|
End: "end",
|
|
PageUp: "pageup",
|
|
PageDown: "pagedown",
|
|
Delete: "delete",
|
|
" ": "space",
|
|
};
|
|
|
|
/** The canonical string for one keydown. Mirrors the TUI's
|
|
* `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the
|
|
* key. A shifted character already spells itself (`N`, `?`), so `shift-` is
|
|
* only written when another modifier is present and the char was folded to
|
|
* lower case. */
|
|
export function canonicalizeKey(e: KeyboardEvent): string | null {
|
|
if (!e.key) return null;
|
|
// Modifier keys pressed alone are never a binding.
|
|
if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null;
|
|
const named = NAMED[e.key];
|
|
if (named === "tab" && e.shiftKey) return "backtab";
|
|
let out = "";
|
|
if (e.ctrlKey) out += "ctrl-";
|
|
if (e.altKey) out += "alt-";
|
|
if (e.metaKey) out += "meta-";
|
|
if (named) {
|
|
if (e.shiftKey) out += "shift-";
|
|
return out + named;
|
|
}
|
|
if (e.key.length === 1) {
|
|
if (out === "") return e.key;
|
|
if (e.shiftKey) out += "shift-";
|
|
return out + e.key.toLowerCase();
|
|
}
|
|
if (e.shiftKey) out += "shift-";
|
|
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
|
|
* chords are the ones that stay deaf inside form fields. */
|
|
function isChorded(chord: string): boolean {
|
|
return chord.includes(" ") || MOD_PREFIX.test(chord);
|
|
}
|
|
|
|
/** True when the event target swallows plain keystrokes — a form field, or the
|
|
* hidden textarea xterm types into. Every plain single-key binding is deaf in
|
|
* the same places. */
|
|
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
const el = target as HTMLElement | null;
|
|
if (!el || typeof el.tagName !== "string") return false;
|
|
return (
|
|
el.tagName === "INPUT" ||
|
|
el.tagName === "TEXTAREA" ||
|
|
el.tagName === "SELECT" ||
|
|
el.isContentEditable
|
|
);
|
|
}
|
|
|
|
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
|
* also where the leader has to work — a pane is exactly where the operator
|
|
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
|
export function isTerminalTextarea(target: EventTarget | null): boolean {
|
|
const el = target as HTMLElement | null;
|
|
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
|
}
|
|
|
|
/** True where the command bar's own keys must stay out of the way: a real form
|
|
* field, and not the pane. `ctrl-k` is intercepted ahead of xterm
|
|
* (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while
|
|
* the spawn form's inputs keep their own Ctrl-K. */
|
|
export function isBarTypingTarget(target: EventTarget | null): boolean {
|
|
return isTypingTarget(target) && !isTerminalTextarea(target);
|
|
}
|
|
|
|
// ---- display ----
|
|
|
|
const CAPS: Record<string, string> = {
|
|
ctrl: "Ctrl",
|
|
alt: "Alt",
|
|
meta: "Meta",
|
|
shift: "Shift",
|
|
esc: "Esc",
|
|
enter: "Enter",
|
|
tab: "Tab",
|
|
backtab: "Shift Tab",
|
|
backspace: "Backspace",
|
|
up: "Up",
|
|
down: "Down",
|
|
left: "Left",
|
|
right: "Right",
|
|
home: "Home",
|
|
end: "End",
|
|
pageup: "PgUp",
|
|
pagedown: "PgDn",
|
|
delete: "Del",
|
|
space: "Space",
|
|
};
|
|
|
|
/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A
|
|
* multi-key chord separates its keys with a `then` cap. */
|
|
export function keyCaps(chord: string): string[] {
|
|
const caps: string[] = [];
|
|
for (const [i, key] of chord.split(" ").entries()) {
|
|
if (i > 0) caps.push("then");
|
|
const parts = key.split("-");
|
|
// A bare `-` key splits into two empties; treat it as the key itself.
|
|
if (parts.every((p) => p === "")) {
|
|
caps.push("-");
|
|
continue;
|
|
}
|
|
const last = parts.pop() as string;
|
|
const mods = parts.filter((p) => p !== "");
|
|
for (const m of mods) caps.push(CAPS[m] ?? m);
|
|
caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last));
|
|
}
|
|
return caps;
|
|
}
|
|
|
|
// ---- dispatch ----
|
|
|
|
/** How long a partial chord waits for its next key — the TUI's
|
|
* `chord_timeout_ms` default. */
|
|
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
|
|
|
export interface CommandSetOptions {
|
|
/** Canonical leader key. A set given one holds every leader press as a
|
|
* pending chord, whether or not anything is bound behind it yet. */
|
|
leader?: string | null;
|
|
chordTimeoutMs?: number;
|
|
/** Call `preventDefault()` on a handled key. Off for the terminal's own key
|
|
* handler, which swallows by returning `false` to xterm instead. */
|
|
preventDefault?: boolean;
|
|
/** Clock, for tests. */
|
|
now?: () => number;
|
|
}
|
|
|
|
/** A group of commands listening on one surface. Resolves a keydown to a
|
|
* command the way the TUI's `ChordState` does: hold while the sequence is a
|
|
* strict prefix of some binding, otherwise resolve and dispatch. */
|
|
export class CommandSet {
|
|
private readonly byChord = new Map<string, Command>();
|
|
private readonly leader: string | null;
|
|
private readonly timeout: number;
|
|
private readonly preventDefault: boolean;
|
|
private readonly now: () => number;
|
|
private held: string[] = [];
|
|
private lastPress = 0;
|
|
|
|
constructor(
|
|
readonly commands: Command[],
|
|
opts: CommandSetOptions = {},
|
|
) {
|
|
for (const c of commands) for (const k of c.keys) this.byChord.set(k, c);
|
|
this.leader = opts.leader ?? null;
|
|
this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS;
|
|
this.preventDefault = opts.preventDefault ?? true;
|
|
this.now = opts.now ?? (() => Date.now());
|
|
publishCommands(commands);
|
|
}
|
|
|
|
/** Whether any binding starts with `candidate` and continues. */
|
|
private isPrefix(candidate: string): boolean {
|
|
const marker = `${candidate} `;
|
|
for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Feed one keydown. Returns true when the key was consumed — dispatched, or
|
|
* held as a partial chord. */
|
|
handle(e: KeyboardEvent): boolean {
|
|
const key = canonicalizeKey(e);
|
|
if (key === null) return false;
|
|
if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = [];
|
|
const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key;
|
|
this.lastPress = this.now();
|
|
|
|
// The leader is a prefix by construction, so it stops being select-all
|
|
// before anything is bound behind it (#149 binds the first chord).
|
|
if (this.leader !== null && this.held.length === 0 && key === this.leader) {
|
|
if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false;
|
|
this.held = [key];
|
|
if (this.preventDefault) e.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
if (this.isPrefix(candidate)) {
|
|
this.held.push(key);
|
|
if (this.preventDefault) e.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
const wasChord = this.held.length > 0;
|
|
this.held = [];
|
|
const cmd = this.byChord.get(candidate);
|
|
if (!cmd) {
|
|
// A chord that resolved to nothing is still consumed: the leader took
|
|
// the key, so it must not also fall through to a plain binding.
|
|
return wasChord;
|
|
}
|
|
// Plain single-key bindings are deaf where typing happens; chorded ones are
|
|
// not, and neither are the pane's and the overlay's, which are only fed
|
|
// from the surface that owns them.
|
|
if (
|
|
!isChorded(candidate) &&
|
|
cmd.scope !== "pane" &&
|
|
cmd.scope !== "overlay" &&
|
|
isTypingTarget(e.target)
|
|
) {
|
|
return false;
|
|
}
|
|
if (this.preventDefault) e.preventDefault();
|
|
cmd.run();
|
|
return true;
|
|
}
|
|
}
|