diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md
index a1a28665b9..69ab0af2a1 100644
--- a/docs/cli/keyboard-shortcuts.md
+++ b/docs/cli/keyboard-shortcuts.md
@@ -106,16 +106,17 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`
`Ctrl + S` |
-| Ctrl+B | `Ctrl + B` |
-| Ctrl+L | `Ctrl + L` |
-| Ctrl+K | `Ctrl + K` |
-| Enter | `Enter` |
-| Esc | `Esc` |
-| Shift+Tab | `Shift + Tab` |
-| Tab | `Tab (no Shift)` |
-| Tab | `Tab (no Shift)` |
-| Focus the shell input from the gemini input. | `Tab (no Shift)` |
-| Focus the Gemini input from the shell input. | `Tab` |
+| Toggle current background shell visibility. | `Ctrl + B` |
+| Toggle background shell list. | `Ctrl + L` |
+| Kill the active background shell. | `Ctrl + K` |
+| Confirm selection in background shell list. | `Enter` |
+| Dismiss background shell list. | `Esc` |
+| Move focus from background shell to Gemini. | `Shift + Tab` |
+| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
+| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` |
+| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` |
+| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
+| Move focus from the shell back to Gemini. | `Shift + Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Restart the application. | `R` |
| Suspend the application (not yet implemented). | `Ctrl + Z` |
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 9b6a903a4b..994c452d99 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -80,6 +80,7 @@ export enum Command {
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
+ SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
@@ -281,6 +282,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
{ key: 'tab', shift: false },
],
+ [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
@@ -288,7 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 's', ctrl: true },
],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
- [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
+ [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
@@ -405,6 +407,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
+ Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
@@ -496,16 +499,23 @@ export const commandDescriptions: Readonly> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
- [Command.BACKGROUND_SHELL_SELECT]: 'Enter',
- [Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
- [Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
- [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
- [Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
- [Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
- [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
- [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
- [Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
- [Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
+ [Command.BACKGROUND_SHELL_SELECT]:
+ 'Confirm selection in background shell list.',
+ [Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
+ [Command.TOGGLE_BACKGROUND_SHELL]:
+ 'Toggle current background shell visibility.',
+ [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
+ [Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
+ [Command.UNFOCUS_BACKGROUND_SHELL]:
+ 'Move focus from background shell to Gemini.',
+ [Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
+ 'Move focus from background shell list to Gemini.',
+ [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
+ 'Show warning when trying to unfocus background shell via Tab.',
+ [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
+ 'Show warning when trying to unfocus shell input via Tab.',
+ [Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
+ [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 3ee4e89ea5..87888265aa 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -1940,6 +1940,160 @@ describe('AppContainer State Management', () => {
unmount();
});
});
+
+ describe('Focus Handling (Tab / Shift+Tab)', () => {
+ beforeEach(() => {
+ // Mock activePtyId to enable focus
+ mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
+ activePtyId: 1,
+ });
+ });
+
+ it('should focus shell input on Tab', async () => {
+ await setupKeypressTest();
+
+ pressKey({ name: 'tab', shift: false });
+
+ expect(capturedUIState.embeddedShellFocused).toBe(true);
+ unmount();
+ });
+
+ it('should unfocus shell input on Shift+Tab', async () => {
+ await setupKeypressTest();
+
+ // Focus first
+ pressKey({ name: 'tab', shift: false });
+ expect(capturedUIState.embeddedShellFocused).toBe(true);
+
+ // Unfocus via Shift+Tab
+ pressKey({ name: 'tab', shift: true });
+ expect(capturedUIState.embeddedShellFocused).toBe(false);
+ unmount();
+ });
+
+ it('should auto-unfocus when activePtyId becomes null', async () => {
+ // Start with active pty and focused
+ mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
+ activePtyId: 1,
+ });
+
+ const renderResult = render(getAppContainer());
+ await act(async () => {
+ vi.advanceTimersByTime(0);
+ });
+
+ // Focus it
+ act(() => {
+ handleGlobalKeypress({
+ name: 'tab',
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ } as Key);
+ });
+ expect(capturedUIState.embeddedShellFocused).toBe(true);
+
+ // Now mock activePtyId becoming null
+ mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
+ activePtyId: null,
+ });
+
+ // Rerender to trigger useEffect
+ await act(async () => {
+ renderResult.rerender(getAppContainer());
+ });
+
+ expect(capturedUIState.embeddedShellFocused).toBe(false);
+ renderResult.unmount();
+ });
+
+ it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
+ const mockToggleBackgroundShell = vi.fn();
+ mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
+ activePtyId: null,
+ isBackgroundShellVisible: true,
+ backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
+ toggleBackgroundShell: mockToggleBackgroundShell,
+ });
+
+ await setupKeypressTest();
+
+ // Initially not focused
+ expect(capturedUIState.embeddedShellFocused).toBe(false);
+
+ // Press Tab
+ pressKey({ name: 'tab', shift: false });
+
+ // Should be focused
+ expect(capturedUIState.embeddedShellFocused).toBe(true);
+ // Should NOT have toggled (closed) the shell
+ expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
+
+ unmount();
+ });
+ });
+
+ describe('Background Shell Toggling (CTRL+B)', () => {
+ it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
+ const mockToggleBackgroundShell = vi.fn();
+ mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
+ activePtyId: null,
+ isBackgroundShellVisible: true,
+ backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
+ toggleBackgroundShell: mockToggleBackgroundShell,
+ });
+
+ await setupKeypressTest();
+
+ // Initially not focused, but visible
+ expect(capturedUIState.embeddedShellFocused).toBe(false);
+
+ // Press Ctrl+B
+ pressKey({ name: 'b', ctrl: true });
+
+ // Should have toggled (closed) the shell
+ expect(mockToggleBackgroundShell).toHaveBeenCalled();
+ // Should be unfocused
+ expect(capturedUIState.embeddedShellFocused).toBe(false);
+
+ unmount();
+ });
+
+ it('should show and focus background shell on Ctrl+B if hidden', async () => {
+ const mockToggleBackgroundShell = vi.fn();
+ const geminiStreamMock = {
+ ...DEFAULT_GEMINI_STREAM_MOCK,
+ activePtyId: null,
+ isBackgroundShellVisible: false,
+ backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
+ toggleBackgroundShell: mockToggleBackgroundShell,
+ };
+ mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
+
+ await setupKeypressTest();
+
+ // Update the mock state when toggled to simulate real behavior
+ mockToggleBackgroundShell.mockImplementation(() => {
+ geminiStreamMock.isBackgroundShellVisible = true;
+ });
+
+ // Press Ctrl+B
+ pressKey({ name: 'b', ctrl: true });
+
+ // Should have toggled (shown) the shell
+ expect(mockToggleBackgroundShell).toHaveBeenCalled();
+ // Should be focused
+ expect(capturedUIState.embeddedShellFocused).toBe(true);
+
+ unmount();
+ });
+ });
});
describe('Copy Mode (CTRL+S)', () => {
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 7c10569902..8cf1dab7d4 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -1288,24 +1288,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, WARNING_PROMPT_DURATION_MS);
}, []);
- useEffect(() => {
- const handleSelectionWarning = () => {
- handleWarning('Press Ctrl-S to enter selection mode to copy text.');
- };
- const handlePasteTimeout = () => {
- handleWarning('Paste Timed out. Possibly due to slow connection.');
- };
- appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
- appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
- return () => {
- appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
- appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
+ // Handle timeout cleanup on unmount
+ useEffect(
+ () => () => {
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
+ },
+ [],
+ );
+
+ useEffect(() => {
+ const handlePasteTimeout = () => {
+ handleWarning('Paste Timed out. Possibly due to slow connection.');
+ };
+ appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
+ return () => {
+ appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
};
}, [handleWarning]);
@@ -1500,71 +1502,60 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(false);
return true;
} else if (
- keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
+ (keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
+ keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
- if (key.name === 'tab' && key.shift) {
- // Always change focus
+ if (embeddedShellFocused) {
+ const capturedTime = lastOutputTimeRef.current;
+ if (tabFocusTimeoutRef.current)
+ clearTimeout(tabFocusTimeoutRef.current);
+ tabFocusTimeoutRef.current = setTimeout(() => {
+ if (lastOutputTimeRef.current === capturedTime) {
+ setEmbeddedShellFocused(false);
+ } else {
+ handleWarning('Use Shift+Tab to unfocus');
+ }
+ }, 150);
+ return false;
+ }
+
+ const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
+
+ if (isIdle && !activePtyId && !isBackgroundShellVisible) {
+ if (tabFocusTimeoutRef.current)
+ clearTimeout(tabFocusTimeoutRef.current);
+ toggleBackgroundShell();
+ setEmbeddedShellFocused(true);
+ if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
+ return true;
+ }
+
+ setEmbeddedShellFocused(true);
+ return true;
+ } else if (
+ keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) ||
+ keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key)
+ ) {
+ if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
return true;
}
-
- if (embeddedShellFocused) {
- handleWarning('Press Shift+Tab to focus out.');
- return true;
- }
-
- 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 && !activePtyId) {
- if (tabFocusTimeoutRef.current) {
- clearTimeout(tabFocusTimeoutRef.current);
- }
- toggleBackgroundShell();
- if (!isBackgroundShellVisible) {
- // We are about to show it, so focus it
- setEmbeddedShellFocused(true);
- if (backgroundShells.size > 1) {
- setIsBackgroundShellListOpen(true);
- }
- } else {
- // We are about to hide it
- 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 true;
- }
-
- // Not idle, just focus it
- setEmbeddedShellFocused(true);
- return true;
+ return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
- if (isBackgroundShellVisible && !embeddedShellFocused) {
+ toggleBackgroundShell();
+ // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
+ if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
- } else {
- toggleBackgroundShell();
- // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
- if (!isBackgroundShellVisible && backgroundShells.size > 0) {
- setEmbeddedShellFocused(true);
- if (backgroundShells.size > 1) {
- setIsBackgroundShellListOpen(true);
- }
- } else {
- setEmbeddedShellFocused(false);
+ if (backgroundShells.size > 1) {
+ setIsBackgroundShellListOpen(true);
}
+ } else {
+ setEmbeddedShellFocused(false);
}
}
return true;
@@ -1607,7 +1598,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
- useKeypress(handleGlobalKeypress, { isActive: true });
+ useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx
index e5060af391..c542f54bee 100644
--- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx
+++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx
@@ -405,55 +405,4 @@ describe('', () => {
expect(lastFrame()).toMatchSnapshot();
});
-
- it('unfocuses the shell when Shift+Tab is pressed', async () => {
- render(
-
-
- ,
- );
- await act(async () => {
- await delay(0);
- });
-
- act(() => {
- simulateKey({ name: 'tab', shift: true });
- });
-
- expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
- });
-
- it('shows a warning when Tab is pressed', async () => {
- render(
-
-
- ,
- );
- await act(async () => {
- await delay(0);
- });
-
- act(() => {
- simulateKey({ name: 'tab' });
- });
-
- expect(mockHandleWarning).toHaveBeenCalledWith(
- 'Press Shift+Tab to focus out.',
- );
- expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
- });
});
diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
index e0e63f636a..03cd10823d 100644
--- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
+++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
@@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { commandDescriptions } from '../../config/keyBindings.js';
+import { formatCommand } from '../utils/keybindingUtils.js';
import {
ScrollableList,
type ScrollableListRef,
@@ -64,8 +64,6 @@ export const BackgroundShellDisplay = ({
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
- handleWarning,
- setEmbeddedShellFocused,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState(
@@ -138,27 +136,6 @@ export const BackgroundShellDisplay = ({
(key) => {
if (!activeShell) return;
- // Handle Shift+Tab or Tab (in list) to focus out
- if (
- keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
- (isListOpenProp &&
- keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
- ) {
- setEmbeddedShellFocused(false);
- return true;
- }
-
- // Handle Tab to warn but propagate
- if (
- !isListOpenProp &&
- keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
- ) {
- handleWarning(
- `Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
- );
- // Fall through to allow Tab to be sent to the shell
- }
-
if (isListOpenProp) {
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
@@ -188,7 +165,7 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
- return true;
+ return false;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
@@ -216,7 +193,27 @@ export const BackgroundShellDisplay = ({
{ isActive: isFocused && !!activeShell },
);
- const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
+ const helpTextParts = [
+ { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
+ { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
+ { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
+ ];
+
+ const helpTextStr = helpTextParts
+ .map((p) => `${p.label} (${formatCommand(p.command)})`)
+ .join(' | ');
+
+ const renderHelpText = () => (
+
+ {helpTextParts.map((p, i) => (
+
+ {i > 0 ? ' | ' : ''}
+ {p.label} (
+ {formatCommand(p.command)})
+
+ ))}
+
+ );
const renderTabs = () => {
const shellList = Array.from(shells.values()).filter(
@@ -230,7 +227,7 @@ export const BackgroundShellDisplay = ({
const availableWidth =
width -
TAB_DISPLAY_HORIZONTAL_PADDING -
- getCachedStringWidth(helpText) -
+ getCachedStringWidth(helpTextStr) -
pidInfoWidth;
let currentWidth = 0;
@@ -272,7 +269,7 @@ export const BackgroundShellDisplay = ({
}
if (shellList.length > tabs.length && !isListOpenProp) {
- const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
+ const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `;
const overflowWidth = getCachedStringWidth(overflowLabel);
// If we only have one tab, ensure we don't show the overflow if it's too cramped
@@ -324,7 +321,7 @@ export const BackgroundShellDisplay = ({
- {`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
+ {`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`}
@@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
- {helpText}
+ {renderHelpText()}
{isListOpenProp ? renderProcessList() : renderOutput()}
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 151c5e14b8..8bd45644af 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -924,15 +924,19 @@ export const InputPrompt: React.FC = ({
return true;
}
+ if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
+ return false;
+ }
+
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
- // If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
+ return true;
}
- return true;
+ return false;
}
// Fall back to the text buffer's default input handling for all other keys
diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx
index 5a204b0580..94f009bedb 100644
--- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx
@@ -8,6 +8,12 @@ import { render } from '../../test-utils/render.js';
import { ShellInputPrompt } from './ShellInputPrompt.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ShellExecutionService } from '@google/gemini-cli-core';
+import { useUIActions, type UIActions } from '../contexts/UIActionsContext.js';
+
+// Mock useUIActions
+vi.mock('../contexts/UIActionsContext.js', () => ({
+ useUIActions: vi.fn(),
+}));
// Mock useKeypress
const mockUseKeypress = vi.fn();
@@ -31,9 +37,13 @@ vi.mock('@google/gemini-cli-core', async () => {
describe('ShellInputPrompt', () => {
const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty);
const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty);
+ const mockHandleWarning = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
+ vi.mocked(useUIActions).mockReturnValue({
+ handleWarning: mockHandleWarning,
+ } as Partial as UIActions);
});
it('renders nothing', () => {
@@ -43,6 +53,23 @@ describe('ShellInputPrompt', () => {
expect(lastFrame()).toBe('');
});
+ it('sends tab to pty', () => {
+ render();
+
+ const handler = mockUseKeypress.mock.calls[0][0];
+
+ handler({
+ name: 'tab',
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ sequence: '\t',
+ });
+
+ expect(mockWriteToPty).toHaveBeenCalledWith(1, '\t');
+ });
+
it.each([
['a', 'a'],
['b', 'b'],
diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx
index 4f956ae262..976831f1f4 100644
--- a/packages/cli/src/ui/components/ShellInputPrompt.tsx
+++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx
@@ -40,6 +40,11 @@ export const ShellInputPrompt: React.FC = ({
return false;
}
+ // Allow unfocus to bubble up
+ if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
+ return false;
+ }
+
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return true;
diff --git a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap
index 84101e7f32..b93819b570 100644
--- a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap
@@ -2,16 +2,16 @@
exports[` > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
+│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[` > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
+│ 1: npm sta... (PID: 1003) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ │
-│ Select Process (Enter to select, Esc to cancel): │
+│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -21,23 +21,23 @@ exports[` > keeps exit code status color even when sel
exports[` > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
+│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[` > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
+│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[` > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
+│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ │
-│ Select Process (Enter to select, Esc to cancel): │
+│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -46,9 +46,9 @@ exports[` > renders the process list when isListOpenPr
exports[` > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
+│ 1: npm sta... (PID: 1002) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ │
-│ Select Process (Enter to select, Esc to cancel): │
+│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
index 7f288f53a2..99a045c4ea 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import React, { act } from 'react';
+import React from 'react';
import {
ShellToolMessage,
type ShellToolMessageProps,
@@ -77,16 +77,6 @@ describe('', () => {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
- // Helper to render with context
- const renderWithContext = (
- ui: React.ReactElement,
- streamingState: StreamingState,
- ) =>
- renderWithProviders(ui, {
- uiActions,
- uiState: { streamingState },
- });
-
beforeEach(() => {
vi.clearAllMocks();
});
@@ -140,40 +130,5 @@ describe('', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
});
-
- it('resets focus when shell finishes', async () => {
- let updateStatus: (s: ToolCallStatus) => void = () => {};
-
- const Wrapper = () => {
- const [status, setStatus] = React.useState(ToolCallStatus.Executing);
- updateStatus = setStatus;
- return (
-
- );
- };
-
- const { lastFrame } = renderWithContext(, StreamingState.Idle);
-
- // Verify it is initially focused
- await waitFor(() => {
- expect(lastFrame()).toContain('(Focused)');
- });
-
- // Now update status to Success
- await act(async () => {
- updateStatus(ToolCallStatus.Success);
- });
-
- // Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
- await waitFor(() => {
- expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
- });
- });
});
});
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
index 9eaabbb4fc..998b8cf6d8 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -89,20 +89,6 @@ export const ShellToolMessage: React.FC = ({
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
- const wasFocusedRef = React.useRef(false);
-
- React.useEffect(() => {
- if (isThisShellFocused) {
- wasFocusedRef.current = true;
- } else if (wasFocusedRef.current) {
- if (embeddedShellFocused) {
- setEmbeddedShellFocused(false);
- }
-
- wasFocusedRef.current = false;
- }
- }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
-
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
diff --git a/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx b/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx
index 2704d0896d..24ba10350b 100644
--- a/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx
@@ -77,7 +77,7 @@ describe('Focus Hint', () => {
// Now it SHOULD contain the focus hint
expect(lastFrame()).toMatchSnapshot('after-delay-no-output');
- expect(lastFrame()).toContain('(tab to focus)');
+ expect(lastFrame()).toContain('(Tab to focus)');
});
it('shows focus hint after delay with output', async () => {
@@ -95,7 +95,7 @@ describe('Focus Hint', () => {
});
expect(lastFrame()).toMatchSnapshot('after-delay-with-output');
- expect(lastFrame()).toContain('(tab to focus)');
+ expect(lastFrame()).toContain('(Tab to focus)');
});
});
@@ -116,7 +116,7 @@ describe('Focus Hint', () => {
// The focus hint should be visible
expect(lastFrame()).toMatchSnapshot('long-description');
- expect(lastFrame()).toContain('(tab to focus)');
+ expect(lastFrame()).toContain('(Tab to focus)');
// The name should still be visible
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
});
diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx
index 46065fe59e..a48aefdc7c 100644
--- a/packages/cli/src/ui/components/messages/ToolShared.tsx
+++ b/packages/cli/src/ui/components/messages/ToolShared.tsx
@@ -22,6 +22,8 @@ import {
type ToolResultDisplay,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
+import { formatCommand } from '../../utils/keybindingUtils.js';
+import { Command } from '../../../config/keyBindings.js';
export const STATUS_INDICATOR_WIDTH = 3;
@@ -117,7 +119,9 @@ export const FocusHint: React.FC<{
return (
- {isThisShellFocused ? '(Focused)' : '(tab to focus)'}
+ {isThisShellFocused
+ ? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
+ : `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
);
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
index 92ca92bedb..415baf877e 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (tab to focus) │
+│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -14,7 +14,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even wit
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (tab to focus) │
+│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -26,7 +26,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with out
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (tab to focus) │
+│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -38,7 +38,7 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (tab to focus) │
+│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -50,6 +50,6 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output >
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │
+│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ │"
`;
diff --git a/packages/cli/src/ui/hooks/shellReducer.ts b/packages/cli/src/ui/hooks/shellReducer.ts
index 0e80994d4e..7d3917c681 100644
--- a/packages/cli/src/ui/hooks/shellReducer.ts
+++ b/packages/cli/src/ui/hooks/shellReducer.ts
@@ -104,10 +104,15 @@ export function shellReducer(
}
shell.output = newOutput;
+ const nextState = { ...state, lastShellOutputTime: Date.now() };
+
if (state.isBackgroundShellVisible) {
- return { ...state, backgroundShells: new Map(state.backgroundShells) };
+ return {
+ ...nextState,
+ backgroundShells: new Map(state.backgroundShells),
+ };
}
- return state;
+ return nextState;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index eca933d982..4fb84308b2 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -474,12 +474,6 @@ export const useGeminiStream = (
const activePtyId = activeShellPtyId || activeToolPtyId;
- useEffect(() => {
- if (!activePtyId) {
- setShellInputFocused(false);
- }
- }, [activePtyId, setShellInputFocused]);
-
const prevActiveShellPtyIdRef = useRef(null);
useEffect(() => {
if (
diff --git a/packages/cli/src/ui/utils/keybindingUtils.test.ts b/packages/cli/src/ui/utils/keybindingUtils.test.ts
new file mode 100644
index 0000000000..cdee917332
--- /dev/null
+++ b/packages/cli/src/ui/utils/keybindingUtils.test.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
+import { Command } from '../../config/keyBindings.js';
+
+describe('keybindingUtils', () => {
+ describe('formatKeyBinding', () => {
+ it('formats simple keys', () => {
+ expect(formatKeyBinding({ key: 'a' })).toBe('A');
+ expect(formatKeyBinding({ key: 'return' })).toBe('Enter');
+ expect(formatKeyBinding({ key: 'escape' })).toBe('Esc');
+ });
+
+ it('formats modifiers', () => {
+ expect(formatKeyBinding({ key: 'c', ctrl: true })).toBe('Ctrl+C');
+ expect(formatKeyBinding({ key: 'z', cmd: true })).toBe('Cmd+Z');
+ expect(formatKeyBinding({ key: 'up', shift: true })).toBe('Shift+Up');
+ expect(formatKeyBinding({ key: 'left', alt: true })).toBe('Alt+Left');
+ });
+
+ it('formats multiple modifiers in order', () => {
+ expect(formatKeyBinding({ key: 'z', ctrl: true, shift: true })).toBe(
+ 'Ctrl+Shift+Z',
+ );
+ expect(
+ formatKeyBinding({
+ key: 'a',
+ ctrl: true,
+ alt: true,
+ shift: true,
+ cmd: true,
+ }),
+ ).toBe('Ctrl+Alt+Shift+Cmd+A');
+ });
+ });
+
+ describe('formatCommand', () => {
+ it('formats default commands', () => {
+ expect(formatCommand(Command.QUIT)).toBe('Ctrl+C');
+ expect(formatCommand(Command.SUBMIT)).toBe('Enter');
+ expect(formatCommand(Command.TOGGLE_BACKGROUND_SHELL)).toBe('Ctrl+B');
+ });
+
+ it('returns empty string for unknown commands', () => {
+ expect(formatCommand('unknown.command' as unknown as Command)).toBe('');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/keybindingUtils.ts b/packages/cli/src/ui/utils/keybindingUtils.ts
new file mode 100644
index 0000000000..43e3d4e1fd
--- /dev/null
+++ b/packages/cli/src/ui/utils/keybindingUtils.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ type Command,
+ type KeyBinding,
+ type KeyBindingConfig,
+ defaultKeyBindings,
+} from '../../config/keyBindings.js';
+
+/**
+ * Maps internal key names to user-friendly display names.
+ */
+const KEY_NAME_MAP: Record = {
+ return: 'Enter',
+ escape: 'Esc',
+ backspace: 'Backspace',
+ delete: 'Delete',
+ up: 'Up',
+ down: 'Down',
+ left: 'Left',
+ right: 'Right',
+ pageup: 'Page Up',
+ pagedown: 'Page Down',
+ home: 'Home',
+ end: 'End',
+ tab: 'Tab',
+ space: 'Space',
+};
+
+/**
+ * Formats a single KeyBinding into a human-readable string (e.g., "Ctrl+C").
+ */
+export function formatKeyBinding(binding: KeyBinding): string {
+ const parts: string[] = [];
+
+ if (binding.ctrl) parts.push('Ctrl');
+ if (binding.alt) parts.push('Alt');
+ if (binding.shift) parts.push('Shift');
+ if (binding.cmd) parts.push('Cmd');
+
+ const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
+ parts.push(keyName);
+
+ return parts.join('+');
+}
+
+/**
+ * Formats the primary keybinding for a command.
+ */
+export function formatCommand(
+ command: Command,
+ config: KeyBindingConfig = defaultKeyBindings,
+): string {
+ const bindings = config[command];
+ if (!bindings || bindings.length === 0) {
+ return '';
+ }
+
+ // Use the first binding as the primary one for display
+ return formatKeyBinding(bindings[0]);
+}