Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
27 changes: 12 additions & 15 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
useShortcutPreferences,
} from "./shortcutPreferences";
import { SHORTCUT_NUMBERS } from "./shortcutBindings";
import { activeTerminalInput } from "./activeTerminalInput";
import {
CheckCircle2,
ChevronLeft,
Expand Down Expand Up @@ -1650,11 +1651,7 @@ export default function App() {
setMobileView("session");
if (!mobile) {
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(
".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]);
Expand Down Expand Up @@ -1917,11 +1914,7 @@ export default function App() {
setAssistantOpen(false);
if (mobile && mobileView === "assistant") setMobileView("session");
requestAnimationFrame(() => {
const terminal = mobile
? null
: document.querySelector<HTMLElement>(
".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
Expand Down Expand Up @@ -2030,11 +2023,7 @@ export default function App() {
);
return;
}
document
.querySelector<HTMLElement>(
".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([]);
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -3348,6 +3344,7 @@ export default function App() {
window.removeEventListener("blur", onBlur);
};
}, [
activateTerminalSurface,
closePaneJump,
commitPaneJump,
defaultPaneJumpIndex,
Expand Down
53 changes: 53 additions & 0 deletions web/src/activeTerminalInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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 = `<textarea class="xterm-helper-textarea"></textarea>`;
expect(
activeTerminalInput(browser.document as unknown as Document),
).toBeNull();
} finally {
await browser.happyDOM.close();
}
});
test.each(["single", "split", "switcher"])(
"finds the selected %s terminal activation target or its composer draft",
async (layout) => {
const browser = new Window();
const doc = browser.document;
try {
const terminal = `<div class="terminal-shell">
<div class="terminal-view" tabindex="-1"><textarea class="xterm-helper-textarea"></textarea></div>
<textarea class="terminal-composer-input" hidden></textarea>
</div>`;
doc.body.innerHTML = `<button>Other control</button><div class="workspace-terminal-surface">${
layout === "split"
? `<div class="pane-layout-cell">${terminal}</div><div class="pane-layout-cell is-active">${terminal}</div>`
: layout === "switcher"
? `<div class="pane-switcher-layout">${terminal}</div>`
: terminal
}</div>`;
const shell = doc.querySelector(
layout === "split" ? ".is-active .terminal-shell" : ".terminal-shell",
)!;
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");
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();
}
},
);
});
12 changes: 12 additions & 0 deletions web/src/activeTerminalInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function activeTerminalInput(root: ParentNode): HTMLElement | null {
const shell = root.querySelector<HTMLElement>(
".pane-layout-cell.is-active .terminal-shell, .pane-switcher-layout > .terminal-shell, .workspace-terminal-surface > .terminal-shell",
);
return (
shell?.querySelector<HTMLElement>(
".terminal-composer-input:not([hidden])",
) ??
shell?.querySelector<HTMLElement>(".terminal-view") ??
null
);
}
9 changes: 8 additions & 1 deletion web/src/components/TerminalView.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions web/src/components/TerminalView.inputMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ async function registerDomTests() {
"../browserStorage"
);
const { normalizeTerminalFontScale } = await import("../appearance");
const { activeTerminalInput } = await import("../activeTerminalInput");
const {
clearTerminalComposerDraft,
readTerminalComposerDraft,
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 8 additions & 1 deletion web/src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3490,7 +3490,14 @@ export function TerminalView({
onPointerDown={preventPaneActionFocus}
/>
<div className="terminal-main">
<div ref={containerRef} className="terminal-view" />
<div
ref={containerRef}
className="terminal-view"
tabIndex={-1}
onFocus={(event) => {
if (event.target === event.currentTarget) focusTerminalInput();
}}
/>
{!isMobileLayout() && touchHandles.length === 0 && isActivePane ? (
<div
className="terminal-mobile-input-actions"
Expand Down
37 changes: 37 additions & 0 deletions web/src/shortcutBindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SHORTCUT_IDS,
shortcutConflicts,
shortcutFromEvent,
shortcutScope,
validateShortcutKeys,
type ShortcutBindings,
type ShortcutEvent,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<ShortcutBindings> = { ...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);
Expand Down
8 changes: 7 additions & 1 deletion web/src/shortcutBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions web/src/shortcutCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions web/src/shortcutPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading