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,