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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..646fbba51976 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1044,7 +1044,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra activeTerminalId={terminalUiState.activeTerminalId} terminalGroups={terminalUiState.terminalGroups} activeTerminalGroupId={terminalUiState.activeTerminalGroupId} - focusRequestId={focusRequestId + localFocusRequestId + (visible ? 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} @@ -1464,6 +1470,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 +5066,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 +5084,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 +7238,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 abd9bf9edfd5..e7eeed5fe991 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -324,6 +324,8 @@ interface TerminalViewportProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; + visible: boolean; + mountFocusPending: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; @@ -348,6 +350,8 @@ export function TerminalViewport({ onAddTerminalContext, focusRequestId, autoFocus, + visible, + mountFocusPending, resizeEpoch, drawerHeight, keybindings, @@ -402,6 +406,29 @@ export function TerminalViewport({ terminal: settings.fontSizeTerminal, }), ); + const terminalAutoFocus = useClientSettings((settings) => settings.terminalAutoFocus); + // 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); + 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. + 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, @@ -526,7 +553,13 @@ 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()); + 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 = () => { selectionActionRequestIdRef.current += 1; @@ -903,8 +936,9 @@ export function TerminalViewport({ cancelled = true; teardown?.(); }; - // autoFocus 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(() => { @@ -940,15 +974,28 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && autoFocus) { + 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; - }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + }, [terminalBuffer, terminalError, terminalStatus, terminalVersion]); useEffect(() => { + 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; @@ -960,6 +1007,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; @@ -1075,6 +1140,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, @@ -1541,7 +1616,9 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} - autoFocus={terminalId === resolvedActiveTerminalId} + autoFocus={visible && terminalId === resolvedActiveTerminalId} + visible={visible} + mountFocusPending={mountFocusPending} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} keybindings={keybindings} @@ -1570,7 +1647,9 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} - autoFocus + autoFocus={visible} + visible={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 e77c05549265..802e8bb438fd 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" + /> + } + /> +