fix(cli): improve focus navigation for interactive and background shells (#18343)

This commit is contained in:
Gal Zahavi
2026-02-06 10:36:14 -08:00
committed by GitHub
parent f062f56b43
commit ec5836c4d6
19 changed files with 456 additions and 256 deletions

View File

@@ -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`<br />`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` |

View File

@@ -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<Record<Command, string>> = {
'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).',

View File

@@ -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)', () => {

View File

@@ -1291,24 +1291,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]);
@@ -1506,71 +1508,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;
@@ -1613,7 +1604,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings

View File

@@ -405,55 +405,4 @@ describe('<BackgroundShellDisplay />', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('unfocuses the shell when Shift+Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
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(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab' });
});
expect(mockHandleWarning).toHaveBeenCalledWith(
'Press Shift+Tab to focus out.',
);
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
});
});

View File

@@ -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<string | AnsiOutput>(
@@ -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 = () => (
<Text>
{helpTextParts.map((p, i) => (
<Text key={p.label}>
{i > 0 ? ' | ' : ''}
{p.label} (
<Text color={theme.text.accent}>{formatCommand(p.command)}</Text>)
</Text>
))}
</Text>
);
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 = ({
<Box flexDirection="column" height="100%" width="100%">
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
<Text bold>
{`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):`}
</Text>
</Box>
<Box flexGrow={1} width="100%">
@@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
</Text>
</Box>
<Text color={theme.text.accent}>{helpText}</Text>
{renderHelpText()}
</Box>
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}

View File

@@ -982,15 +982,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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

View File

@@ -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<UIActions> as UIActions);
});
it('renders nothing', () => {
@@ -43,6 +53,23 @@ describe('ShellInputPrompt', () => {
expect(lastFrame()).toBe('');
});
it('sends tab to pty', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
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'],

View File

@@ -40,6 +40,11 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
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;

View File

@@ -2,16 +2,16 @@
exports[`<BackgroundShellDisplay /> > 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[`<BackgroundShellDisplay /> > 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[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
exports[`<BackgroundShellDisplay /> > 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[`<BackgroundShellDisplay /> > 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[`<BackgroundShellDisplay /> > 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[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
exports[`<BackgroundShellDisplay /> > 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) │

View File

@@ -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('<ShellToolMessage />', () => {
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('<ShellToolMessage />', () => {
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 (
<ShellToolMessage
{...shellProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
};
const { lastFrame } = renderWithContext(<Wrapper />, 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);
});
});
});
});

View File

@@ -89,20 +89,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
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,

View File

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

View File

@@ -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 (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
</Text>
</Box>
);

View File

@@ -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) │
│ │"
`;

View File

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

View File

@@ -474,12 +474,6 @@ export const useGeminiStream = (
const activePtyId = activeShellPtyId || activeToolPtyId;
useEffect(() => {
if (!activePtyId) {
setShellInputFocused(false);
}
}, [activePtyId, setShellInputFocused]);
const prevActiveShellPtyIdRef = useRef<number | null>(null);
useEffect(() => {
if (

View File

@@ -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('');
});
});
});

View File

@@ -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<string, string> = {
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]);
}