From 1e3c7b294dc54d477f5a0372d4c2a47542afebf4 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 13:30:14 +0100 Subject: [PATCH] fix(maestro): send plain Escape to the pane, move focus.toggle behind the leader 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 --- docs/SIDEBAR.md | 7 ++++--- web/src/attach.ts | 20 -------------------- web/src/catalog.ts | 4 ---- web/src/commands.test.ts | 26 ++++++++++++-------------- web/src/commands.ts | 10 ++-------- web/src/main.ts | 18 +++++++----------- 6 files changed, 25 insertions(+), 60 deletions(-) diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index c1af7e3..4c34bbf 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -307,9 +307,10 @@ lands on the first row it was hiding. A highlight parked on a skipped row — reachable by clicking a folder header — still steps to its neighbour rather than jumping to the end of the list. -The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the -same chord back): an attached terminal owns every plain keystroke. The TUI uses -`ctrl-w h`. +The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h` +by default — and the same chord back): an attached terminal owns every plain +keystroke, and Escape is left to reach the pane's shell/app instead. The TUI +uses `ctrl-w h`. Everything else is the same key on both clients: diff --git a/web/src/attach.ts b/web/src/attach.ts index 3886680..02c3e5e 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; -import { FOCUS_SIDEBAR_EVENT } from "./catalog.js"; -import { CommandSet, defineCommands } from "./commands.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; @@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; import { WebglPolicy } from "./webglRenderer.js"; -// The pane's half of the command registry (#148): plain Escape hands the -// keyboard back to the session list, the pair to the list's Tab. -const paneKeys = new CommandSet( - defineCommands({ - "pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)), - }), - { preventDefault: false }, -); - /** One budget for the whole page — see webglRenderer.ts for why contexts are * rationed rather than handed to every connection. */ const webglPolicy = new WebglPolicy(); @@ -221,15 +210,6 @@ export function createAttachConnection( } } - // Plain Escape hands the keyboard back to the session list instead of the - // shell/app. `false` tells xterm to skip its own handling of this event — - // which is also why the set doesn't `preventDefault`: swallowing here is - // xterm's return value, not the browser's. - term.attachCustomKeyEventHandler((e) => { - if (e.type !== "keydown") return true; - return !paneKeys.handle(e); - }); - // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a // line at a time and stall), so map finger drags ourselves. Two routes: // • normal buffer → term.scrollLines walks the scrollback. diff --git a/web/src/catalog.ts b/web/src/catalog.ts index ab8364c..f7e8f0b 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -53,10 +53,6 @@ export function requestOpenCommandBar(): void { * connection pool. */ export const FOCUS_PANE_EVENT = "catalog-focus-pane"; -/** Ask the shell to hand the keyboard back to the session list — what Escape - * does when the pane's terminal is focused. Handled in main.ts. */ -export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar"; - /** Ask the shell to open the spawn modal seeded from an existing session * ("Spawn similar"). Detail is the session being cloned. */ export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts index 93ce959..70b2d2d 100644 --- a/web/src/commands.test.ts +++ b/web/src/commands.test.ts @@ -133,31 +133,28 @@ describe("CommandSet", () => { expect(run).toHaveBeenCalledTimes(2); }); - it("keeps plain keys deaf inside a form field, and chorded ones live", () => { + it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => { const plain = vi.fn(); const chorded = vi.fn(); const set = new CommandSet( - defineCommands({ "spawn.open": plain, "focus.toggle": chorded }), + defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER), + { leader: DEFAULT_LEADER }, ); const input = el(""); expect(pressOn(set, input, "n")).toBe(false); expect(plain).not.toHaveBeenCalled(); - expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true); + // focus.toggle hangs off the leader (#166): a plain, unmodified Escape is + // left for the pane's shell/app instead. + const terminal = el(''); + pressOn(set, terminal, "a", { ctrlKey: true }); + expect(pressOn(set, terminal, "h")).toBe(true); expect(chorded).toHaveBeenCalledTimes(1); }); - it("keeps pane commands live inside the terminal's textarea", () => { - const run = vi.fn(); - const set = new CommandSet(defineCommands({ "pane.focusList": run }), { - preventDefault: false, - }); + it("leaves a plain Escape for the pane instead of binding it", () => { + const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() })); const textarea = el(''); - const e = key("Escape"); - Object.defineProperty(e, "target", { value: textarea }); - expect(set.handle(e)).toBe(true); - expect(run).toHaveBeenCalledTimes(1); - // xterm is told to swallow by the return value, not by the browser default. - expect(e.defaultPrevented).toBe(false); + expect(pressOn(set, textarea, "Escape")).toBe(false); }); }); @@ -173,6 +170,7 @@ describe("the leader", () => { const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s])); expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]); expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]); + expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]); // Commands that do not hang off the leader are untouched. expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]); }); diff --git a/web/src/commands.ts b/web/src/commands.ts index a692072..4eff30d 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [ id: "focus.toggle", label: "Move the keyboard between the session list and the pane", group: "Session list", - keys: ["ctrl-shift-h"], + keys: [`${DEFAULT_LEADER} h`], + leaderKeys: (leader) => [`${leader} h`], scope: "global", }, { @@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [ keys: ["tab"], scope: "list", }, - { - id: "pane.focusList", - label: "Hand the keyboard back to the session list, from the pane", - group: "Session list", - keys: ["esc"], - scope: "pane", - }, { id: "help.toggle", label: "Show or hide this help", diff --git a/web/src/main.ts b/web/src/main.ts index 8bc489f..f8e5f74 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -35,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js"; import { createPreviewSchedule } from "./previewSchedule.js"; import { FOCUS_PANE_EVENT, - FOCUS_SIDEBAR_EVENT, OPEN_COMMAND_BAR_EVENT, OPEN_SPAWN_EVENT, SESSION_SELECT_EVENT, @@ -440,10 +439,11 @@ const spawnKeys = new CommandSet( }), ); -// Ctrl+Shift+H moves the keyboard between the session list and the pane, the -// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached -// terminal owns every plain keystroke, so nothing unmodified could ever get -// the focus back out of it. +// Leader-h moves the keyboard between the session list and the pane, the web +// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the +// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H +// is already claimed by the browser (history), so nothing unmodified — or +// modified with a common browser chord — could get the focus back out of it. // // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served // on `/api/info`): it holds the key as a partial chord and takes select-all off @@ -528,8 +528,8 @@ window.addEventListener( ); -// Tab in the session list hands the keyboard to the pane; Escape in the pane -// hands it back — the pair the TUI gets from `tab` and `esc`. +// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle, +// above) hands it back the other way. document.addEventListener(FOCUS_PANE_EVENT, () => { // Handing the keyboard to a previewed pane is choosing it: the preview stops // being provisional rather than snapping back the moment the list blurs. @@ -537,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => { if (previewed) focusSession(previewed); pool.focusTerminal(); }); -document.addEventListener(FOCUS_SIDEBAR_EVENT, () => { - drawer.open(); // no-op on desktop; on mobile the list is off-canvas - sidebar.focusList(); -}); /** Open/focus a session and reflect it in the shell chrome. Shared by the * hash router and the sidebar's click-driven path (#133), which can't rely