feat(web): setting to keep the terminal from taking focus automatically#8372

closed4
main
GitHub fork: feat/terminal-auto-focus-setting
openedAug 27, 2026byDdarahaas15
View on GitHubTwo-way sync enabledPR last imported 21d ago

What Changed

A new client setting: Settings → General → "Terminal auto-focus" (terminalAutoFocus, default on, so behavior is unchanged for existing users). When off, the embedded terminal never takes keyboard focus on its own: not when you switch to a thread whose terminal is open, and not when the drawer opens. Direct interaction still focuses it: clicking into the terminal, and terminal actions such as creating, splitting, closing, or activating one.

The gating splits automatic focus from explicit focus requests (refined per the review feedback below). Automatic paths honor the setting: the surface-ready and first-output focus in TerminalViewport, its reveal effect on the hidden-to-visible transition (thread activation, drawer open), and the drawer-open request bump in ChatView. The focus-request id carries only explicit actions - encoding visibility or the setting into it would turn their transitions, including async settings hydration, into spoofed requests. Action-driven focus requests (terminal create/split/close/tab activation) always focus, so terminal actions cannot drop keyboard focus on document.body. Two supporting changes make the split sound: the focus-request id is now passed to persistent drawers unconditionally (the previous swap-to-0 made thread activation itself look like a request), and a viewport mounted by the same render as a request (the single-terminal view remounts on create/close/activate) carries it across the remount via a mount-captured flag. Focus permission is checked at frame time through an effect event, so a setting that hydrates or toggles after scheduling wins over the captured render. Both terminal surfaces (bottom drawer and right-panel terminal) render through TerminalViewport, so both respect the setting. The row is searchable, has a reset button, and participates in restore-defaults.

Why

Switching threads with thread.next / thread.previous (⌘⇧] / ⌘⇧[) or thread.jump.N drops focus into the terminal whenever the target thread has one open. Once the Ghostty surface has focus it owns keystrokes: keybindings.ts already documents that the jump commands cannot run there regardless of when clauses, and the jump hints are deliberately hidden while terminalFocus for the same reason. So keyboard-driven thread navigation dead-ends on the first thread with an open terminal, and every jump costs an extra ⌘J or a click to get focus back.

This is a setting rather than a behavior change because auto-focus is the right default for people who open a terminal to type into it. Keyboard-heavy users who mostly watch agent terminals can opt out and keep focus in the composer.

UI Changes

One standard SettingsRow + Switch in Settings → General, placed with the other interaction-behavior rows and reusing the archive/delete-confirmation row pattern verbatim. Happy to attach before/after screenshots if wanted.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (single stock SettingsRow/Switch; can add on request)
  • I included a video for animation/interaction changes (no motion involved)

Note

Medium Risk Terminal focus is intricate keyboard UX; regressions could break thread navigation or terminal shortcuts, though the default preserves prior auto-focus behavior.

Overview Adds a Terminal auto-focus client setting (terminalAutoFocus, default on) so existing behavior stays the same until users turn it off in Settings → General.

When off, the embedded terminal no longer steals keyboard focus on automatic reveals—opening the drawer, switching to a thread with an open terminal, surface-ready mount, or first output. Explicit terminal actions (create, split, close, tab activate) still focus the terminal so focus does not fall to document.body when a viewport remounts.

The focus model is split so focusRequestId carries only action-driven requests (visibility and settings are no longer mixed into that counter). TerminalViewport gates automatic paths via frame-time permission checks and a one-shot mountFocusPending flag for remounts tied to explicit requests; ChatView only bumps the focus request when the drawer opens if auto-focus is enabled.

Reviewed by Cursor Bugbot for commit 8f49ff3bb13fbadf0ed7f307c0b6231cc81df62d. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add terminalAutoFocus setting to control terminal drawer auto-focus

  • Adds a new boolean terminalAutoFocus field (default true) to ClientSettingsSchema and the patch schema in settings.ts
  • Adds a toggle in the General settings panel with reset-to-default support and a searchable entry in SettingsPanels.tsx and settingsSearch.ts
  • Reworks focus logic in ChatView.tsx and ThreadTerminalDrawer.tsx so reveal and readiness events respect the setting, while explicit focus requests (create/split/activate) still focus when autoFocus is true
  • focusRequestId no longer zeroes for inactive threads and no longer changes on visibility transitions, preventing spurious focus on activation
  • Behavioral Change: focusRequestId is now always the current terminalFocusRequestId instead of zeroed for inactive threads; hidden drawers ignore requests. Reviewers should verify inactive-thread terminal activation still behaves correctly.

Macroscope summarized 8f49ff3.

Added 1 commit

  • feat(web): setting to keep the terminal from taking focus automaticallyf1c443e
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ba98415-338a-459a-808f-79d66911cba4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

apps/web/src/components/ThreadTerminalDrawer.tsxline 949Outdated · f1c443eOpen thread
……@@ -940,16 +945,16 @@ export function TerminalViewport({
942947
943- if (previous.version === 0 && autoFocus) {
948+ if (previous.version === 0 && shouldAutoFocus) {
944949 window.requestAnimationFrame(() => {
MmacroscopeappBoton line 949Aug 27, 2026

🟡 Medium components/ThreadTerminalDrawer.tsx:949

Disabling terminalAutoFocus does not prevent a queued terminal.focus() callback from moving focus into the terminal. Both the setup-time callback and the first-session-data callback check shouldAutoFocus only when scheduling and are never cancelled; retain their frame handles and cancel them in cleanup, or re-check the current preference in the callback.

✅ Resolved in 5823e3ed8e0e11c21d61f8b4d27de1a60d36c919

Fixed in 5823e3e. Focus grants now go through an effect event (focusTerminalIfPermitted) that re-checks permission at frame time, so a queued callback can no longer act on a stale grant; the setup-time frame is additionally cancelled via setupCleanups. The first-output frame deliberately has no cancel-on-cleanup: that effect re-runs on every buffer update, and cancelling there would drop a legitimate first-output focus when a second write lands within the same frame - the frame-time check covers the staleness instead.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

MmacroscopeappBotreviewed · 1 inline threadAug 27, 2026
Synced from GitHub

One finding: gating the focusRequestId effect on the new setting also suppresses focus for explicit user actions (new/split/close terminal, terminal tab activation), which goes beyond the setting's documented scope and can leave keyboard focus on nothing. Details inline.

Posted via Macroscope — UI Consistency

apps/web/src/components/ThreadTerminalDrawer.tsxlines 957–957Outdated · f1c443eOpen thread
……@@ -940,16 +945,16 @@ export function TerminalViewport({
950955
951956 useEffect(() => {
952- if (!autoFocus) return;
957+ if (!shouldAutoFocus) return;
MmacroscopeappBoton lines 957–957Aug 27, 2026

components/ThreadTerminalDrawer.tsx:957

focusRequestId is not only an "automatic" focus channel: ChatView bumps it for explicit user actions — activateTerminal / activatePanelTerminal (terminal tab click), createNewTerminal, splitTerminal, closeTerminal — in addition to the drawer becoming visible. Gating this effect on terminalAutoFocus therefore also drops focus for those deliberate interactions, which is wider than the setting's own description ("the terminal only takes focus when you click it").

Concretely, with the setting off and the drawer in single-terminal mode: pressing the new-terminal or close-terminal keybinding unmounts the currently focused viewport (only the active terminal renders in the non-split branch), and no replacement focus is applied, so focus falls to document.body and the user's next keystrokes go nowhere until they click the pane. Same for activating another terminal from the tab list.

Suggest keeping the explicit-request path on autoFocus and gating only the automatic paths (mount-time focus at the surface creation and the first-output focus in the effect above), and splitting the focus-request channel in ChatView so the open/reveal bumps (visible ? 1 : 0, the terminal-open effect) are gated by the setting while action-driven bumps stay ungated. Fix spans this file and ChatView.tsx, so no inline diff.

No longer relevant as of 5823e3ed8e0e11c21d61f8b4d27de1a60d36c919

Fixed in 5823e3e, following the suggested split: the focusRequestId effect is back on plain autoFocus, and the reveal sources in ChatView (the drawer-open bump and the wrapper's visible term) are the gated ones, so create/split/close/tab-activation always focus. Two holes the split alone would have left are also closed: (1) the swap-to-0 for inactive threads made the id jump on activation, which reads as an explicit request - the id is now passed unconditionally and hidden drawers bail out via visibility-gated autoFocus; (2) in the single-terminal view, create/close/activate remounts the viewport in the same render as the bump, so the new viewport's request effect can never see the change - a mount-captured pending flag carries the request across the remount and is consumed by the first focus it grants.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/ThreadTerminalDrawer.tsxline 534Outdated · f1c443eOpen thread
……@@ -526,7 +531,7 @@ export function TerminalViewport({
527532 synchronizedStatusRef.current = "closed";
528533 synchronizeTerminalStatus(terminal, latestSession.status);
529- if (autoFocus) window.requestAnimationFrame(() => terminal.focus());
534+ if (shouldAutoFocus) window.requestAnimationFrame(() => terminal.focus());
CcursorBoton line 534Aug 27, 2026

Auto-focus races with settings hydration

Medium Severity

The setup-time terminal.focus() call captures shouldAutoFocus when the effect starts, and that effect intentionally never re-runs. Client settings hydrate asynchronously from the schema default (true), the same race this file already handles for fonts with a ref. A persisted terminalAutoFocus of false is therefore ignored if Ghostty finishes loading after hydration, so the terminal still steals keyboard focus on first mount — including app restore of a thread whose terminal is already open.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit f1c443eb979955f1f0b12a84a68dcbf623fac396. Configure here.

Fixed in 5823e3e using the same live-value pattern as terminalFontRef: the focus callback is an effect event that reads the setting at frame time, so a persisted false that hydrates after mount is honored even when Ghostty finishes loading later, and mid-flight toggles win over the captured render.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This adds a persisted user-facing setting and changes production terminal focus behavior across ChatView and the terminal lifecycle, including asynchronous mount and visibility transitions. The implementation is broader than a simple settings-only change and lacks new tests for the runtime focus paths.

You can add or adjust custom eligibility rules. Learn more.

Added 1 commit

  • fix(web): honor explicit terminal focus requests when auto-focus is off5823e3e
CcursorBotreviewed · 1 inline threadAug 27, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5823e3ed8e0e11c21d61f8b4d27de1a60d36c919. Configure here.

apps/web/src/components/ChatView.tsxline 1054Outdated · 5823e3eOpen thread
……@@ -1044,7 +1045,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1051+ // ChatView-level requests) stay ungated.
1052+ focusRequestId={
1053+ focusRequestId + localFocusRequestId + (visible && terminalAutoFocus ? 1 : 0)
1054+ }
CcursorBoton line 1054Aug 27, 2026

Setting bit spoofs explicit focus requests

Medium Severity

The drawer encodes terminalAutoFocus into the composite focusRequestId, while TerminalViewport treats any id change as an explicit request and calls terminal.focus() without consulting the setting. Client settings start as the schema default (true) and hydrate asynchronously, so a stored false changes the id after the Ghostty surface exists and refocuses the terminal. That undoes auto-focus opt-out on reload for the users this setting is for.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 5823e3ed8e0e11c21d61f8b4d27de1a60d36c919. Configure here.

Fixed in c73f8be. The setting (and visibility) are no longer encoded into the composite focus-request id at all - the id now carries only explicit, action-driven requests, so a hydrating or toggling preference changes no id and cannot be spoofed into a request. Reveal focus (thread activation, drawer open) moved into the viewport as a hidden-to-visible transition effect whose grant goes through the same frame-time permission check as the other automatic paths.

Added 1 commit

  • fix(web): settings hydration no longer spoofs a terminal focus requestc73f8be
MmacroscopeappBotreviewed · 1 inline threadAug 27, 2026
Synced from GitHub

One finding on the new focus-gating logic in TerminalViewport: the mount-time focus grant (mountFocusPending) is handed to every viewport mounted on a request render, including split panes that are not the request target, and it never expires — so it can be redeemed by a later automatic reveal even when terminalAutoFocus is off. Details inline.

Separately, the focus decision is now a four-signal protocol (focusRequestId, autoFocus, visible, mountFocusPending) plus three refs, and none of it is covered by a test. This file already follows the pattern of exporting pure predicates (shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp) and unit-testing them in ThreadTerminalDrawer.test.ts; extracting the permission rule (shouldAutoFocus || (autoFocus && mountFocusPending)) and the reveal/request-change transitions into such a predicate would make the new state transitions verifiable without changing behavior.

Posted via Macroscope — UI Consistency

apps/web/src/components/ThreadTerminalDrawer.tsxlines 556–557Outdated · c73f8beOpen thread
……@@ -526,7 +553,8 @@ export function TerminalViewport({
528555 synchronizeTerminalStatus(terminal, latestSession.status);
529- if (autoFocus) window.requestAnimationFrame(() => terminal.focus());
556+ const focusFrame = window.requestAnimationFrame(() => focusTerminalIfPermitted(terminal));
557+ setupCleanups.push(() => window.cancelAnimationFrame(focusFrame));
MmacroscopeappBoton lines 556–557Aug 27, 2026

components/ThreadTerminalDrawer.tsx:557

The mount-time grant outlives the mount it was captured for. mountFocusPendingRef is only cleared when it actually grants a focus, and mountFocusPending is passed to every viewport mounted on a request render — including split panes that are not the request target (autoFocus={visible && terminalId === resolvedActiveTerminalId}). Those panes keep mountFocusPendingRef.current === true indefinitely, so a later automatic path (the visible reveal effect or the first-output frame, once that pane becomes the active one without a new request — e.g. the active pane's session exits while the drawer is hidden, so bumpFocusRequestId is skipped) will call terminal.focus() even with terminalAutoFocus off, which is exactly what the setting promises not to do.

Since the grant exists only to cover "surface not ready yet at mount", it can be retired as soon as the mount-time focus attempt runs:

suggestion
1 const focusFrame = window.requestAnimationFrame(() => {
2 focusTerminalIfPermitted(terminal);
3 // The mount-time grant covers only this first ready frame; keeping it
4 // alive would let a later automatic reveal spend it with the setting off.
5 mountFocusPendingRef.current = false;
6 });
7 setupCleanups.push(() => window.cancelAnimationFrame(focusFrame));

No longer relevant as of 8f49ff3bb13fbadf0ed7f307c0b6231cc81df62d

Fixed in 8f49ff3, applying the suggestion as-is. You are right that the grant's lifetime was wrong: it exists only to cover "surface not ready at mount", so it is now retired at the first ready frame whether or not it granted - a non-target split pane forfeits it there (autoFocus false), and no later reveal can spend it with the setting off. The first-output path does not need the grant: the surface-ready frame fires before output and already handles the mount-request case.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Added 1 commit

  • fix(web): retire the mount-time focus grant at the first ready frame8f49ff3

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

We're closing this PR as we clean up the T3 Code backlog. Thank you for taking the time to put this together.

We are not adding another setting for terminal focus behavior. The current focus path works, and this preference is not a priority for the active queue.

If you believe we closed this in error, please reopen the PR and leave a comment explaining what we missed.