From f603f4a12b36b7835b3f2655d4b1be44ee739658 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Thu, 12 Feb 2026 11:35:40 -0500 Subject: [PATCH] fix(cli): dismiss '?' shortcuts help on hotkeys and active states (#18583) Co-authored-by: jacob314 --- docs/cli/keyboard-shortcuts.md | 7 +- packages/cli/src/ui/AppContainer.test.tsx | 125 +++++++++++++++++- packages/cli/src/ui/AppContainer.tsx | 36 +++++ .../cli/src/ui/components/Composer.test.tsx | 44 +++++- packages/cli/src/ui/components/Composer.tsx | 54 ++++++-- .../src/ui/components/InputPrompt.test.tsx | 12 ++ .../cli/src/ui/components/InputPrompt.tsx | 5 + packages/cli/src/ui/utils/shortcutsHelp.ts | 12 ++ 8 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/ui/utils/shortcutsHelp.ts diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index d377cfd3e2..91baedc8c9 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -130,9 +130,10 @@ available combinations. terminal isn't configured to send Meta with Option. - `!` on an empty prompt: Enter or exit shell mode. - `?` on an empty prompt: Toggle the shortcuts panel above the input. Press - `Esc`, `Backspace`, or any printable key to close it. Press `?` again to close - the panel and insert a `?` into the prompt. You can hide only the hint text - via `ui.showShortcutsHint`, without changing this keyboard behavior. + `Esc`, `Backspace`, any printable key, or a registered app hotkey to close it. + The panel also auto-hides while the agent is running/streaming or when + action-required dialogs are shown. Press `?` again to close the panel and + insert a `?` into the prompt. - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0c333176e0..063315f8ac 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -197,7 +197,8 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; -import { useKeypress } from './hooks/useKeypress.js'; +import { useKeypress, type Key } from './hooks/useKeypress.js'; +import * as useKeypressModule from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { @@ -2091,6 +2092,128 @@ describe('AppContainer State Management', () => { }); }); + describe('Shortcuts Help Visibility', () => { + let handleGlobalKeypress: (key: Key) => boolean; + let mockedUseKeypress: Mock; + let rerender: () => void; + let unmount: () => void; + + const setupShortcutsVisibilityTest = async () => { + const renderResult = renderAppContainer(); + await act(async () => { + vi.advanceTimersByTime(0); + }); + rerender = () => renderResult.rerender(getAppContainer()); + unmount = renderResult.unmount; + }; + + const pressKey = (key: Partial) => { + act(() => { + handleGlobalKeypress({ + name: 'r', + shift: false, + alt: false, + ctrl: false, + cmd: false, + insertable: false, + sequence: '', + ...key, + } as Key); + }); + rerender(); + }; + + beforeEach(() => { + mockedUseKeypress = vi.spyOn(useKeypressModule, 'useKeypress') as Mock; + mockedUseKeypress.mockImplementation( + (callback: (key: Key) => boolean, options: { isActive: boolean }) => { + // AppContainer registers multiple keypress handlers; capture only + // active handlers so inactive copy-mode handler doesn't override. + if (options?.isActive) { + handleGlobalKeypress = callback; + } + }, + ); + vi.useFakeTimers(); + }); + + afterEach(() => { + mockedUseKeypress.mockRestore(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('dismisses shortcuts help when a registered hotkey is pressed', async () => { + await setupShortcutsVisibilityTest(); + + act(() => { + capturedUIActions.setShortcutsHelpVisible(true); + }); + rerender(); + expect(capturedUIState.shortcutsHelpVisible).toBe(true); + + pressKey({ name: 'r', ctrl: true, sequence: '\x12' }); // Ctrl+R + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + + unmount(); + }); + + it('dismisses shortcuts help when streaming starts', async () => { + await setupShortcutsVisibilityTest(); + + act(() => { + capturedUIActions.setShortcutsHelpVisible(true); + }); + rerender(); + expect(capturedUIState.shortcutsHelpVisible).toBe(true); + + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + }); + + await act(async () => { + rerender(); + }); + await waitFor(() => { + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + }); + + unmount(); + }); + + it('dismisses shortcuts help when action-required confirmation appears', async () => { + await setupShortcutsVisibilityTest(); + + act(() => { + capturedUIActions.setShortcutsHelpVisible(true); + }); + rerender(); + expect(capturedUIState.shortcutsHelpVisible).toBe(true); + + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: vi.fn(), + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: { + prompt: 'Confirm this action?', + onConfirm: vi.fn(), + }, + }); + + await act(async () => { + rerender(); + }); + await waitFor(() => { + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + }); + + unmount(); + }); + }); + describe('Copy Mode (CTRL+S)', () => { let rerender: () => void; let unmount: () => void; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 72fdb0ce48..7489d07e2a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -147,6 +147,7 @@ import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; import { isITerm2 } from './utils/terminalUtils.js'; +import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -1489,6 +1490,10 @@ Logging in with Google... Restarting Gemini CLI to continue. debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } + if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) { + setShortcutsHelpVisible(false); + } + if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { setCopyModeEnabled(true); disableMouseEvents(); @@ -1652,6 +1657,7 @@ Logging in with Google... Restarting Gemini CLI to continue. refreshStatic, setCopyModeEnabled, isAlternateBuffer, + shortcutsHelpVisible, backgroundCurrentShell, toggleBackgroundShell, backgroundShells, @@ -1811,6 +1817,36 @@ Logging in with Google... Restarting Gemini CLI to continue. [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); + const hasPendingToolConfirmation = useMemo( + () => isToolAwaitingConfirmation(pendingHistoryItems), + [pendingHistoryItems], + ); + + const hasPendingActionRequired = + hasPendingToolConfirmation || + !!commandConfirmationRequest || + !!authConsentRequest || + confirmUpdateExtensionRequests.length > 0 || + !!loopDetectionConfirmationRequest || + !!proQuotaRequest || + !!validationRequest || + !!customDialog; + + const isPassiveShortcutsHelpState = + isInputActive && + streamingState === StreamingState.Idle && + !hasPendingActionRequired; + + useEffect(() => { + if (shortcutsHelpVisible && !isPassiveShortcutsHelpState) { + setShortcutsHelpVisible(false); + } + }, [ + shortcutsHelpVisible, + isPassiveShortcutsHelpState, + setShortcutsHelpVisible, + ]); + const allToolCalls = useMemo( () => pendingHistoryItems diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index ee3a441c04..1a25d2bb56 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -189,6 +189,7 @@ const createMockUIActions = (): UIActions => setShellModeActive: vi.fn(), onEscapePromptChange: vi.fn(), vimHandleInput: vi.fn(), + setShortcutsHelpVisible: vi.fn(), }) as Partial as UIActions; const createMockConfig = (overrides = {}): Config => @@ -337,7 +338,7 @@ describe('Composer', () => { expect(output).toContain('LoadingIndicator: Thinking ...'); }); - it('keeps shortcuts hint visible while loading', () => { + it('hides shortcuts hint while loading', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, elapsedTime: 1, @@ -347,7 +348,7 @@ describe('Composer', () => { const output = lastFrame(); expect(output).toContain('LoadingIndicator'); - expect(output).toContain('ShortcutsHint'); + expect(output).not.toContain('ShortcutsHint'); }); it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => { @@ -686,4 +687,43 @@ describe('Composer', () => { expect(lastFrame()).toContain('ShortcutsHint'); }); }); + + describe('Shortcuts Help', () => { + it('shows shortcuts help in passive state', () => { + const uiState = createMockUIState({ + shortcutsHelpVisible: true, + streamingState: StreamingState.Idle, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('ShortcutsHelp'); + }); + + it('hides shortcuts help while streaming', () => { + const uiState = createMockUIState({ + shortcutsHelpVisible: true, + streamingState: StreamingState.Responding, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHelp'); + }); + + it('hides shortcuts help when action is required', () => { + const uiState = createMockUIState({ + shortcutsHelpVisible: true, + customDialog: ( + + Dialog content + + ), + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHelp'); + }); + }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index e87e86e801..b5b88b4e15 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Box, useIsScreenReaderEnabled } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StatusDisplay } from './StatusDisplay.js'; @@ -28,7 +28,11 @@ import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; -import { StreamingState, ToolCallStatus } from '../types.js'; +import { + StreamingState, + type HistoryItemToolGroup, + ToolCallStatus, +} from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; @@ -51,11 +55,19 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; - const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some( - (item) => - item.type === 'tool_group' && - item.tools.some((tool) => tool.status === ToolCallStatus.Confirming), + + const hasPendingToolConfirmation = useMemo( + () => + (uiState.pendingHistoryItems ?? []) + .filter( + (item): item is HistoryItemToolGroup => item.type === 'tool_group', + ) + .some((item) => + item.tools.some((tool) => tool.status === ToolCallStatus.Confirming), + ), + [uiState.pendingHistoryItems], ); + const hasPendingActionRequired = hasPendingToolConfirmation || Boolean(uiState.commandConfirmationRequest) || @@ -65,6 +77,31 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { Boolean(uiState.quota.proQuotaRequest) || Boolean(uiState.quota.validationRequest) || Boolean(uiState.customDialog); + const isPassiveShortcutsHelpState = + uiState.isInputActive && + uiState.streamingState === StreamingState.Idle && + !hasPendingActionRequired; + + const { setShortcutsHelpVisible } = uiActions; + + useEffect(() => { + if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) { + setShortcutsHelpVisible(false); + } + }, [ + uiState.shortcutsHelpVisible, + isPassiveShortcutsHelpState, + setShortcutsHelpVisible, + ]); + + const showShortcutsHelp = + uiState.shortcutsHelpVisible && + uiState.streamingState === StreamingState.Idle && + !hasPendingActionRequired; + const showShortcutsHint = + settings.merged.ui.showShortcutsHint && + uiState.streamingState === StreamingState.Idle && + !hasPendingActionRequired; const hasToast = shouldShowToast(uiState); const showLoadingIndicator = (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && @@ -133,11 +170,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} > - {settings.merged.ui.showShortcutsHint && - !hasPendingActionRequired && } + {showShortcutsHint && } - {uiState.shortcutsHelpVisible && } + {showShortcutsHelp && } { vi.mocked(clipboardy.read).mockResolvedValue('clipboard text'); }, }, + { + name: 'Ctrl+R hotkey is pressed', + input: '\x12', + }, + { + name: 'Ctrl+X hotkey is pressed', + input: '\x18', + }, + { + name: 'F12 hotkey is pressed', + input: '\x1b[24~', + }, ])( 'should close shortcuts help when a $name', async ({ input, setupMocks, mouseEventsEnabled }) => { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index f2f23f5506..22fd317c10 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -75,6 +75,7 @@ import { useMouseClick } from '../hooks/useMouseClick.js'; import { useMouse, type MouseEvent } from '../contexts/MouseContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js'; /** * Returns if the terminal can be trusted to handle paste events atomically @@ -661,6 +662,10 @@ export const InputPrompt: React.FC = ({ return true; } + if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) { + setShortcutsHelpVisible(false); + } + if (shortcutsHelpVisible) { if (key.sequence === '?' && key.insertable) { setShortcutsHelpVisible(false); diff --git a/packages/cli/src/ui/utils/shortcutsHelp.ts b/packages/cli/src/ui/utils/shortcutsHelp.ts new file mode 100644 index 0000000000..65ab8f2a13 --- /dev/null +++ b/packages/cli/src/ui/utils/shortcutsHelp.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Command, keyMatchers } from '../keyMatchers.js'; +import type { Key } from '../hooks/useKeypress.js'; + +export function shouldDismissShortcutsHelpOnHotkey(key: Key): boolean { + return Object.values(Command).some((command) => keyMatchers[command](key)); +}