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);
});
});
});