From f0851a62ed3bb1bc98d4a351eceead7898a17cf9 Mon Sep 17 00:00:00 2001
From: Miguel Palhas
Date: Mon, 31 Aug 2026 14:37:53 +0100
Subject: [PATCH] feat(maestro): keyboard shortcut overlay on web
`?` toggles a dialog listing every web binding, mirroring the TUI's help
overlay. A `?` button in the header covers pointer-only clients, and the
welcome screen points at the key.
One registry (SHORTCUTS) drives the rendering, so a binding added without
an entry reads as missing. main.ts and the overlay now share one
isTypingTarget guard instead of two copies.
---
web/src/catalog.ts | 9 +++
web/src/help.test.ts | 112 ++++++++++++++++++++++++++++++
web/src/help.ts | 159 +++++++++++++++++++++++++++++++++++++++++++
web/src/main.ts | 16 ++---
web/src/styles.css | 110 +++++++++++++++++++++++++++++-
5 files changed, 394 insertions(+), 12 deletions(-)
create mode 100644 web/src/help.test.ts
create mode 100644 web/src/help.ts
diff --git a/web/src/catalog.ts b/web/src/catalog.ts
index febb706..2f1a4f2 100644
--- a/web/src/catalog.ts
+++ b/web/src/catalog.ts
@@ -1,6 +1,7 @@
import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js";
import { openContextMenu } from "./contextMenu.js";
+import { toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import {
@@ -305,6 +306,14 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
+ const helpBtn = document.createElement("button");
+ helpBtn.type = "button";
+ helpBtn.className = "btn btn-ghost btn-icon help-btn";
+ helpBtn.textContent = "?";
+ helpBtn.title = "Keyboard shortcuts (?)";
+ helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
+ helpBtn.addEventListener("click", () => toggleHelp());
+ actions.appendChild(helpBtn);
actions.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
diff --git a/web/src/help.test.ts b/web/src/help.test.ts
new file mode 100644
index 0000000..660e06d
--- /dev/null
+++ b/web/src/help.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ SHORTCUTS,
+ closeHelp,
+ installHelpKeybind,
+ isHelpOpen,
+ isTypingTarget,
+ 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 every registered shortcut, grouped", () => {
+ openHelp();
+ const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
+ expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
+ expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
+ expect(document.querySelectorAll(".help-desc")).toHaveLength(
+ SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
+ );
+ });
+
+ 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);
+ });
+});
+
+describe("isTypingTarget", () => {
+ it("is true for form fields and editable nodes, false otherwise", () => {
+ const el = (html: string) => {
+ const d = document.createElement("div");
+ d.innerHTML = html;
+ return d.firstElementChild as HTMLElement;
+ };
+ expect(isTypingTarget(el(""))).toBe(true);
+ expect(isTypingTarget(el(""))).toBe(true);
+ expect(isTypingTarget(el(""))).toBe(true);
+ expect(isTypingTarget(el(''))).toBe(true);
+ expect(isTypingTarget(el(""))).toBe(false);
+ expect(isTypingTarget(null)).toBe(false);
+ });
+});
diff --git a/web/src/help.ts b/web/src/help.ts
new file mode 100644
index 0000000..2437aec
--- /dev/null
+++ b/web/src/help.ts
@@ -0,0 +1,159 @@
+/** 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 a spawn draft pane" },
+ { keys: ["N"], description: "Spawn a session with every default resolved" },
+ ],
+ },
+ {
+ 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();
+ });
+}
diff --git a/web/src/main.ts b/web/src/main.ts
index 11130bd..857c96f 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -1,6 +1,7 @@
import { renderSidebar, renderTopbar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installHudKeybind } from "./debug.js";
+import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import {
@@ -28,6 +29,7 @@ import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
+installHelpKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
@@ -219,6 +221,7 @@ function renderWelcome(): HTMLElement {
Pick a session from the list, or start a new one. Open sessions keep
streaming in the background — switching back is instant.
+ Press ? for keyboard shortcuts.
`;
const spawn = document.createElement("button");
spawn.type = "button";
@@ -327,16 +330,9 @@ async function requestFastForward(): Promise {
// textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
- const target = e.target as HTMLElement | null;
- if (
- target &&
- (target.tagName === "INPUT" ||
- target.tagName === "TEXTAREA" ||
- target.tagName === "SELECT" ||
- target.isContentEditable)
- ) {
- return;
- }
+ if (isTypingTarget(e.target)) return;
+ // The help overlay owns the keyboard while it is up.
+ if (isHelpOpen()) return;
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
diff --git a/web/src/styles.css b/web/src/styles.css
index 67fabfb..ddb2e04 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -876,6 +876,113 @@ input.leaf-rename:focus {
background: var(--danger-tint);
}
+/* ---------- help overlay (`?`) ---------- */
+.help-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-modal);
+ display: grid;
+ place-items: center;
+ padding: var(--sp-4);
+ background: oklch(0 0 0 / 0.5);
+ backdrop-filter: blur(4px);
+}
+
+.help-panel {
+ width: min(440px, 100%);
+ max-height: min(80vh, 640px);
+ overflow-y: auto;
+ padding: var(--sp-4);
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r-3);
+ box-shadow: var(--shadow-2);
+}
+
+.help-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--sp-2);
+ margin-bottom: var(--sp-3);
+}
+
+.help-title {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: var(--fs-15);
+ color: var(--ink);
+}
+
+.help-close {
+ appearance: none;
+ background: none;
+ border: 0;
+ border-radius: var(--r-2);
+ color: var(--muted);
+ font: inherit;
+ line-height: 1;
+ padding: var(--sp-1) var(--sp-2);
+ cursor: pointer;
+}
+
+.help-close:hover,
+.help-close:focus-visible {
+ background: var(--surface-3);
+ color: var(--ink);
+ outline: none;
+}
+
+.help-group + .help-group {
+ margin-top: var(--sp-4);
+}
+
+.help-group-title {
+ margin: 0 0 var(--sp-2);
+ font-size: var(--fs-11);
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+
+.help-list {
+ margin: 0;
+}
+
+/* Keycaps in a fixed column so the descriptions line up down the list. */
+.help-row {
+ display: grid;
+ grid-template-columns: 14ch 1fr;
+ align-items: baseline;
+ gap: var(--sp-3);
+ padding: var(--sp-1) 0;
+}
+
+.help-keys {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: var(--sp-1);
+ margin: 0;
+}
+
+.help-keys kbd {
+ font-family: var(--font-mono);
+ font-size: var(--fs-11);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-bottom-width: 2px;
+ border-radius: var(--r-1);
+ padding: 1px 6px;
+ color: var(--ink);
+}
+
+.help-desc {
+ margin: 0;
+ font-size: var(--fs-13);
+ color: var(--muted);
+}
+
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
state word itself is never spelled out here; the colour carries it. */
.card-line2 {
@@ -2106,7 +2213,7 @@ input.leaf-rename:focus {
.mv-canvas > .mv-split,
.mv-canvas > .mv-pane {
position: absolute;
- inset: var(--sp-2);
+ inset: 0;
}
.mv-canvas > .content-welcome {
position: absolute;
@@ -2346,7 +2453,6 @@ input.leaf-rename:focus {
.mv-mobile {
position: absolute;
- /* Flush to the edges — a single full-screen pane needs no canvas inset. */
inset: 0;
display: flex;
flex-direction: column;