From b2cbb23fe220ecda3c9d5567e48e821c2a001157 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 17:03:19 +0100 Subject: [PATCH] feat(maestro): open the bar with ctrl-k over a focused pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global listener already ran in the capture phase, but deferred to isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats that textarea as not a typing target, while real form fields — the spawn form's inputs — keep their own Ctrl-K. Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused pane through the leader chord the command set already resolves, so readline's kill-to-end-of-line stays reachable. The pool grows a sendToFocused for it, over a new AttachConnection.sendInput. Co-Authored-By: Claude Opus 5 --- web/src/attach.ts | 7 ++++ web/src/commandBar.test.ts | 35 ++++++++++++++++++ web/src/commands.test.ts | 73 +++++++++++++++++++++++++++++++++++++ web/src/commands.ts | 25 ++++++++++--- web/src/connections.test.ts | 40 ++++++++++++++++++++ web/src/connections.ts | 12 ++++++ web/src/main.ts | 16 +++++--- 7 files changed, 198 insertions(+), 10 deletions(-) diff --git a/web/src/attach.ts b/web/src/attach.ts index 0dc1492..3886680 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -50,6 +50,10 @@ export interface AttachConnection { onUnmount(): void; /** Put the keyboard back into this terminal — what leaving the sidebar does. */ focusTerminal(): void; + /** Type raw bytes straight into the PTY, bypassing the terminal's own key + * handling — the leader's literal Ctrl-K escape hatch + * (docs/COMMAND-BAR.md). */ + sendInput(data: string): void; /** Fully disconnect: close the WS, dispose the terminal, remove the element. */ dispose(): void; } @@ -506,6 +510,9 @@ export function createAttachConnection( focusTerminal() { term.focus(); }, + // The closure above: the same bytes-out path keystrokes and the mobile key + // bar already take. + sendInput, dispose() { if (disposed) return; disposed = true; diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index bb2cc6a..bf4ebd4 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -507,3 +507,38 @@ describe("the sigils in the overlay", () => { expect(seen).toEqual(["/home/me/dev/api"]); }); }); + +describe("the focus round-trip over a pane (#152)", () => { + function terminalTextarea(): HTMLTextAreaElement { + const ta = document.createElement("textarea"); + ta.className = "xterm-helper-textarea"; + document.body.appendChild(ta); + return ta; + } + + it("hands the keyboard back to the terminal that had it", () => { + const ta = terminalTextarea(); + ta.focus(); + openCommandBar({ sessions: [], commands: [] }); + expect(document.activeElement).toBe(document.querySelector(".cmdbar-input")); + press("Escape"); + expect(document.activeElement).toBe(ta); + }); + + it("reports no focus change to the daemon, opening or closing", () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))); + vi.stubGlobal("fetch", fetchMock); + try { + const ta = terminalTextarea(); + ta.focus(); + openCommandBar({ sessions: [], commands: [] }); + press("Escape"); + const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) => + String(url).includes("/focus"), + ); + expect(focusCalls).toEqual([]); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts index 4bb658c..5202ab5 100644 --- a/web/src/commands.test.ts +++ b/web/src/commands.test.ts @@ -7,6 +7,8 @@ import { DEFAULT_LEADER, canonicalizeKey, defineCommands, + isBarTypingTarget, + isTerminalTextarea, isTypingTarget, keyCaps, publishedCommands, @@ -234,3 +236,74 @@ describe("isTypingTarget", () => { expect(isTypingTarget(null)).toBe(false); }); }); + +describe("isTerminalTextarea", () => { + it("recognises xterm's offscreen textarea, and nothing else", () => { + expect(isTerminalTextarea(el(''))).toBe(true); + expect(isTerminalTextarea(el(""))).toBe(false); + expect(isTerminalTextarea(el(""))).toBe(false); + expect(isTerminalTextarea(null)).toBe(false); + }); +}); + +describe("isBarTypingTarget", () => { + it("holds the bar's keys out of real form fields", () => { + expect(isBarTypingTarget(el(""))).toBe(true); + expect(isBarTypingTarget(el(""))).toBe(true); + }); + + it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => { + expect(isBarTypingTarget(el(''))).toBe(false); + expect(isBarTypingTarget(el(""))).toBe(false); + expect(isBarTypingTarget(null)).toBe(false); + }); +}); + +describe("ctrl-k over a focused pane (#152)", () => { + const terminal = () => el(''); + + /** The global listener's routing: the bar's set only sees a key that is not + * aimed at a real form field. */ + function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) { + if (isBarTypingTarget(target)) return false; + return pressOn(set, target, k, init); + } + + it("opens the bar when the terminal has the keyboard", () => { + const open = vi.fn(); + const set = new CommandSet(defineCommands({ "bar.open": open })); + expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true); + expect(open).toHaveBeenCalledTimes(1); + }); + + it("leaves the spawn form's inputs their own Ctrl-K", () => { + const open = vi.fn(); + const set = new CommandSet(defineCommands({ "bar.open": open })); + expect(route(set, el(""), "k", { ctrlKey: true })).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it("sends the literal 0x0B after the leader, without opening the bar", () => { + const open = vi.fn(); + const literal = vi.fn(); + const bar = new CommandSet(defineCommands({ "bar.open": open })); + const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), { + leader: DEFAULT_LEADER, + }); + const target = terminal(); + expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); + const e = key("k", { ctrlKey: true }); + Object.defineProperty(e, "target", { value: target }); + expect(set.handle(e)).toBe(true); + expect(literal).toHaveBeenCalledTimes(1); + // The leader's set consumed the key, so the bar's set never sees it. + expect(open).not.toHaveBeenCalled(); + expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise + expect(open).toHaveBeenCalledTimes(1); + }); + + it("binds the escape hatch behind the default leader", () => { + const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK"); + expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]); + }); +}); diff --git a/web/src/commands.ts b/web/src/commands.ts index 72a409d..1d8ff61 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -32,6 +32,10 @@ export interface Command extends CommandSpec { run: () => void; } +/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all + * in a browser, so a set that owns the leader `preventDefault`s it. */ +export const DEFAULT_LEADER = "ctrl-a"; + /** Every command the web client has. The single list this issue exists to * create: labels, chords and grouping live here and nowhere else. */ export const COMMAND_SPECS: readonly CommandSpec[] = [ @@ -56,6 +60,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ keys: ["ctrl-k"], scope: "global", }, + { + id: "pane.literalCtrlK", + label: "Send a literal Ctrl-K to the pane", + group: "Pane", + keys: [`${DEFAULT_LEADER} ctrl-k`], + scope: "global", + }, { id: "focus.toggle", label: "Move the keyboard between the session list and the pane", @@ -241,11 +252,19 @@ export function isTypingTarget(target: EventTarget | null): boolean { /** xterm's offscreen textarea. It is a typing target like any other, but it is * also where the leader has to work — a pane is exactly where the operator * needs the escape hatch (docs/COMMAND-BAR.md). */ -function isTerminalTextarea(target: EventTarget | null): boolean { +export function isTerminalTextarea(target: EventTarget | null): boolean { const el = target as HTMLElement | null; return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea"); } +/** True where the command bar's own keys must stay out of the way: a real form + * field, and not the pane. `ctrl-k` is intercepted ahead of xterm + * (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while + * the spawn form's inputs keep their own Ctrl-K. */ +export function isBarTypingTarget(target: EventTarget | null): boolean { + return isTypingTarget(target) && !isTerminalTextarea(target); +} + // ---- display ---- const CAPS: Record = { @@ -292,10 +311,6 @@ export function keyCaps(chord: string): string[] { // ---- dispatch ---- -/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all - * in a browser, so a set that owns the leader `preventDefault`s it. */ -export const DEFAULT_LEADER = "ctrl-a"; - /** How long a partial chord waits for its next key — the TUI's * `chord_timeout_ms` default. */ export const DEFAULT_CHORD_TIMEOUT_MS = 1000; diff --git a/web/src/connections.test.ts b/web/src/connections.test.ts index 27622de..9464504 100644 --- a/web/src/connections.test.ts +++ b/web/src/connections.test.ts @@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ConnectionPool } from "./connections.js"; +// The pane's own bytes-out path, stubbed: the escape hatch only needs to know +// the pool reaches the focused connection with them. +const sent: { id: string; data: string }[] = []; +vi.mock("./attach.js", () => ({ + createAttachConnection: (id: string) => ({ + sessionId: id, + el: document.createElement("div"), + onMount() {}, + onUnmount() {}, + focusTerminal() {}, + sendInput(data: string) { + sent.push({ id, data }); + }, + dispose() {}, + }), +})); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => { await Promise.resolve(); }); }); + +describe("ConnectionPool.sendToFocused", () => { + it("types the bytes into the focused session's PTY", () => { + mockFetch(); + sent.length = 0; + const pool = new ConnectionPool(); + const pane = document.createElement("div"); + pool.mount(A, pane); + pool.setFocus(A); + + expect(pool.sendToFocused("\x0b")).toBe(true); + expect(sent).toEqual([{ id: A, data: "\x0b" }]); + }); + + it("is a no-op with nothing focused", () => { + mockFetch(); + sent.length = 0; + const pool = new ConnectionPool(); + + expect(pool.sendToFocused("\x0b")).toBe(false); + expect(sent).toEqual([]); + }); +}); diff --git a/web/src/connections.ts b/web/src/connections.ts index 53b7082..1f17afe 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -159,6 +159,18 @@ export class ConnectionPool { this.cache.get(this.focusedId)?.focusTerminal(); } + /** Type raw bytes into the focused session's PTY. Returns false when there is + * nothing focused, or its connection is gone — the leader's literal Ctrl-K + * is a no-op over the welcome pane rather than an error + * (docs/COMMAND-BAR.md). */ + sendToFocused(data: string): boolean { + if (this.focusedId === null) return false; + const conn = this.cache.get(this.focusedId); + if (!conn) return false; + conn.sendInput(data); + return true; + } + /** Fully disconnect + forget a session. */ close(id: Uuid): void { const conn = this.cache.get(id); diff --git a/web/src/main.ts b/web/src/main.ts index fa1c46a..6811186 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,7 @@ import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen } from "./help.js"; -import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js"; +import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js"; import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; @@ -424,7 +424,7 @@ document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => { // attached terminal must never be able to trap the focus keys. // // `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain -// keys, so `isTypingTarget` keeps them out of form fields and the terminal's +// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's // hidden textarea. const spawnKeys = new CommandSet( defineCommands({ @@ -451,6 +451,11 @@ const focusKeys = new CommandSet( sidebar.focusList(); } }, + // The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line + // away from every pane, and leader ctrl-k gives it back as the literal + // 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the + // bar's, so the chord never opens the bar. + "pane.literalCtrlK": () => pool.sendToFocused("\x0b"), }), { leader: DEFAULT_LEADER }, ); @@ -479,9 +484,10 @@ window.addEventListener( if (focusKeys.handle(e)) return; // The help overlay owns the keyboard while it is up. if (isHelpOpen()) return; - // Opening the bar over a focused terminal is #152; until then the pane's - // hidden textarea keeps its Ctrl+K, like any other typing target. - if (!isTypingTarget(e.target) && barKeys.handle(e)) return; + // The pane's hidden textarea is not a typing target for the bar: ctrl-k is + // seen ahead of xterm and opens the bar (#152). A real form field — the + // spawn form's inputs — keeps its own Ctrl+K. + if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return; spawnKeys.handle(e); }, true,