fix(cli): dismiss '?' shortcuts help on hotkeys and active states (#18583)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Dmitry Lyalin
2026-02-12 11:35:40 -05:00
committed by GitHub
parent 2ca183ffc9
commit f603f4a12b
8 changed files with 280 additions and 15 deletions
+4 -3
View File
@@ -130,9 +130,10 @@ available combinations.
terminal isn't configured to send Meta with Option. terminal isn't configured to send Meta with Option.
- `!` on an empty prompt: Enter or exit shell mode. - `!` on an empty prompt: Enter or exit shell mode.
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press - `?` 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 `Esc`, `Backspace`, any printable key, or a registered app hotkey to close it.
the panel and insert a `?` into the prompt. You can hide only the hint text The panel also auto-hides while the agent is running/streaming or when
via `ui.showShortcutsHint`, without changing this keyboard behavior. 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 - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode. mode.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty, - `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
+124 -1
View File
@@ -197,7 +197,8 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js'; import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.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 { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useTerminalSize } from './hooks/useTerminalSize.js';
import { 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<Key>) => {
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)', () => { describe('Copy Mode (CTRL+S)', () => {
let rerender: () => void; let rerender: () => void;
let unmount: () => void; let unmount: () => void;
+36
View File
@@ -147,6 +147,7 @@ import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js'; import { useTimedMessage } from './hooks/useTimedMessage.js';
import { isITerm2 } from './utils/terminalUtils.js'; import { isITerm2 } from './utils/terminalUtils.js';
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => { return pendingHistoryItems.some((item) => {
@@ -1489,6 +1490,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
} }
if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
setShortcutsHelpVisible(false);
}
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
setCopyModeEnabled(true); setCopyModeEnabled(true);
disableMouseEvents(); disableMouseEvents();
@@ -1652,6 +1657,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic, refreshStatic,
setCopyModeEnabled, setCopyModeEnabled,
isAlternateBuffer, isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentShell, backgroundCurrentShell,
toggleBackgroundShell, toggleBackgroundShell,
backgroundShells, backgroundShells,
@@ -1811,6 +1817,36 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], [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( const allToolCalls = useMemo(
() => () =>
pendingHistoryItems pendingHistoryItems
@@ -189,6 +189,7 @@ const createMockUIActions = (): UIActions =>
setShellModeActive: vi.fn(), setShellModeActive: vi.fn(),
onEscapePromptChange: vi.fn(), onEscapePromptChange: vi.fn(),
vimHandleInput: vi.fn(), vimHandleInput: vi.fn(),
setShortcutsHelpVisible: vi.fn(),
}) as Partial<UIActions> as UIActions; }) as Partial<UIActions> as UIActions;
const createMockConfig = (overrides = {}): Config => const createMockConfig = (overrides = {}): Config =>
@@ -337,7 +338,7 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator: Thinking ...'); expect(output).toContain('LoadingIndicator: Thinking ...');
}); });
it('keeps shortcuts hint visible while loading', () => { it('hides shortcuts hint while loading', () => {
const uiState = createMockUIState({ const uiState = createMockUIState({
streamingState: StreamingState.Responding, streamingState: StreamingState.Responding,
elapsedTime: 1, elapsedTime: 1,
@@ -347,7 +348,7 @@ describe('Composer', () => {
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('LoadingIndicator'); expect(output).toContain('LoadingIndicator');
expect(output).toContain('ShortcutsHint'); expect(output).not.toContain('ShortcutsHint');
}); });
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => { it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
@@ -686,4 +687,43 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ShortcutsHint'); 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: (
<Box>
<Text>Dialog content</Text>
</Box>
),
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
});
});
}); });
+45 -9
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink'; import { Box, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js'; import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js'; import { StatusDisplay } from './StatusDisplay.js';
@@ -28,7 +28,11 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.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 { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js'; import { TodoTray } from './messages/Todo.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
@@ -51,11 +55,19 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary = const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above'; suggestionsVisible && suggestionsPosition === 'above';
const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some(
(item) => const hasPendingToolConfirmation = useMemo(
item.type === 'tool_group' && () =>
item.tools.some((tool) => tool.status === ToolCallStatus.Confirming), (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 = const hasPendingActionRequired =
hasPendingToolConfirmation || hasPendingToolConfirmation ||
Boolean(uiState.commandConfirmationRequest) || Boolean(uiState.commandConfirmationRequest) ||
@@ -65,6 +77,31 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.quota.proQuotaRequest) || Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) || Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog); 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 hasToast = shouldShowToast(uiState);
const showLoadingIndicator = const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
@@ -133,11 +170,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexDirection="column" flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'} alignItems={isNarrow ? 'flex-start' : 'flex-end'}
> >
{settings.merged.ui.showShortcutsHint && {showShortcutsHint && <ShortcutsHint />}
!hasPendingActionRequired && <ShortcutsHint />}
</Box> </Box>
</Box> </Box>
{uiState.shortcutsHelpVisible && <ShortcutsHelp />} {showShortcutsHelp && <ShortcutsHelp />}
<HorizontalLine /> <HorizontalLine />
<Box <Box
justifyContent={ justifyContent={
@@ -4342,6 +4342,18 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text'); 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', 'should close shortcuts help when a $name',
async ({ input, setupMocks, mouseEventsEnabled }) => { async ({ input, setupMocks, mouseEventsEnabled }) => {
@@ -75,6 +75,7 @@ import { useMouseClick } from '../hooks/useMouseClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js'; import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.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 * Returns if the terminal can be trusted to handle paste events atomically
@@ -661,6 +662,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true; return true;
} }
if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
setShortcutsHelpVisible(false);
}
if (shortcutsHelpVisible) { if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) { if (key.sequence === '?' && key.insertable) {
setShortcutsHelpVisible(false); setShortcutsHelpVisible(false);
@@ -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));
}