From 44c67daa07f4078a4e056d05fed9406608b4b287 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Fri, 6 Feb 2026 13:01:51 -0800 Subject: [PATCH] fix(patch): cherry-pick ec5836c to release/v0.28.0-preview.4-pr-18343 to patch version v0.28.0-preview.4 and create version 0.28.0-preview.5 (#18472) --- docs/cli/keyboard-shortcuts.md | 21 +-- packages/cli/src/config/keyBindings.ts | 32 ++-- packages/cli/src/ui/AppContainer.test.tsx | 154 ++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 123 +++++++------- .../BackgroundShellDisplay.test.tsx | 51 ------ .../ui/components/BackgroundShellDisplay.tsx | 57 +++---- .../cli/src/ui/components/InputPrompt.tsx | 8 +- .../ui/components/ShellInputPrompt.test.tsx | 27 +++ .../src/ui/components/ShellInputPrompt.tsx | 5 + .../BackgroundShellDisplay.test.tsx.snap | 18 +- .../messages/ShellToolMessage.test.tsx | 47 +----- .../components/messages/ShellToolMessage.tsx | 14 -- .../messages/ToolMessageFocusHint.test.tsx | 6 +- .../src/ui/components/messages/ToolShared.tsx | 6 +- .../ToolMessageFocusHint.test.tsx.snap | 10 +- packages/cli/src/ui/hooks/shellReducer.ts | 9 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 6 - .../cli/src/ui/utils/keybindingUtils.test.ts | 53 ++++++ packages/cli/src/ui/utils/keybindingUtils.ts | 65 ++++++++ 19 files changed, 456 insertions(+), 256 deletions(-) create mode 100644 packages/cli/src/ui/utils/keybindingUtils.test.ts create mode 100644 packages/cli/src/ui/utils/keybindingUtils.ts 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]); +}