This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/help.ts
T
Miguel PalhasandClaude Opus 5 b1527f5fc1 fix(maestro): align TUI and web shortcuts
Spawn was `s`/`S` in the TUI and `n`/`N` in the web client; the TUI now
uses `n`/`N` too. The web sidebar gains `c`, `r` and `x` — collapse,
rename, kill — which the TUI already bound.

docs/SIDEBAR.md now carries the shared table, so a new binding has one
place to land on both clients.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 08:25:35 +01:00

177 lines
5.5 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: ["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();
});
}