From f1c443eb979955f1f0b12a84a68dcbf623fac396 Mon Sep 17 00:00:00 2001 From: Darahaas Yajamanyam Date: Thu, 27 Aug 2026 13:35:05 +0530 Subject: [PATCH 1/4] feat(web): setting to keep the terminal from taking focus automatically --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/components/ThreadTerminalDrawer.tsx | 17 +++++++---- .../components/settings/SettingsPanels.tsx | 29 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 5 ++++ packages/contracts/src/settings.ts | 6 ++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa4..0f305e27f05f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -48,6 +48,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + terminalAutoFocus: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index abd9bf9edfd5..6d964b3c2148 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -402,6 +402,11 @@ export function TerminalViewport({ terminal: settings.fontSizeTerminal, }), ); + const terminalAutoFocus = useClientSettings((settings) => settings.terminalAutoFocus); + // Every automatic focus path gates on this; a click still focuses the + // terminal directly, so turning the setting off keeps focus wherever the + // user left it (usually the composer, so thread shortcuts keep working). + const shouldAutoFocus = autoFocus && terminalAutoFocus; const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, @@ -526,7 +531,7 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); + if (shouldAutoFocus) window.requestAnimationFrame(() => terminal.focus()); const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -903,7 +908,7 @@ export function TerminalViewport({ cancelled = true; teardown?.(); }; - // autoFocus is intentionally omitted; + // shouldAutoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); @@ -940,16 +945,16 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && autoFocus) { + if (previous.version === 0 && shouldAutoFocus) { window.requestAnimationFrame(() => { terminal.focus(); }); } previousSessionRef.current = current; - }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + }, [shouldAutoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); useEffect(() => { - if (!autoFocus) return; + if (!shouldAutoFocus) return; const terminal = terminalRef.current; if (!terminal) return; const frame = window.requestAnimationFrame(() => { @@ -958,7 +963,7 @@ export function TerminalViewport({ return () => { window.cancelAnimationFrame(frame); }; - }, [autoFocus, focusRequestId]); + }, [shouldAutoFocus, focusRequestId]); useEffect(() => { const terminal = terminalRef.current; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..76489767754b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -529,6 +529,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), + ...(settings.terminalAutoFocus !== DEFAULT_UNIFIED_SETTINGS.terminalAutoFocus + ? ["Terminal auto-focus"] + : []), ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? ["Archive confirmation"] : []), @@ -557,6 +560,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.terminalAutoFocus, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, @@ -668,6 +672,7 @@ export function useSettingsRestore(onRestored?: () => void) { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + terminalAutoFocus: DEFAULT_UNIFIED_SETTINGS.terminalAutoFocus, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, @@ -2320,6 +2325,30 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + terminalAutoFocus: DEFAULT_UNIFIED_SETTINGS.terminalAutoFocus, + }) + } + /> + ) : null + } + control={ + updateSettings({ terminalAutoFocus: Boolean(checked) })} + aria-label="Terminal auto-focus" + /> + } + /> + Date: Thu, 27 Aug 2026 14:37:38 +0530 Subject: [PATCH 2/4] fix(web): honor explicit terminal focus requests when auto-focus is off --- apps/web/src/components/ChatView.tsx | 25 +++++-- .../src/components/ThreadTerminalDrawer.tsx | 72 +++++++++++++++---- .../components/settings/SettingsPanels.tsx | 2 +- packages/contracts/src/settings.ts | 3 +- 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..439b2ae7019d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -727,6 +727,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { + const terminalAutoFocus = useClientSettings((settings) => settings.terminalAutoFocus); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -1044,7 +1045,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra activeTerminalId={terminalUiState.activeTerminalId} terminalGroups={terminalUiState.terminalGroups} activeTerminalGroupId={terminalUiState.activeTerminalGroupId} - focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} + // The visible term turns thread activation and drawer reveal into a + // focus request; those are automatic reveals, so the terminalAutoFocus + // setting gates them. Action-driven bumps (localFocusRequestId and the + // ChatView-level requests) stay ungated. + focusRequestId={ + focusRequestId + localFocusRequestId + (visible && terminalAutoFocus ? 1 : 0) + } onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} @@ -1464,6 +1471,7 @@ function ChatViewContent(props: ChatViewProps) { useState>({}); const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const terminalAutoFocus = useClientSettings((settings) => settings.terminalAutoFocus); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = @@ -5059,7 +5067,12 @@ function ChatViewContent(props: ChatViewProps) { if (!previous && current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; - setTerminalFocusRequestId((value) => value + 1); + // Opening the drawer is an automatic reveal, so the terminalAutoFocus + // setting gates its focus request. Closing always returns focus to the + // composer below, setting or not. + if (terminalAutoFocus) { + setTerminalFocusRequestId((value) => value + 1); + } return; } else if (previous && !current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; @@ -5072,7 +5085,7 @@ function ChatViewContent(props: ChatViewProps) { } terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + }, [activeThreadKey, focusComposer, terminalAutoFocus, terminalUiState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -7226,7 +7239,11 @@ function ChatViewContent(props: ChatViewProps) { launchContext={ mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null } - focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0} + // Passed unconditionally: swapping to 0 for inactive threads made + // the id jump on activation, which reads as a focus request. Hidden + // drawers cannot act on requests anyway - their viewports render + // with autoFocus false. + focusRequestId={terminalFocusRequestId} splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6d964b3c2148..e8cea27e418e 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -324,6 +324,7 @@ interface TerminalViewportProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; + mountFocusPending: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; @@ -348,6 +349,7 @@ export function TerminalViewport({ onAddTerminalContext, focusRequestId, autoFocus, + mountFocusPending, resizeEpoch, drawerHeight, keybindings, @@ -403,10 +405,27 @@ export function TerminalViewport({ }), ); const terminalAutoFocus = useClientSettings((settings) => settings.terminalAutoFocus); - // Every automatic focus path gates on this; a click still focuses the - // terminal directly, so turning the setting off keeps focus wherever the - // user left it (usually the composer, so thread shortcuts keep working). + // Automatic focus (surface ready, first output) honors the terminalAutoFocus + // setting; explicit focus requests honor only autoFocus, so terminal actions + // keep working with the setting off. A click still focuses the terminal + // directly either way. const shouldAutoFocus = autoFocus && terminalAutoFocus; + // Captured once at mount and consumed by the first focus it grants: a + // viewport mounted by the same render as an explicit focus request (the + // single-terminal view remounts on create/close/activate) must focus once + // ready even when terminalAutoFocus is off, otherwise focus falls to + // document.body when the previously focused viewport unmounts. + const mountFocusPendingRef = useRef(mountFocusPending); + const latestFocusRequestIdRef = useRef(focusRequestId); + // Effect event so queued focus callbacks read the live values at frame + // time: settings hydrate asynchronously after mount, and a toggle between + // scheduling and firing must win over the captured render. + const focusTerminalIfPermitted = useEffectEvent((terminal: GhosttyTerminalSurface) => { + if (shouldAutoFocus || (autoFocus && mountFocusPendingRef.current)) { + terminal.focus(); + mountFocusPendingRef.current = false; + } + }); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, @@ -531,7 +550,8 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - if (shouldAutoFocus) window.requestAnimationFrame(() => terminal.focus()); + const focusFrame = window.requestAnimationFrame(() => focusTerminalIfPermitted(terminal)); + setupCleanups.push(() => window.cancelAnimationFrame(focusFrame)); const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -908,8 +928,9 @@ export function TerminalViewport({ cancelled = true; teardown?.(); }; - // shouldAutoFocus is intentionally omitted; - // it is only read at mount time and must not trigger terminal teardown/recreation. + // Focus permission is intentionally not a dependency: it is checked at + // frame time inside focusTerminalIfPermitted, and changing it must not + // trigger terminal teardown/recreation. }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { @@ -945,16 +966,29 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && shouldAutoFocus) { + if (previous.version === 0) { + // No cancel on cleanup here: this effect re-runs on every buffer + // update, and cancelling would drop a legitimate first-output focus + // when a second write lands within the same frame. The frame-time + // permission check makes a stale grant impossible instead. window.requestAnimationFrame(() => { - terminal.focus(); + focusTerminalIfPermitted(terminal); }); } previousSessionRef.current = current; - }, [shouldAutoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + }, [terminalBuffer, terminalError, terminalStatus, terminalVersion]); useEffect(() => { - if (!shouldAutoFocus) return; + const previous = latestFocusRequestIdRef.current; + latestFocusRequestIdRef.current = focusRequestId; + // Explicit focus requests (terminal create/split/close/tab activation) + // are honored regardless of the terminalAutoFocus setting: suppressing + // them would drop keyboard focus on document.body when the previously + // focused viewport unmounts. Only an actual request may focus, so runs + // without a changed id (mount, autoFocus flips on reveal) bail out; + // those transitions belong to the gated automatic paths. + if (focusRequestId === previous) return; + if (!autoFocus) return; const terminal = terminalRef.current; if (!terminal) return; const frame = window.requestAnimationFrame(() => { @@ -963,7 +997,7 @@ export function TerminalViewport({ return () => { window.cancelAnimationFrame(frame); }; - }, [shouldAutoFocus, focusRequestId]); + }, [autoFocus, focusRequestId]); useEffect(() => { const terminal = terminalRef.current; @@ -1080,6 +1114,16 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + // True exactly on renders carrying a new focus request. A viewport mounted + // by such a render (the single-terminal view remounts on create/close/ + // activate) captures it so the request survives the remount: the new + // viewport's focusRequestId effect cannot act on the change because its + // terminal surface is not ready yet. + const previousFocusRequestIdRef = useRef(focusRequestId); + const mountFocusPending = focusRequestId !== previousFocusRequestIdRef.current; + useEffect(() => { + previousFocusRequestIdRef.current = focusRequestId; + }, [focusRequestId]); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1546,7 +1590,8 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} - autoFocus={terminalId === resolvedActiveTerminalId} + autoFocus={visible && terminalId === resolvedActiveTerminalId} + mountFocusPending={mountFocusPending} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} keybindings={keybindings} @@ -1575,7 +1620,8 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} - autoFocus + autoFocus={visible} + mountFocusPending={mountFocusPending} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} keybindings={keybindings} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 76489767754b..802e8bb438fd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2327,7 +2327,7 @@ export function GeneralSettingsPanel() { Date: Thu, 27 Aug 2026 15:25:10 +0530 Subject: [PATCH 3/4] fix(web): settings hydration no longer spoofs a terminal focus request --- apps/web/src/components/ChatView.tsx | 15 ++++++------ .../src/components/ThreadTerminalDrawer.tsx | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 439b2ae7019d..646fbba51976 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -727,7 +727,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const terminalAutoFocus = useClientSettings((settings) => settings.terminalAutoFocus); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -1045,13 +1044,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra activeTerminalId={terminalUiState.activeTerminalId} terminalGroups={terminalUiState.terminalGroups} activeTerminalGroupId={terminalUiState.activeTerminalGroupId} - // The visible term turns thread activation and drawer reveal into a - // focus request; those are automatic reveals, so the terminalAutoFocus - // setting gates them. Action-driven bumps (localFocusRequestId and the - // ChatView-level requests) stay ungated. - focusRequestId={ - focusRequestId + localFocusRequestId + (visible && terminalAutoFocus ? 1 : 0) - } + // Carries only explicit, action-driven focus requests. Reveal focus + // (thread activation, drawer open) is the viewport's own visible + // transition, checked against the terminalAutoFocus setting at frame + // time - encoding visibility or the setting into this id would turn + // their transitions (including async settings hydration) into spoofed + // requests. + focusRequestId={focusRequestId + localFocusRequestId} onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index e8cea27e418e..3963a242b893 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -324,6 +324,7 @@ interface TerminalViewportProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; + visible: boolean; mountFocusPending: boolean; resizeEpoch: number; drawerHeight: number; @@ -349,6 +350,7 @@ export function TerminalViewport({ onAddTerminalContext, focusRequestId, autoFocus, + visible, mountFocusPending, resizeEpoch, drawerHeight, @@ -417,6 +419,7 @@ export function TerminalViewport({ // document.body when the previously focused viewport unmounts. const mountFocusPendingRef = useRef(mountFocusPending); const latestFocusRequestIdRef = useRef(focusRequestId); + const previousVisibleRef = useRef(visible); // Effect event so queued focus callbacks read the live values at frame // time: settings hydrate asynchronously after mount, and a toggle between // scheduling and firing must win over the captured render. @@ -999,6 +1002,24 @@ export function TerminalViewport({ }; }, [autoFocus, focusRequestId]); + useEffect(() => { + const wasVisible = previousVisibleRef.current; + previousVisibleRef.current = visible; + // Reveal focus (thread activation, drawer open) is the automatic channel: + // it acts only on a hidden-to-visible transition, and the frame-time + // permission check honors the terminalAutoFocus setting. Mount-time focus + // belongs to the surface setup path (the terminal is not ready here yet). + if (wasVisible || !visible) return; + const terminal = terminalRef.current; + if (!terminal) return; + const frame = window.requestAnimationFrame(() => { + focusTerminalIfPermitted(terminal); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [visible]); + useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; @@ -1591,6 +1612,7 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus={visible && terminalId === resolvedActiveTerminalId} + visible={visible} mountFocusPending={mountFocusPending} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} @@ -1621,6 +1643,7 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus={visible} + visible={visible} mountFocusPending={mountFocusPending} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} From 8f49ff3bb13fbadf0ed7f307c0b6231cc81df62d Mon Sep 17 00:00:00 2001 From: Darahaas Yajamanyam Date: Thu, 27 Aug 2026 15:33:11 +0530 Subject: [PATCH 4/4] fix(web): retire the mount-time focus grant at the first ready frame --- apps/web/src/components/ThreadTerminalDrawer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 3963a242b893..e7eeed5fe991 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -553,7 +553,12 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - const focusFrame = window.requestAnimationFrame(() => focusTerminalIfPermitted(terminal)); + const focusFrame = window.requestAnimationFrame(() => { + focusTerminalIfPermitted(terminal); + // The mount-time grant covers only this first ready frame; keeping it + // alive would let a later automatic reveal spend it with the setting off. + mountFocusPendingRef.current = false; + }); setupCleanups.push(() => window.cancelAnimationFrame(focusFrame)); const clearSelectionAction = () => {