Compare commits

...
2 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 d4c72577b9 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 <noreply@anthropic.com>
2026-09-02 17:58:28 +01:00
Miguel PalhasandClaude Sonnet 5 aed9a5eec0 feat(maestro): AoE-style bare q/tab/esc keybinds in the TUI
Normal mode only exists while the sidebar is focused (Insert mode always
pairs with Focus::Pane), so bare `q` and `tab` there never reach a pty:
`q` quits, `tab` focuses the highlighted pane (same as Enter). `esc` is
added as a second, fixed way out of Insert alongside the leader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:58:24 +01:00
6 changed files with 55 additions and 10 deletions
+28 -4
View File
@@ -48,6 +48,10 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("c", "ToggleSidebarGroup"), ("c", "ToggleSidebarGroup"),
// Catalog actions // Catalog actions
("enter", "AttachHighlighted"), ("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"), ("s", "OpenSpawnModal"),
// Fast-forward spawn (#67): the spawn key's neighbour does the same // Fast-forward spawn (#67): the spawn key's neighbour does the same
// thing with every default resolved and no form. // thing with every default resolved and no form.
@@ -63,14 +67,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("ctrl-w b", "ToggleSidebar"), ("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"), ("?", "ShowHelp"),
("ctrl-w q", "Quit"), ("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
("mouse:dblclick(sidebar-row)", "AttachHighlighted"), ("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
]; ];
/// Default Insert-mode bindings. Empty by default — the only Insert binding /// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so /// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
/// rebinding the leader rebinds its Insert escape automatically. /// escape automatically; `esc` is a second, fixed way back to the sidebar.
const DEFAULT_INSERT: &[(&str, &str)] = &[]; const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
impl TuiConfig { impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui` /// 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] #[test]
fn default_leader_is_ctrl_a_and_escapes_insert() { fn default_leader_is_ctrl_a_and_escapes_insert() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
+10
View File
@@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js"; import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -134,6 +135,15 @@ export function createAttachConnection(
term.loadAddon(fit); term.loadAddon(fit);
term.open(body); 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 // 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: // line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback. // • normal buffer → term.scrollLines walks the scrollback.
+6 -2
View File
@@ -36,10 +36,14 @@ export function requestOpenSpawn(): void {
} }
/** Ask the shell to hand the keyboard back to the focused pane's terminal — /** 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. */ * connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane"; 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 /** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; 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); if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1); else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav(); 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; else return;
e.preventDefault(); e.preventDefault();
}); });
+2 -1
View File
@@ -31,7 +31,8 @@ export const SHORTCUTS: ShortcutGroup[] = [
{ keys: ["j"], description: "Highlight the next session" }, { keys: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" }, { keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" }, { 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" },
], ],
}, },
{ {
+7 -1
View File
@@ -31,6 +31,7 @@ import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js"; import { loadLayout, saveLayout } from "./persistence.js";
import { import {
FOCUS_PANE_EVENT, FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_SPAWN_EVENT, OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT, SESSION_SELECT_EVENT,
SPAWN_SIMILAR_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_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 /** 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 * hash router and the sidebar's click-driven path (#133), which can't rely
+2 -2
View File
@@ -109,11 +109,11 @@ describe("sidebar keyboard navigation", () => {
expect(handle.hasListFocus()).toBe(true); 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(); const { el } = twoFolders();
let asked = 0; let asked = 0;
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1)); document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
press(tree(el), "Escape"); press(tree(el), "Tab");
expect(asked).toBe(1); expect(asked).toBe(1);
}); });