Merge remote-tracking branch 'origin/main' into activity
ci / web (push) Successful in 39s
ci / rust (push) Successful in 3m45s
ci / flake (push) Successful in 4m16s

This commit is contained in:
Miguel Palhas
2026-09-02 18:08:00 +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"),
// 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();
+10
View File
@@ -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.
+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 —
* 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();
});
+2 -1
View File
@@ -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" },
],
},
{
+7 -1
View File
@@ -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
+2 -2
View File
@@ -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);
});