/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help * overlay. One registry drives both the rendering and what we claim is bound, * so a binding added to `main.ts` without an entry here reads as missing. */ export interface Shortcut { /** Keycaps, rendered left to right as separate . */ keys: string[]; description: string; } export interface ShortcutGroup { title: string; items: Shortcut[]; } export const SHORTCUTS: ShortcutGroup[] = [ { title: "Sessions", items: [ { keys: ["n"], description: "Open the spawn form" }, { keys: ["N"], description: "Spawn a session with every default resolved" }, ], }, { title: "Session list", items: [ { keys: ["Ctrl", "Shift", "H"], description: "Move the keyboard between the session list and the pane", }, { keys: ["j"], description: "Highlight the next session" }, { keys: ["k"], description: "Highlight the previous session" }, { keys: ["Enter"], description: "Open the highlighted session, or expand a folder" }, { keys: ["c"], description: "Collapse or expand the highlighted folder" }, { keys: ["r"], description: "Rename the highlighted session" }, { keys: ["x"], description: "Kill the highlighted session" }, { keys: ["Tab"], description: "Send the keyboard to the pane" }, { keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" }, ], }, { title: "Help", items: [ { keys: ["?"], description: "Show or hide this help" }, { keys: ["Esc"], description: "Close the help overlay or an open menu" }, ], }, { title: "Debug", items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }], }, ]; /** True when the event target swallows plain keystrokes — a form field, or the * hidden textarea xterm types into. Shared with `main.ts` so every global * 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 ); } let open: { el: HTMLElement; dispose: () => void } | null = null; export function isHelpOpen(): boolean { return open !== null; } export function closeHelp(): void { open?.dispose(); } export function toggleHelp(): void { if (open) closeHelp(); else openHelp(); } /** Mount the overlay. Opening while already open is a no-op re-render, so a * stray second call can never stack two dialogs. */ export function openHelp(): HTMLElement { closeHelp(); const el = document.createElement("div"); el.className = "help-overlay"; el.setAttribute("role", "dialog"); el.setAttribute("aria-modal", "true"); el.setAttribute("aria-label", "Keyboard shortcuts"); const panel = document.createElement("div"); panel.className = "help-panel"; const head = document.createElement("header"); head.className = "help-head"; const title = document.createElement("h2"); title.className = "help-title"; title.textContent = "Keyboard shortcuts"; const close = document.createElement("button"); close.type = "button"; close.className = "help-close"; close.setAttribute("aria-label", "Close"); close.textContent = "✕"; close.addEventListener("click", closeHelp); head.append(title, close); panel.appendChild(head); for (const group of SHORTCUTS) { const section = document.createElement("section"); section.className = "help-group"; const heading = document.createElement("h3"); heading.className = "help-group-title"; heading.textContent = group.title; const list = document.createElement("dl"); list.className = "help-list"; for (const item of group.items) { const row = document.createElement("div"); row.className = "help-row"; const keys = document.createElement("dt"); keys.className = "help-keys"; for (const key of item.keys) { const cap = document.createElement("kbd"); cap.textContent = key; keys.appendChild(cap); } const desc = document.createElement("dd"); desc.className = "help-desc"; desc.textContent = item.description; row.append(keys, desc); list.appendChild(row); } section.append(heading, list); panel.appendChild(section); } // A press on the scrim closes; one inside the panel is left alone. el.addEventListener("click", (ev) => { if (!panel.contains(ev.target as Node)) closeHelp(); }); el.appendChild(panel); document.body.appendChild(el); const dispose = () => { if (open?.el !== el) return; open = null; el.remove(); }; open = { el, dispose }; close.focus(); return el; } let installed = false; /** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a * second call during a hot reload doesn't double-toggle. */ export function installHelpKeybind(): void { if (installed) return; installed = true; window.addEventListener("keydown", (e) => { if (e.key === "Escape" && open) { e.preventDefault(); closeHelp(); return; } // Shift is how `?` is typed on most layouts; the others would be a // different binding entirely. if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return; if (isTypingTarget(e.target)) return; e.preventDefault(); toggleHelp(); }); }