From ca6786a28bdcc1f452352acb19cfa53542059b55 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Mon, 12 Jan 2026 15:30:12 -0800 Subject: [PATCH] feat(ui): use Tab to switch focus between shell and input (#14332) --- docs/cli/keyboard-shortcuts.md | 26 +++---- docs/tools/shell.md | 2 +- packages/cli/src/config/keyBindings.ts | 17 ++-- packages/cli/src/ui/AppContainer.tsx | 74 +++++++++++++----- .../src/ui/components/InputPrompt.test.tsx | 78 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 12 ++- .../components/messages/ShellToolMessage.tsx | 2 +- .../ui/components/messages/ToolMessage.tsx | 2 +- .../src/ui/hooks/useAutoAcceptIndicator.ts | 4 +- packages/cli/src/ui/hooks/usePhraseCycler.ts | 2 +- packages/cli/src/ui/keyMatchers.test.ts | 75 +----------------- 11 files changed, 180 insertions(+), 114 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 56f19a45a0..e6960bcde5 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -91,17 +91,18 @@ available combinations. #### App Controls -| Action | Keys | -| ----------------------------------------------------------------- | ------------- | -| Toggle detailed error information. | `F12` | -| Toggle the full TODO list. | `Ctrl + T` | -| Toggle IDE context details. | `Ctrl + G` | -| Toggle Markdown rendering. | `Cmd + M` | -| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` | -| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | -| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` | -| Expand a height-constrained response to show additional lines. | `Ctrl + S` | -| Toggle focus between the shell and Gemini input. | `Ctrl + F` | +| Action | Keys | +| ----------------------------------------------------------------- | ----------------------------------- | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl + T` | +| Toggle IDE context details. | `Ctrl + G` | +| Toggle Markdown rendering. | `Cmd + M` | +| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` | +| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | +| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` | +| Expand a height-constrained response to show additional lines. | `Ctrl + S` | +| Toggle focus between the shell and Gemini input. | `Tab (no Shift)` | +| Toggle focus out of the interactive shell and into Gemini input. | `Tab (no Shift)`
`Shift + Tab` | #### Session Control @@ -122,8 +123,7 @@ available combinations. - `Ctrl+Delete` / `Meta+Delete`: Delete the word to the right of the cursor. - `Ctrl+B` or `Left Arrow`: Move the cursor one character to the left while editing text. -- `Ctrl+F` or `Right Arrow`: Move the cursor one character to the right; with an - embedded shell attached, `Ctrl+F` still toggles focus. +- `Ctrl+F` or `Right Arrow`: Move the cursor one character to the right. - `Ctrl+D` or `Delete`: Remove the character immediately to the right of the cursor. - `Ctrl+H` or `Backspace`: Remove the character immediately to the left of the diff --git a/docs/tools/shell.md b/docs/tools/shell.md index b179b6ce7f..0bb4b68244 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -135,7 +135,7 @@ user input, such as text editors (`vim`, `nano`), terminal-based UIs (`htop`), and interactive version control operations (`git rebase -i`). When an interactive command is running, you can send input to it from the Gemini -CLI. To focus on the interactive shell, press `ctrl+f`. The terminal output, +CLI. To focus on the interactive shell, press `Tab`. The terminal output, including complex TUIs, will be rendered correctly. ## Important notes diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index a919da1aff..06819e382a 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -72,7 +72,8 @@ export enum Command { REVERSE_SEARCH = 'reverseSearch', SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', - TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus', + TOGGLE_SHELL_INPUT_FOCUS_IN = 'toggleShellInputFocus', + TOGGLE_SHELL_INPUT_FOCUS_OUT = 'toggleShellInputFocusOut', // Suggestion expansion EXPAND_SUGGESTION = 'expandSuggestion', @@ -216,8 +217,11 @@ export const defaultKeyBindings: KeyBindingConfig = { // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], - [Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }], - + [Command.TOGGLE_SHELL_INPUT_FOCUS_IN]: [{ key: 'tab', shift: false }], + [Command.TOGGLE_SHELL_INPUT_FOCUS_OUT]: [ + { key: 'tab', shift: false }, + { key: 'tab', shift: true }, + ], // Suggestion expansion [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], @@ -312,7 +316,8 @@ export const commandCategories: readonly CommandCategory[] = [ Command.TOGGLE_YOLO, Command.TOGGLE_AUTO_EDIT, Command.SHOW_MORE_LINES, - Command.TOGGLE_SHELL_INPUT_FOCUS, + Command.TOGGLE_SHELL_INPUT_FOCUS_IN, + Command.TOGGLE_SHELL_INPUT_FOCUS_OUT, ], }, { @@ -370,8 +375,10 @@ export const commandDescriptions: Readonly> = { [Command.SUBMIT_REVERSE_SEARCH]: 'Insert the selected reverse-search match.', [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: 'Accept a suggestion while reverse searching.', - [Command.TOGGLE_SHELL_INPUT_FOCUS]: + [Command.TOGGLE_SHELL_INPUT_FOCUS_IN]: 'Toggle focus between the shell and Gemini input.', + [Command.TOGGLE_SHELL_INPUT_FOCUS_OUT]: + 'Toggle focus out of the interactive shell and into Gemini input.', [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e98a6476a2..93fef00dbb 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -823,11 +823,17 @@ Logging in with Google... Restarting Gemini CLI to continue. embeddedShellFocused, ); + const lastOutputTimeRef = useRef(0); + useEffect(() => { + lastOutputTimeRef.current = lastOutputTime; + }, [lastOutputTime]); + // Auto-accept indicator const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, addItem: historyManager.addItem, onApprovalModeChange: handleApprovalModeChange, + isActive: !embeddedShellFocused, }); const { @@ -1053,19 +1059,20 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); + const warningTimeoutRef = useRef(null); + const tabFocusTimeoutRef = useRef(null); + + const handleWarning = useCallback((message: string) => { + setWarningMessage(message); + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + } + warningTimeoutRef.current = setTimeout(() => { + setWarningMessage(null); + }, WARNING_PROMPT_DURATION_MS); + }, []); + useEffect(() => { - let timeoutId: NodeJS.Timeout; - - const handleWarning = (message: string) => { - setWarningMessage(message); - if (timeoutId) { - clearTimeout(timeoutId); - } - timeoutId = setTimeout(() => { - setWarningMessage(null); - }, WARNING_PROMPT_DURATION_MS); - }; - const handleSelectionWarning = () => { handleWarning('Press Ctrl-S to enter selection mode to copy text.'); }; @@ -1077,11 +1084,14 @@ Logging in with Google... Restarting Gemini CLI to continue. return () => { appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); - if (timeoutId) { - clearTimeout(timeoutId); + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + } + if (tabFocusTimeoutRef.current) { + clearTimeout(tabFocusTimeoutRef.current); } }; - }, []); + }, [handleWarning]); useEffect(() => { if (ideNeedsRestart) { @@ -1269,10 +1279,37 @@ Logging in with Google... Restarting Gemini CLI to continue. !enteringConstrainHeightMode ) { setConstrainHeight(false); - } else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { - if (activePtyId || embeddedShellFocused) { - setEmbeddedShellFocused((prev) => !prev); + } else if ( + keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS_OUT](key) && + activePtyId && + embeddedShellFocused + ) { + if (key.name === 'tab' && key.shift) { + // Always change focus + setEmbeddedShellFocused(false); + return; } + + const now = Date.now(); + // If the shell hasn't produced output in the last 100ms, it's considered idle. + const isIdle = now - lastOutputTimeRef.current >= 100; + if (isIdle) { + if (tabFocusTimeoutRef.current) { + clearTimeout(tabFocusTimeoutRef.current); + } + tabFocusTimeoutRef.current = setTimeout(() => { + tabFocusTimeoutRef.current = null; + // If the shell produced output since the tab press, we assume it handled the tab + // (e.g. autocomplete) so we should not toggle focus. + if (lastOutputTimeRef.current > now) { + handleWarning('Press Shift+Tab to focus out.'); + return; + } + setEmbeddedShellFocused(false); + }, 100); + return; + } + handleWarning('Press Shift+Tab to focus out.'); } }, [ @@ -1293,6 +1330,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setCopyModeEnabled, copyModeEnabled, isAlternateBuffer, + handleWarning, ], ); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 7318d2119c..ee194dc3cb 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2418,6 +2418,84 @@ describe('InputPrompt', () => { }); }); + describe('Tab focus toggle', () => { + it.each([ + { + name: 'should toggle focus in on Tab when no suggestions or ghost text', + showSuggestions: false, + ghostText: '', + suggestions: [], + expectedFocusToggle: true, + }, + { + name: 'should accept ghost text and NOT toggle focus on Tab', + showSuggestions: false, + ghostText: 'ghost text', + suggestions: [], + expectedFocusToggle: false, + expectedAcceptCall: true, + }, + { + name: 'should NOT toggle focus on Tab when suggestions are present', + showSuggestions: true, + ghostText: '', + suggestions: [{ label: 'test', value: 'test' }], + expectedFocusToggle: false, + }, + ])( + '$name', + async ({ + showSuggestions, + ghostText, + suggestions, + expectedFocusToggle, + expectedAcceptCall, + }) => { + const mockAccept = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions, + suggestions, + promptCompletion: { + text: ghostText, + accept: mockAccept, + clear: vi.fn(), + isLoading: false, + isActive: ghostText !== '', + markSelected: vi.fn(), + }, + }); + + const { stdin, unmount } = renderWithProviders( + , + { + uiActions, + uiState: { activePtyId: 1 }, + }, + ); + + await act(async () => { + stdin.write('\t'); + }); + + await waitFor(() => { + if (expectedFocusToggle) { + expect(uiActions.setEmbeddedShellFocused).toHaveBeenCalledWith( + true, + ); + } else { + expect(uiActions.setEmbeddedShellFocused).not.toHaveBeenCalled(); + } + + if (expectedAcceptCall) { + expect(mockAccept).toHaveBeenCalled(); + } + }); + unmount(); + }, + ); + }); + describe('mouse interaction', () => { it.each([ { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 78031a8a9e..239e192fec 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -135,7 +135,7 @@ export const InputPrompt: React.FC = ({ const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); const { setEmbeddedShellFocused } = useUIActions(); - const { mainAreaWidth } = useUIState(); + const { mainAreaWidth, activePtyId } = useUIState(); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const escPressCount = useRef(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); @@ -829,6 +829,14 @@ export const InputPrompt: React.FC = ({ return; } + if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS_IN](key)) { + // If we got here, Autocomplete didn't handle the key (e.g. no suggestions). + if (activePtyId) { + setEmbeddedShellFocused(true); + } + return; + } + // Fall back to the text buffer's default input handling for all other keys buffer.handleInput(key); @@ -870,6 +878,8 @@ export const InputPrompt: React.FC = ({ kittyProtocol.enabled, tryLoadQueuedMessages, setBannerVisible, + activePtyId, + setEmbeddedShellFocused, ], ); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index a9197f15c5..b5be480279 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -140,7 +140,7 @@ export const ShellToolMessage: React.FC = ({ {shouldShowFocusHint && ( - {isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'} + {isThisShellFocused ? '(Focused)' : '(tab to focus)'} )} diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 86ad6968d8..f8180f92c5 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -112,7 +112,7 @@ export const ToolMessage: React.FC = ({ {shouldShowFocusHint && ( - {isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'} + {isThisShellFocused ? '(Focused)' : '(tab to focus)'} )} diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index 282ac3ea7d..aca0de57df 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -15,12 +15,14 @@ export interface UseAutoAcceptIndicatorArgs { config: Config; addItem?: (item: HistoryItemWithoutId, timestamp: number) => void; onApprovalModeChange?: (mode: ApprovalMode) => void; + isActive?: boolean; } export function useAutoAcceptIndicator({ config, addItem, onApprovalModeChange, + isActive = true, }: UseAutoAcceptIndicatorArgs): ApprovalMode { const currentConfigValue = config.getApprovalMode(); const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] = @@ -82,7 +84,7 @@ export function useAutoAcceptIndicator({ } } }, - { isActive: true }, + { isActive }, ); return showAutoAcceptIndicator; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 969fe47135..86a7292152 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -12,7 +12,7 @@ import { useInactivityTimer } from './useInactivityTimer.js'; export const PHRASE_CHANGE_INTERVAL_MS = 15000; export const INTERACTIVE_SHELL_WAITING_PHRASE = - 'Interactive shell awaiting input... press Ctrl+f to focus shell'; + 'Interactive shell awaiting input... press tab to focus shell'; /** * Custom hook to manage cycling through loading phrases. diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index b3ed875e6e..8ddfd0371d 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -22,67 +22,6 @@ describe('keyMatchers', () => { ...mods, }); - // Original hard-coded logic (for comparison) - const originalMatchers: Record boolean> = { - [Command.RETURN]: (key: Key) => key.name === 'return', - [Command.HOME]: (key: Key) => key.ctrl && key.name === 'a', - [Command.END]: (key: Key) => key.ctrl && key.name === 'e', - [Command.KILL_LINE_RIGHT]: (key: Key) => key.ctrl && key.name === 'k', - [Command.KILL_LINE_LEFT]: (key: Key) => key.ctrl && key.name === 'u', - [Command.CLEAR_INPUT]: (key: Key) => key.ctrl && key.name === 'c', - [Command.DELETE_WORD_BACKWARD]: (key: Key) => - (key.ctrl || key.meta) && key.name === 'backspace', - [Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l', - [Command.SCROLL_UP]: (key: Key) => key.name === 'up' && !!key.shift, - [Command.SCROLL_DOWN]: (key: Key) => key.name === 'down' && !!key.shift, - [Command.SCROLL_HOME]: (key: Key) => key.name === 'home', - [Command.SCROLL_END]: (key: Key) => key.name === 'end', - [Command.PAGE_UP]: (key: Key) => key.name === 'pageup', - [Command.PAGE_DOWN]: (key: Key) => key.name === 'pagedown', - [Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p', - [Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n', - [Command.NAVIGATION_UP]: (key: Key) => key.name === 'up', - [Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down', - [Command.DIALOG_NAVIGATION_UP]: (key: Key) => - !key.shift && (key.name === 'up' || key.name === 'k'), - [Command.DIALOG_NAVIGATION_DOWN]: (key: Key) => - !key.shift && (key.name === 'down' || key.name === 'j'), - [Command.ACCEPT_SUGGESTION]: (key: Key) => - key.name === 'tab' || (key.name === 'return' && !key.ctrl), - [Command.COMPLETION_UP]: (key: Key) => - key.name === 'up' || (key.ctrl && key.name === 'p'), - [Command.COMPLETION_DOWN]: (key: Key) => - key.name === 'down' || (key.ctrl && key.name === 'n'), - [Command.ESCAPE]: (key: Key) => key.name === 'escape', - [Command.SUBMIT]: (key: Key) => - key.name === 'return' && !key.ctrl && !key.meta && !key.paste, - [Command.NEWLINE]: (key: Key) => - key.name === 'return' && (key.ctrl || key.meta || key.paste), - [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) => - key.ctrl && (key.name === 'x' || key.sequence === '\x18'), - [Command.PASTE_CLIPBOARD]: (key: Key) => key.ctrl && key.name === 'v', - [Command.SHOW_ERROR_DETAILS]: (key: Key) => key.name === 'f12', - [Command.SHOW_FULL_TODOS]: (key: Key) => key.ctrl && key.name === 't', - [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => - key.ctrl && key.name === 'g', - [Command.TOGGLE_MARKDOWN]: (key: Key) => key.meta && key.name === 'm', - [Command.TOGGLE_COPY_MODE]: (key: Key) => key.ctrl && key.name === 's', - [Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c', - [Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd', - [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', - [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', - [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => - key.name === 'return' && !key.ctrl, - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: (key: Key) => - key.name === 'tab', - [Command.TOGGLE_SHELL_INPUT_FOCUS]: (key: Key) => - key.ctrl && key.name === 'f', - [Command.TOGGLE_YOLO]: (key: Key) => key.ctrl && key.name === 'y', - [Command.TOGGLE_AUTO_EDIT]: (key: Key) => key.shift && key.name === 'tab', - [Command.EXPAND_SUGGESTION]: (key: Key) => key.name === 'right', - [Command.COLLAPSE_SUGGESTION]: (key: Key) => key.name === 'left', - }; - // Test data for each command with positive and negative test cases const testCases = [ // Basic bindings @@ -334,9 +273,9 @@ describe('keyMatchers', () => { negative: [createKey('return'), createKey('space')], }, { - command: Command.TOGGLE_SHELL_INPUT_FOCUS, - positive: [createKey('f', { ctrl: true })], - negative: [createKey('f')], + command: Command.TOGGLE_SHELL_INPUT_FOCUS_IN, + positive: [createKey('tab')], + negative: [createKey('f', { ctrl: true }), createKey('f')], }, { command: Command.TOGGLE_YOLO, @@ -358,10 +297,6 @@ describe('keyMatchers', () => { keyMatchers[command](key), `Expected ${command} to match ${JSON.stringify(key)}`, ).toBe(true); - expect( - originalMatchers[command](key), - `Original matcher should also match ${JSON.stringify(key)}`, - ).toBe(true); }); negative.forEach((key) => { @@ -369,10 +304,6 @@ describe('keyMatchers', () => { keyMatchers[command](key), `Expected ${command} to NOT match ${JSON.stringify(key)}`, ).toBe(false); - expect( - originalMatchers[command](key), - `Original matcher should also NOT match ${JSON.stringify(key)}`, - ).toBe(false); }); }); });