From d4c72577b9fe9dcd62adac1cd99cf829e834105c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 17:58:28 +0100 Subject: [PATCH] feat(maestro): swap web session-list/pane focus keys to Tab/Esc 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 --- web/src/attach.ts | 10 ++++++++++ web/src/catalog.ts | 8 ++++++-- web/src/help.ts | 3 ++- web/src/main.ts | 8 +++++++- web/src/sidebarNav.test.ts | 4 ++-- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/web/src/attach.ts b/web/src/attach.ts index ec7d236..6ce6de1 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; +import { FOCUS_SIDEBAR_EVENT } from "./catalog.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; @@ -134,6 +135,15 @@ export function createAttachConnection( term.loadAddon(fit); term.open(body); + // 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. + term.attachCustomKeyEventHandler((e) => { + if (e.type !== "keydown" || e.key !== "Escape") return true; + if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true; + document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)); + return false; + }); + // 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 106a161..7256ca7 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -36,10 +36,14 @@ export function requestOpenSpawn(): void { } /** Ask the shell to hand the keyboard back to the focused pane's terminal — - * what Escape in the sidebar list does. Handled in main.ts, which owns the + * what Tab in the sidebar list does. Handled in main.ts, which owns the * 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"; @@ -568,7 +572,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S if (e.key === "j" || e.key === "ArrowDown") moveNav(1); else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1); else if (e.key === "Enter") activateNav(); - else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)); + else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)); else return; e.preventDefault(); }); diff --git a/web/src/help.ts b/web/src/help.ts index e6a0565..6b38721 100644 --- a/web/src/help.ts +++ b/web/src/help.ts @@ -31,7 +31,8 @@ export const SHORTCUTS: ShortcutGroup[] = [ { 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: ["Esc"], description: "Hand the keyboard back to the pane" }, + { keys: ["Tab"], description: "Send the keyboard to the pane" }, + { keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" }, ], }, { diff --git a/web/src/main.ts b/web/src/main.ts index d9c6f3a..f635094 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -31,6 +31,7 @@ import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; import { FOCUS_PANE_EVENT, + FOCUS_SIDEBAR_EVENT, OPEN_SPAWN_EVENT, SESSION_SELECT_EVENT, SPAWN_SIMILAR_EVENT, @@ -372,8 +373,13 @@ window.addEventListener("keydown", (e) => { } }); -// Escape in the session list hands the keyboard back to the pane. +// 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`. document.addEventListener(FOCUS_PANE_EVENT, () => 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 diff --git a/web/src/sidebarNav.test.ts b/web/src/sidebarNav.test.ts index 32464bf..ccbe9da 100644 --- a/web/src/sidebarNav.test.ts +++ b/web/src/sidebarNav.test.ts @@ -109,11 +109,11 @@ describe("sidebar keyboard navigation", () => { expect(handle.hasListFocus()).toBe(true); }); - it("Escape asks the shell for the pane back", () => { + it("Tab asks the shell for the pane back", () => { const { el } = twoFolders(); let asked = 0; document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1)); - press(tree(el), "Escape"); + press(tree(el), "Tab"); expect(asked).toBe(1); });