Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/cli/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
125 changes: 124 additions & 1 deletion packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)', () => {
let rerender: () => void;
let unmount: () => void;
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1652,6 +1657,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
setCopyModeEnabled,
isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
Expand Down Expand Up @@ -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;
Comment thread
LyalinDotCom marked this conversation as resolved.

const isPassiveShortcutsHelpState =
isInputActive &&
streamingState === StreamingState.Idle &&
!hasPendingActionRequired;

useEffect(() => {
if (shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
setShortcutsHelpVisible(false);
}
}, [
shortcutsHelpVisible,
isPassiveShortcutsHelpState,
setShortcutsHelpVisible,
]);

const allToolCalls = useMemo(
() =>
pendingHistoryItems
Expand Down
44 changes: 42 additions & 2 deletions packages/cli/src/ui/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ const createMockUIActions = (): UIActions =>
setShellModeActive: vi.fn(),
onEscapePromptChange: vi.fn(),
vimHandleInput: vi.fn(),
setShortcutsHelpVisible: vi.fn(),
}) as Partial<UIActions> as UIActions;

const createMockConfig = (overrides = {}): Config =>
Expand Down Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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: (
<Box>
<Text>Dialog content</Text>
</Box>
),
});

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).not.toContain('ShortcutsHelp');
});
});
});
54 changes: 45 additions & 9 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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) ||
Expand All @@ -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) &&
Expand Down Expand Up @@ -133,11 +170,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{settings.merged.ui.showShortcutsHint &&
!hasPendingActionRequired && <ShortcutsHint />}
{showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
{showShortcutsHelp && <ShortcutsHelp />}
<HorizontalLine />
<Box
justifyContent={
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4342,6 +4342,18 @@ describe('InputPrompt', () => {
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 }) => {
Expand Down
Loading
Loading