diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index db75b8c..b031068 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -48,6 +48,10 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("c", "ToggleSidebarGroup"), // Catalog actions ("enter", "AttachHighlighted"), + // Tab focuses the highlighted pane, mirroring the web keybind — bare + // because Normal mode only exists while the sidebar is focused, so + // nothing forwards it to a pty. + ("tab", "AttachHighlighted"), ("s", "OpenSpawnModal"), // Fast-forward spawn (#67): the spawn key's neighbour does the same // thing with every default resolved and no form. @@ -63,14 +67,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("ctrl-w b", "ToggleSidebar"), ("?", "ShowHelp"), ("ctrl-w q", "Quit"), + // Bare quit, AoE-style: only reachable from the sidebar (Normal mode + // never forwards keys to a pty), same reasoning as bare `tab` above. + ("q", "Quit"), // Mouse ("mouse:dblclick(sidebar-row)", "AttachHighlighted"), ]; -/// Default Insert-mode bindings. Empty by default — the only Insert binding -/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so -/// rebinding the leader rebinds its Insert escape automatically. -const DEFAULT_INSERT: &[(&str, &str)] = &[]; +/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted +/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert +/// escape automatically; `esc` is a second, fixed way back to the sidebar. +const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")]; impl TuiConfig { /// Build a runtime config from a [`TuiSection`] (typically the `tui` @@ -222,6 +229,23 @@ mod tests { ); } + /// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal + /// mode only exists while the sidebar is focused, so neither can leak + /// into a pty; `esc` is a second way out of Insert alongside the leader. + #[test] + fn aoe_style_defaults_are_bound() { + let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); + assert_eq!(cfg.keymap.resolve(Mode::Normal, "q"), Some(Action::Quit)); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "tab"), + Some(Action::AttachHighlighted), + ); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "esc"), + Some(Action::LeaveInsert), + ); + } + #[test] fn default_leader_is_ctrl_a_and_escapes_insert() { let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); 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); });