Tab now moves the keyboard from the session list to the pane (was Escape); Escape moves it back from the pane to the list, intercepted via xterm's attachCustomKeyEventHandler so it never reaches the shell/app. Matches the TUI's `tab`/`esc` pair; Ctrl+Q was ruled out since browsers reserve it and never deliver it to page JS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
5.3 KiB
TypeScript
174 lines
5.3 KiB
TypeScript
/** 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 <kbd>. */
|
|
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: ["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();
|
|
});
|
|
}
|