Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
legacySidebarEnabled: false,
terminalAutoFocus: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
24 changes: 20 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -1464,6 +1470,7 @@ function ChatViewContent(props: ChatViewProps) {
useState<Record<string, number>>({});
const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
const terminalAutoFocus = useClientSettings((settings) => settings.terminalAutoFocus);
const [pullRequestDialogState, setPullRequestDialogState] =
useState<PullRequestDialogState | null>(null);
const [terminalUiLaunchContext, setTerminalUiLaunchContext] =
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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}
Expand Down
95 changes: 87 additions & 8 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ interface TerminalViewportProps {
onAddTerminalContext: (selection: TerminalContextSelection) => void;
focusRequestId: number;
autoFocus: boolean;
visible: boolean;
mountFocusPending: boolean;
resizeEpoch: number;
drawerHeight: number;
keybindings: ResolvedKeybindingsConfig;
Expand All @@ -348,6 +350,8 @@ export function TerminalViewport({
onAddTerminalContext,
focusRequestId,
autoFocus,
visible,
mountFocusPending,
resizeEpoch,
drawerHeight,
keybindings,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
: []),
Expand Down Expand Up @@ -557,6 +560,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
settings.terminalAutoFocus,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.diffIgnoreWhitespace,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2320,6 +2325,30 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("terminal-auto-focus")}
description="Move focus into the terminal when it opens and when you switch to a thread whose terminal is open. When off, the terminal only takes focus when you interact with it directly, like clicking it or creating a terminal."
resetAction={
settings.terminalAutoFocus !== DEFAULT_UNIFIED_SETTINGS.terminalAutoFocus ? (
<SettingResetButton
label="terminal auto-focus"
onClick={() =>
updateSettings({
terminalAutoFocus: DEFAULT_UNIFIED_SETTINGS.terminalAutoFocus,
})
}
/>
) : null
}
control={
<Switch
checked={settings.terminalAutoFocus}
onCheckedChange={(checked) => updateSettings({ terminalAutoFocus: Boolean(checked) })}
aria-label="Terminal auto-focus"
/>
}
/>

<SettingsRow
{...searchableSetting("archive-confirmation")}
description="Require a second click on the inline archive action before a thread is archived."
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Add project starts in",
to: "/settings/general",
},
{
id: "terminal-auto-focus",
title: "Terminal auto-focus",
to: "/settings/general",
},
{
id: "archive-confirmation",
title: "Archive confirmation",
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ export const ClientSettingsSchema = Schema.Struct({
sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
),
// Whether the embedded terminal takes keyboard focus on its own: when it
// opens, and when switching to a thread whose terminal is open. Off keeps
// focus in the chat composer (thread navigation shortcuts keep working);
// direct interaction - clicking the terminal, or creating/activating one
// from its own UI - still focuses it.
terminalAutoFocus: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
timestampFormat: TimestampFormat.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
),
Expand Down Expand Up @@ -950,6 +956,7 @@ export const ClientSettingsPatch = Schema.Struct({
sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
terminalAutoFocus: Schema.optionalKey(Schema.Boolean),
timestampFormat: Schema.optionalKey(TimestampFormat),
wordWrap: Schema.optionalKey(Schema.Boolean),
});
Expand Down
Loading