Skip to content

Commit 796ff94

Browse files
authored
fix(cli): improve focus navigation for interactive and background shells (google-gemini#18343)
1 parent 4e08245 commit 796ff94

19 files changed

Lines changed: 448 additions & 248 deletions

docs/cli/keyboard-shortcuts.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,17 @@ available combinations.
106106
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
107107
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
108108
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
109-
| Ctrl+B | `Ctrl + B` |
110-
| Ctrl+L | `Ctrl + L` |
111-
| Ctrl+K | `Ctrl + K` |
112-
| Enter | `Enter` |
113-
| Esc | `Esc` |
114-
| Shift+Tab | `Shift + Tab` |
115-
| Tab | `Tab (no Shift)` |
116-
| Tab | `Tab (no Shift)` |
117-
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
118-
| Focus the Gemini input from the shell input. | `Tab` |
109+
| Toggle current background shell visibility. | `Ctrl + B` |
110+
| Toggle background shell list. | `Ctrl + L` |
111+
| Kill the active background shell. | `Ctrl + K` |
112+
| Confirm selection in background shell list. | `Enter` |
113+
| Dismiss background shell list. | `Esc` |
114+
| Move focus from background shell to Gemini. | `Shift + Tab` |
115+
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
116+
| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` |
117+
| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` |
118+
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
119+
| Move focus from the shell back to Gemini. | `Shift + Tab` |
119120
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
120121
| Restart the application. | `R` |
121122
| Suspend the application (not yet implemented). | `Ctrl + Z` |

packages/cli/src/config/keyBindings.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export enum Command {
8080
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
8181
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
8282
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
83+
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
8384

8485
// App Controls
8586
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
@@ -281,14 +282,15 @@ export const defaultKeyBindings: KeyBindingConfig = {
281282
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
282283
{ key: 'tab', shift: false },
283284
],
285+
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
284286
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
285287
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
286288
[Command.SHOW_MORE_LINES]: [
287289
{ key: 'o', ctrl: true },
288290
{ key: 's', ctrl: true },
289291
],
290292
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
291-
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
293+
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
292294
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
293295
[Command.RESTART_APP]: [{ key: 'r' }],
294296
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
@@ -405,6 +407,7 @@ export const commandCategories: readonly CommandCategory[] = [
405407
Command.UNFOCUS_BACKGROUND_SHELL,
406408
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
407409
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
410+
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
408411
Command.FOCUS_SHELL_INPUT,
409412
Command.UNFOCUS_SHELL_INPUT,
410413
Command.CLEAR_SCREEN,
@@ -496,16 +499,23 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
496499
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
497500
[Command.SHOW_MORE_LINES]:
498501
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
499-
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
500-
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
501-
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
502-
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
503-
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
504-
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
505-
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
506-
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
507-
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
508-
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
502+
[Command.BACKGROUND_SHELL_SELECT]:
503+
'Confirm selection in background shell list.',
504+
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
505+
[Command.TOGGLE_BACKGROUND_SHELL]:
506+
'Toggle current background shell visibility.',
507+
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
508+
[Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
509+
[Command.UNFOCUS_BACKGROUND_SHELL]:
510+
'Move focus from background shell to Gemini.',
511+
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
512+
'Move focus from background shell list to Gemini.',
513+
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
514+
'Show warning when trying to unfocus background shell via Tab.',
515+
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
516+
'Show warning when trying to unfocus shell input via Tab.',
517+
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
518+
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
509519
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
510520
[Command.RESTART_APP]: 'Restart the application.',
511521
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',

packages/cli/src/ui/AppContainer.test.tsx

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1940,6 +1940,160 @@ describe('AppContainer State Management', () => {
19401940
unmount();
19411941
});
19421942
});
1943+
1944+
describe('Focus Handling (Tab / Shift+Tab)', () => {
1945+
beforeEach(() => {
1946+
// Mock activePtyId to enable focus
1947+
mockedUseGeminiStream.mockReturnValue({
1948+
...DEFAULT_GEMINI_STREAM_MOCK,
1949+
activePtyId: 1,
1950+
});
1951+
});
1952+
1953+
it('should focus shell input on Tab', async () => {
1954+
await setupKeypressTest();
1955+
1956+
pressKey({ name: 'tab', shift: false });
1957+
1958+
expect(capturedUIState.embeddedShellFocused).toBe(true);
1959+
unmount();
1960+
});
1961+
1962+
it('should unfocus shell input on Shift+Tab', async () => {
1963+
await setupKeypressTest();
1964+
1965+
// Focus first
1966+
pressKey({ name: 'tab', shift: false });
1967+
expect(capturedUIState.embeddedShellFocused).toBe(true);
1968+
1969+
// Unfocus via Shift+Tab
1970+
pressKey({ name: 'tab', shift: true });
1971+
expect(capturedUIState.embeddedShellFocused).toBe(false);
1972+
unmount();
1973+
});
1974+
1975+
it('should auto-unfocus when activePtyId becomes null', async () => {
1976+
// Start with active pty and focused
1977+
mockedUseGeminiStream.mockReturnValue({
1978+
...DEFAULT_GEMINI_STREAM_MOCK,
1979+
activePtyId: 1,
1980+
});
1981+
1982+
const renderResult = render(getAppContainer());
1983+
await act(async () => {
1984+
vi.advanceTimersByTime(0);
1985+
});
1986+
1987+
// Focus it
1988+
act(() => {
1989+
handleGlobalKeypress({
1990+
name: 'tab',
1991+
shift: false,
1992+
alt: false,
1993+
ctrl: false,
1994+
cmd: false,
1995+
} as Key);
1996+
});
1997+
expect(capturedUIState.embeddedShellFocused).toBe(true);
1998+
1999+
// Now mock activePtyId becoming null
2000+
mockedUseGeminiStream.mockReturnValue({
2001+
...DEFAULT_GEMINI_STREAM_MOCK,
2002+
activePtyId: null,
2003+
});
2004+
2005+
// Rerender to trigger useEffect
2006+
await act(async () => {
2007+
renderResult.rerender(getAppContainer());
2008+
});
2009+
2010+
expect(capturedUIState.embeddedShellFocused).toBe(false);
2011+
renderResult.unmount();
2012+
});
2013+
2014+
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
2015+
const mockToggleBackgroundShell = vi.fn();
2016+
mockedUseGeminiStream.mockReturnValue({
2017+
...DEFAULT_GEMINI_STREAM_MOCK,
2018+
activePtyId: null,
2019+
isBackgroundShellVisible: true,
2020+
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
2021+
toggleBackgroundShell: mockToggleBackgroundShell,
2022+
});
2023+
2024+
await setupKeypressTest();
2025+
2026+
// Initially not focused
2027+
expect(capturedUIState.embeddedShellFocused).toBe(false);
2028+
2029+
// Press Tab
2030+
pressKey({ name: 'tab', shift: false });
2031+
2032+
// Should be focused
2033+
expect(capturedUIState.embeddedShellFocused).toBe(true);
2034+
// Should NOT have toggled (closed) the shell
2035+
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
2036+
2037+
unmount();
2038+
});
2039+
});
2040+
2041+
describe('Background Shell Toggling (CTRL+B)', () => {
2042+
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
2043+
const mockToggleBackgroundShell = vi.fn();
2044+
mockedUseGeminiStream.mockReturnValue({
2045+
...DEFAULT_GEMINI_STREAM_MOCK,
2046+
activePtyId: null,
2047+
isBackgroundShellVisible: true,
2048+
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
2049+
toggleBackgroundShell: mockToggleBackgroundShell,
2050+
});
2051+
2052+
await setupKeypressTest();
2053+
2054+
// Initially not focused, but visible
2055+
expect(capturedUIState.embeddedShellFocused).toBe(false);
2056+
2057+
// Press Ctrl+B
2058+
pressKey({ name: 'b', ctrl: true });
2059+
2060+
// Should have toggled (closed) the shell
2061+
expect(mockToggleBackgroundShell).toHaveBeenCalled();
2062+
// Should be unfocused
2063+
expect(capturedUIState.embeddedShellFocused).toBe(false);
2064+
2065+
unmount();
2066+
});
2067+
2068+
it('should show and focus background shell on Ctrl+B if hidden', async () => {
2069+
const mockToggleBackgroundShell = vi.fn();
2070+
const geminiStreamMock = {
2071+
...DEFAULT_GEMINI_STREAM_MOCK,
2072+
activePtyId: null,
2073+
isBackgroundShellVisible: false,
2074+
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
2075+
toggleBackgroundShell: mockToggleBackgroundShell,
2076+
};
2077+
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
2078+
2079+
await setupKeypressTest();
2080+
2081+
// Update the mock state when toggled to simulate real behavior
2082+
mockToggleBackgroundShell.mockImplementation(() => {
2083+
geminiStreamMock.isBackgroundShellVisible = true;
2084+
});
2085+
2086+
// Press Ctrl+B
2087+
pressKey({ name: 'b', ctrl: true });
2088+
2089+
// Should have toggled (shown) the shell
2090+
expect(mockToggleBackgroundShell).toHaveBeenCalled();
2091+
// Should be focused
2092+
expect(capturedUIState.embeddedShellFocused).toBe(true);
2093+
2094+
unmount();
2095+
});
2096+
});
19432097
});
19442098

19452099
describe('Copy Mode (CTRL+S)', () => {

0 commit comments

Comments
 (0)