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