From 7070ad97f46c64a39751205f32db8c53229fd803 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Thu, 15 Oct 2026 15:18:38 +0800 Subject: [PATCH 1/2] Add terminal focus shortcut and input focus highlight --- FEATURES.md | 8 +++++ web/src/App.tsx | 27 +++++++-------- web/src/activeTerminalInput.test.ts | 52 +++++++++++++++++++++++++++++ web/src/activeTerminalInput.ts | 12 +++++++ web/src/components/TerminalView.css | 9 ++++- web/src/shortcutBindings.test.ts | 37 ++++++++++++++++++++ web/src/shortcutBindings.ts | 8 ++++- web/src/shortcutCatalog.ts | 1 + web/src/shortcutPreferences.ts | 1 + 9 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 web/src/activeTerminalInput.test.ts create mode 100644 web/src/activeTerminalInput.ts diff --git a/FEATURES.md b/FEATURES.md index 4ee7e602..239be6cf 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -614,6 +614,7 @@ Common defaults (Linux/Android exceptions follow): | Zen mode (desktop) | `Cmd+Shift+Z` | `Ctrl+Alt+Z` | | Recent pane switcher | `Ctrl+Tab` | `Ctrl+Alt+J` | | Search panes | `Alt+K` | `Alt+K` | +| Focus terminal input (desktop) | `Ctrl+Backquote` | `Ctrl+Backquote` | | Create / close tab or pane | `Cmd+T` / `Cmd+W` | `Ctrl+Alt+T` / `Ctrl+Alt+W` | | Previous / next tab | `Cmd+Option+Left/Right` | `Alt+Shift+Left/Right` | | Focus neighboring pane | `Cmd+Ctrl+Arrow` | `Ctrl+Shift+Arrow` | @@ -638,6 +639,13 @@ right, and `Ctrl+Alt+Shift+S` to split down, avoiding desktop-reserved bindings. Letter/number bindings use physical keys. Browsers/OSes can intercept shortcuts; choose alternatives in the editor or use menus. +Use **Focus terminal input** to return to the current pane after using other +controls. It reveals the terminal and focuses the draft when Composer is open, +or direct terminal input otherwise. Close open menus or dialogs first. A bright +accent border and glow indicate actual input focus, including single-pane and +zoomed terminals; split panes keep a muted border on the selected pane when +input focus moves elsewhere. + Copy needs a selection; plain `Ctrl+C` remains terminal input. Page/half-page navigation and modified Enter are configurable. Native editing/IME/app keys remain available; remapped paste requires the Clipboard API. Touch shortcuts diff --git a/web/src/App.tsx b/web/src/App.tsx index 0d35814d..2a03c5e7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,6 +36,7 @@ import { useShortcutPreferences, } from "./shortcutPreferences"; import { SHORTCUT_NUMBERS } from "./shortcutBindings"; +import { activeTerminalInput } from "./activeTerminalInput"; import { CheckCircle2, ChevronLeft, @@ -1650,11 +1651,7 @@ export default function App() { setMobileView("session"); if (!mobile) { requestAnimationFrame(() => { - document - .querySelector( - ".pane-layout-cell.is-active .xterm-helper-textarea, .pane-switcher-layout .xterm-helper-textarea, .workspace-terminal-surface > .terminal-shell .xterm-helper-textarea", - ) - ?.focus(); + activeTerminalInput(document)?.focus({ preventScroll: true }); }); } }, [mobile, updateInspectorState]); @@ -1917,11 +1914,7 @@ export default function App() { setAssistantOpen(false); if (mobile && mobileView === "assistant") setMobileView("session"); requestAnimationFrame(() => { - const terminal = mobile - ? null - : document.querySelector( - ".pane-layout-cell.is-active .xterm-helper-textarea, .pane-switcher-layout .xterm-helper-textarea, .workspace-terminal-surface > .terminal-shell .xterm-helper-textarea", - ); + const terminal = mobile ? null : activeTerminalInput(document); focusIfUnchanged( mobile && mobileControlsCollapsed ? mobileControlsToggleRef.current @@ -2030,11 +2023,7 @@ export default function App() { ); return; } - document - .querySelector( - ".pane-layout-cell.is-active .xterm-helper-textarea, .pane-switcher-layout .xterm-helper-textarea, .workspace-terminal-surface > .terminal-shell .xterm-helper-textarea", - ) - ?.focus(); + activeTerminalInput(document)?.focus({ preventScroll: true }); }, [mobile]); const clearAnnotations = useCallback(() => { commitAnnotations([]); @@ -3093,6 +3082,13 @@ export default function App() { openPaneJumpSearch(); return; } + if (shortcutMatches(e, "terminal.focus")) { + if (mobile) return; + e.preventDefault(); + e.stopPropagation(); + if (!e.repeat) activateTerminalSurface(); + return; + } if (e.key === "Escape") { if ( document.getElementById(CONFIG_MENU_ID) || @@ -3348,6 +3344,7 @@ export default function App() { window.removeEventListener("blur", onBlur); }; }, [ + activateTerminalSurface, closePaneJump, commitPaneJump, defaultPaneJumpIndex, diff --git a/web/src/activeTerminalInput.test.ts b/web/src/activeTerminalInput.test.ts new file mode 100644 index 00000000..1a829e92 --- /dev/null +++ b/web/src/activeTerminalInput.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { activeTerminalInput } from "./activeTerminalInput"; + +describe("activeTerminalInput", () => { + test("leaves other inputs alone when no terminal is visible", async () => { + const browser = new Window(); + try { + browser.document.body.innerHTML = ``; + expect( + activeTerminalInput(browser.document as unknown as Document), + ).toBeNull(); + } finally { + await browser.happyDOM.close(); + } + }); + test.each(["single", "split", "switcher"])( + "returns keyboard input to the selected %s terminal or its composer draft", + async (layout) => { + const browser = new Window(); + const doc = browser.document; + try { + const terminal = `
+
+ +
`; + doc.body.innerHTML = `
${ + layout === "split" + ? `
${terminal}
${terminal}
` + : layout === "switcher" + ? `
${terminal}
` + : terminal + }
`; + const shell = doc.querySelector( + layout === "split" ? ".is-active .terminal-shell" : ".terminal-shell", + )!; + const [input, draft] = shell.querySelectorAll("textarea"); + doc.querySelector("button")!.focus(); + activeTerminalInput(doc as unknown as Document)?.focus(); + expect(doc.activeElement).toBe(input); + draft!.removeAttribute("hidden"); + activeTerminalInput(doc as unknown as Document)?.focus(); + expect(doc.activeElement).toBe(draft); + draft!.setAttribute("hidden", ""); + activeTerminalInput(doc as unknown as Document)?.focus(); + expect(doc.activeElement).toBe(input); + } finally { + await browser.happyDOM.close(); + } + }, + ); +}); diff --git a/web/src/activeTerminalInput.ts b/web/src/activeTerminalInput.ts new file mode 100644 index 00000000..e1fb8c4f --- /dev/null +++ b/web/src/activeTerminalInput.ts @@ -0,0 +1,12 @@ +export function activeTerminalInput(root: ParentNode): HTMLElement | null { + const shell = root.querySelector( + ".pane-layout-cell.is-active .terminal-shell, .pane-switcher-layout > .terminal-shell, .workspace-terminal-surface > .terminal-shell", + ); + return ( + shell?.querySelector( + ".terminal-composer-input:not([hidden])", + ) ?? + shell?.querySelector(".xterm-helper-textarea") ?? + null + ); +} diff --git a/web/src/components/TerminalView.css b/web/src/components/TerminalView.css index b85f1724..2ae033eb 100644 --- a/web/src/components/TerminalView.css +++ b/web/src/components/TerminalView.css @@ -9,8 +9,15 @@ } .pane-layout-cell.is-active .terminal-view { + border-color: var(--accent-soft); +} + +.terminal-view:has(.xterm-helper-textarea:focus), +.terminal-shell:has(.terminal-composer-input:focus) .terminal-view { border-color: var(--accent); - box-shadow: inset 0 0 0 1px var(--accent-soft); + outline: 2px solid var(--accent-soft); + outline-offset: -2px; + box-shadow: 0 0 10px var(--accent-soft); } .terminal-view { diff --git a/web/src/shortcutBindings.test.ts b/web/src/shortcutBindings.test.ts index d0c1b4c8..c19fa573 100644 --- a/web/src/shortcutBindings.test.ts +++ b/web/src/shortcutBindings.test.ts @@ -8,6 +8,7 @@ import { SHORTCUT_IDS, shortcutConflicts, shortcutFromEvent, + shortcutScope, validateShortcutKeys, type ShortcutBindings, type ShortcutEvent, @@ -42,6 +43,27 @@ const preset = () => ({ }); describe("platform shortcut maps", () => { + test("terminal focus is a configurable global shortcut on every platform", () => { + for (const platform of ["mac", "windows", "linux"] as const) { + const bindings = defaultShortcutBindings(platform); + expect(bindings["terminal.focus"]).toEqual(["Ctrl+Backquote"]); + expect( + matchesShortcut( + event({ key: "`", code: "Backquote", ctrlKey: true }), + "terminal.focus", + bindings, + ), + ).toBe(true); + expect( + shortcutConflicts( + "terminal.focus", + bindings["preview.selectAll"], + bindings, + ), + ).toContain("preview.selectAll"); + } + expect(shortcutScope("terminal.focus")).toBe("global"); + }); test("Linux and Windows share the pane-switcher bindings", () => { for (const id of ["panes.recent", "panes.search", "command.menu"] as const) expect(defaultShortcutBindings("linux")[id]).toEqual( @@ -384,6 +406,21 @@ test("older presets keep a key assigned before the popup shortcut existed", () = } }); +test("older presets gain terminal focus without replacing saved assignments", () => { + for (const base of ["mac", "windows", "linux"] as const) { + const defaults = defaultShortcutBindings(base); + const bindings: Partial = { ...defaults }; + delete bindings["terminal.focus"]; + expect( + validateShortcutPreset({ ...preset(), base, bindings }).bindings, + ).toEqual(defaults); + bindings["tab.create"] = defaults["terminal.focus"]; + const loaded = validateShortcutPreset({ ...preset(), base, bindings }); + expect(loaded.bindings["tab.create"]).toEqual(defaults["terminal.focus"]); + expect(loaded.bindings["terminal.focus"]).toEqual([]); + } +}); + test("older presets gain annotation delivery shortcuts without replacing saved keys", () => { for (const base of ["mac", "windows", "linux"] as const) { const defaults = defaultShortcutBindings(base); diff --git a/web/src/shortcutBindings.ts b/web/src/shortcutBindings.ts index 063d6a95..d13b66ec 100644 --- a/web/src/shortcutBindings.ts +++ b/web/src/shortcutBindings.ts @@ -47,6 +47,7 @@ const base = { "workspaces.open": ["Ctrl+Alt+O"], "files.toggle": ["Ctrl+Alt+E"], "diff.toggle": ["Ctrl+Alt+G"], + "terminal.focus": ["Ctrl+Backquote"], "terminal.history": ["Ctrl+Alt+H"], "terminal.pageUp": ["PageUp"], "terminal.pageDown": ["PageDown"], @@ -289,7 +290,12 @@ export function formatShortcut( export function shortcutScope(id: ShortcutId): string { if (id.startsWith("command.") && id !== "command.menu") return "command"; - if (id === "terminal.history" || id === "annotations.toggle") return "global"; + if ( + id === "terminal.focus" || + id === "terminal.history" || + id === "annotations.toggle" + ) + return "global"; if (/^(terminal|preview|composer|annotation|annotations)\./.test(id)) return id.split(".")[0]; return "global"; diff --git a/web/src/shortcutCatalog.ts b/web/src/shortcutCatalog.ts index 8e6629ab..8bf73faa 100644 --- a/web/src/shortcutCatalog.ts +++ b/web/src/shortcutCatalog.ts @@ -43,6 +43,7 @@ const descriptions: [ShortcutId, string, string][] = [ `Run numbered action ${n} in the command menu`, "Command menu", ]), + ["terminal.focus", "Focus the terminal input on desktop", "Terminal"], ["terminal.history", "Toggle agent message history", "Terminal"], [ "terminal.pageUp", diff --git a/web/src/shortcutPreferences.ts b/web/src/shortcutPreferences.ts index 57f8af4f..6417c0ff 100644 --- a/web/src/shortcutPreferences.ts +++ b/web/src/shortcutPreferences.ts @@ -49,6 +49,7 @@ const LATE_SHORTCUT_IDS: ShortcutId[] = [ "pane.moveRight", "pane.moveUp", "pane.moveDown", + "terminal.focus", ]; export function validateShortcutPreset(value: unknown): ShortcutPreset { if (!value || typeof value !== "object") From 8b060b6152e09a2338396aade81e401a58957080 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Thu, 15 Oct 2026 15:30:28 +0800 Subject: [PATCH 2/2] Activate terminal input before restoring focus after touch --- web/src/activeTerminalInput.test.ts | 11 ++-- web/src/activeTerminalInput.ts | 2 +- .../components/TerminalView.inputMode.test.ts | 58 +++++++++++++++++++ web/src/components/TerminalView.tsx | 9 ++- 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/web/src/activeTerminalInput.test.ts b/web/src/activeTerminalInput.test.ts index 1a829e92..f2bb8739 100644 --- a/web/src/activeTerminalInput.test.ts +++ b/web/src/activeTerminalInput.test.ts @@ -15,13 +15,13 @@ describe("activeTerminalInput", () => { } }); test.each(["single", "split", "switcher"])( - "returns keyboard input to the selected %s terminal or its composer draft", + "finds the selected %s terminal activation target or its composer draft", async (layout) => { const browser = new Window(); const doc = browser.document; try { const terminal = `
-
+
`; doc.body.innerHTML = `
${ @@ -34,14 +34,15 @@ describe("activeTerminalInput", () => { const shell = doc.querySelector( layout === "split" ? ".is-active .terminal-shell" : ".terminal-shell", )!; - const [input, draft] = shell.querySelectorAll("textarea"); + const input = shell.querySelector("div")!; + const draft = shell.querySelectorAll("textarea")[1]!; doc.querySelector("button")!.focus(); activeTerminalInput(doc as unknown as Document)?.focus(); expect(doc.activeElement).toBe(input); - draft!.removeAttribute("hidden"); + draft.removeAttribute("hidden"); activeTerminalInput(doc as unknown as Document)?.focus(); expect(doc.activeElement).toBe(draft); - draft!.setAttribute("hidden", ""); + draft.setAttribute("hidden", ""); activeTerminalInput(doc as unknown as Document)?.focus(); expect(doc.activeElement).toBe(input); } finally { diff --git a/web/src/activeTerminalInput.ts b/web/src/activeTerminalInput.ts index e1fb8c4f..ba623e98 100644 --- a/web/src/activeTerminalInput.ts +++ b/web/src/activeTerminalInput.ts @@ -6,7 +6,7 @@ export function activeTerminalInput(root: ParentNode): HTMLElement | null { shell?.querySelector( ".terminal-composer-input:not([hidden])", ) ?? - shell?.querySelector(".xterm-helper-textarea") ?? + shell?.querySelector(".terminal-view") ?? null ); } diff --git a/web/src/components/TerminalView.inputMode.test.ts b/web/src/components/TerminalView.inputMode.test.ts index 2ac783fb..e141c3bc 100644 --- a/web/src/components/TerminalView.inputMode.test.ts +++ b/web/src/components/TerminalView.inputMode.test.ts @@ -265,6 +265,7 @@ async function registerDomTests() { "../browserStorage" ); const { normalizeTerminalFontScale } = await import("../appearance"); + const { activeTerminalInput } = await import("../activeTerminalInput"); const { clearTerminalComposerDraft, readTerminalComposerDraft, @@ -399,6 +400,63 @@ async function registerDomTests() { }); } + test.each(["closed", "direct", "composer"] as const)( + "terminal focus activates %s input after touch on a coarse-pointer desktop", + async (mode) => { + await mount(mode !== "closed"); + container.className = "workspace-terminal-surface"; + if (mode === "direct") await choose("direct"); + const term = currentTerminal(); + const otherControl = document.createElement("button"); + document.body.append(otherControl); + act(() => { + otherControl.dispatchEvent( + new browser.PointerEvent("pointerdown", { + bubbles: true, + pointerType: "touch", + }) as unknown as PointerEvent, + ); + otherControl.focus(); + }); + expectInputBlocked(); + act(() => term.textarea.focus()); + expect(document.activeElement === term.textarea).toBe(false); + expectInputBlocked(); + act(() => activeTerminalInput(document)?.focus({ preventScroll: true })); + if (mode === "composer") { + expect(document.activeElement).toBe( + container.querySelector(".terminal-composer-input"), + ); + expectInputBlocked(); + } else { + expect(document.activeElement === term.textarea).toBe(true); + expect(term.options.disableStdin).toBe(false); + expect(term.textarea.readOnly).toBe(false); + act(() => term.onDataCallback("focused")); + expect( + calls.filter((call) => call.method === "terminal.input"), + ).toHaveLength(1); + } + }, + ); + + test.each(["disconnected", "paused", "inactive split"] as const)( + "terminal focus does not activate input when %s", + async (change) => { + explicitPane = "pane-a"; + await mount(); + container.className = "workspace-terminal-surface"; + if (change === "disconnected") await patch({ status: "disconnected" }); + if (change === "paused") await patch({ connectionPaused: true }); + if (change === "inactive split") + await patch({ selectedPaneId: "pane-b" }); + const term = currentTerminal(); + act(() => activeTerminalInput(document)?.focus({ preventScroll: true })); + expect(document.activeElement).not.toBe(term.textarea); + expectInputBlocked(); + }, + ); + test("Ctrl+/ sends one unambiguous slash key and suppresses xterm's Ctrl+_ fallback", async () => { await mount(true); await choose("direct"); diff --git a/web/src/components/TerminalView.tsx b/web/src/components/TerminalView.tsx index 72fa3e72..791c2aad 100644 --- a/web/src/components/TerminalView.tsx +++ b/web/src/components/TerminalView.tsx @@ -3490,7 +3490,14 @@ export function TerminalView({ onPointerDown={preventPaneActionFocus} />
-
+
{ + if (event.target === event.currentTarget) focusTerminalInput(); + }} + /> {!isMobileLayout() && touchHandles.length === 0 && isActivePane ? (