Add shortcuts hint and panel for discoverability (#18035)

This commit is contained in:
Dmitry Lyalin
2026-02-06 11:33:39 -08:00
committed by GitHub
parent ec5836c4d6
commit 1f1cf756c8
25 changed files with 639 additions and 54 deletions
+5 -1
View File
@@ -113,10 +113,14 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Lists all active extensions in the current Gemini CLI - **Description:** Lists all active extensions in the current Gemini CLI
session. See [Gemini CLI Extensions](../extensions/index.md). session. See [Gemini CLI Extensions](../extensions/index.md).
- **`/help`** (or **`/?`**) - **`/help`**
- **Description:** Display help information about Gemini CLI, including - **Description:** Display help information about Gemini CLI, including
available commands and their usage. available commands and their usage.
- **`/shortcuts`**
- **Description:** Toggle the shortcuts panel above the input.
- **Shortcut:** Press `?` when the prompt is empty.
- **`/hooks`** - **`/hooks`**
- **Description:** Manage hooks, which allow you to intercept and customize - **Description:** Manage hooks, which allow you to intercept and customize
Gemini CLI behavior at specific lifecycle events. Gemini CLI behavior at specific lifecycle events.
+3
View File
@@ -128,6 +128,9 @@ available combinations.
- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your - `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your
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
`Esc`, `Backspace`, or any printable key to close it. 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,
@@ -85,6 +85,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: () => ({}), extensionsCommand: () => ({}),
})); }));
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
vi.mock('../ui/commands/shortcutsCommand.js', () => ({
shortcutsCommand: {},
}));
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
vi.mock('../ui/commands/modelCommand.js', () => ({ vi.mock('../ui/commands/modelCommand.js', () => ({
modelCommand: { name: 'model' }, modelCommand: { name: 'model' },
@@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js';
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js';
@@ -116,6 +117,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
] ]
: [extensionsCommand(this.config?.getEnableExtensionReloading())]), : [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand, helpCommand,
shortcutsCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand, rewindCommand,
await ideCommand(), await ideCommand(),
@@ -60,6 +60,7 @@ export const createMockCommandContext = (
setPendingItem: vi.fn(), setPendingItem: vi.fn(),
loadHistory: vi.fn(), loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(), toggleCorgiMode: vi.fn(),
toggleShortcutsHelp: vi.fn(),
toggleVimEnabled: vi.fn(), toggleVimEnabled: vi.fn(),
openAgentConfigDialog: vi.fn(), openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(), closeAgentConfigDialog: vi.fn(),
+1
View File
@@ -191,6 +191,7 @@ const mockUIActions: UIActions = {
handleApiKeySubmit: vi.fn(), handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(), handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(), setBannerVisible: vi.fn(),
setShortcutsHelpVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(), setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(), dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(), setActiveBackgroundShellPid: vi.fn(),
+7
View File
@@ -760,6 +760,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {}, () => {},
); );
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
const slashCommandActions = useMemo( const slashCommandActions = useMemo(
() => ({ () => ({
@@ -795,6 +796,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
} }
} }
}, },
toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible),
setText: stableSetText, setText: stableSetText,
}), }),
[ [
@@ -813,6 +815,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog, openPermissionsDialog,
addConfirmUpdateExtensionRequest, addConfirmUpdateExtensionRequest,
toggleDebugProfiler, toggleDebugProfiler,
setShortcutsHelpVisible,
stableSetText, stableSetText,
], ],
); );
@@ -1840,6 +1843,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlCPressedOnce: ctrlCPressCount >= 1,
ctrlDPressedOnce: ctrlDPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt, showEscapePrompt,
shortcutsHelpVisible,
isFocused, isFocused,
elapsedTime, elapsedTime,
currentLoadingPhrase, currentLoadingPhrase,
@@ -1945,6 +1949,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlCPressCount, ctrlCPressCount,
ctrlDPressCount, ctrlDPressCount,
showEscapePrompt, showEscapePrompt,
shortcutsHelpVisible,
isFocused, isFocused,
elapsedTime, elapsedTime,
currentLoadingPhrase, currentLoadingPhrase,
@@ -2044,6 +2049,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit, handleApiKeySubmit,
handleApiKeyCancel, handleApiKeyCancel,
setBannerVisible, setBannerVisible,
setShortcutsHelpVisible,
handleWarning, handleWarning,
setEmbeddedShellFocused, setEmbeddedShellFocused,
dismissBackgroundShell, dismissBackgroundShell,
@@ -2120,6 +2126,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit, handleApiKeySubmit,
handleApiKeyCancel, handleApiKeyCancel,
setBannerVisible, setBannerVisible,
setShortcutsHelpVisible,
handleWarning, handleWarning,
setEmbeddedShellFocused, setEmbeddedShellFocused,
dismissBackgroundShell, dismissBackgroundShell,
@@ -10,7 +10,6 @@ import { MessageType, type HistoryItemHelp } from '../types.js';
export const helpCommand: SlashCommand = { export const helpCommand: SlashCommand = {
name: 'help', name: 'help',
altNames: ['?'],
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
description: 'For help on gemini-cli', description: 'For help on gemini-cli',
autoExecute: true, autoExecute: true,
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const shortcutsCommand: SlashCommand = {
name: 'shortcuts',
altNames: [],
kind: CommandKind.BUILT_IN,
description: 'Toggle the shortcuts panel above the input',
autoExecute: true,
action: (context) => {
context.ui.toggleShortcutsHelp();
},
};
+1
View File
@@ -91,6 +91,7 @@ export interface CommandContext {
setConfirmationRequest: (value: ConfirmationRequest) => void; setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void; removeComponent: () => void;
toggleBackgroundShell: () => void; toggleBackgroundShell: () => void;
toggleShortcutsHelp: () => void;
}; };
// Session-specific data // Session-specific data
session: { session: {
@@ -24,7 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
})), })),
})); }));
import { ApprovalMode } from '@google/gemini-cli-core'; import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js'; import { StreamingState, ToolCallStatus } from '../types.js';
// Mock child components // Mock child components
vi.mock('./LoadingIndicator.js', () => ({ vi.mock('./LoadingIndicator.js', () => ({
@@ -49,6 +49,14 @@ vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>, ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
})); }));
vi.mock('./ShortcutsHint.js', () => ({
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
}));
vi.mock('./ShortcutsHelp.js', () => ({
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
}));
vi.mock('./DetailedMessagesDisplay.js', () => ({ vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>, DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
})); }));
@@ -95,7 +103,8 @@ vi.mock('../contexts/OverflowContext.js', () => ({
// Create mock context providers // Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState => const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({ ({
streamingState: null, streamingState: StreamingState.Idle,
isConfigInitialized: true,
contextFileNames: [], contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT, showApprovalModeIndicator: ApprovalMode.DEFAULT,
messageQueue: [], messageQueue: [],
@@ -116,6 +125,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ctrlCPressedOnce: false, ctrlCPressedOnce: false,
ctrlDPressedOnce: false, ctrlDPressedOnce: false,
showEscapePrompt: false, showEscapePrompt: false,
shortcutsHelpVisible: false,
ideContextState: null, ideContextState: null,
geminiMdFileCount: 0, geminiMdFileCount: 0,
renderMarkdown: true, renderMarkdown: true,
@@ -268,6 +278,19 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator'); expect(output).toContain('LoadingIndicator');
}); });
it('keeps shortcuts hint visible while loading', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('ShortcutsHint');
});
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => { it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
const uiState = createMockUIState({ const uiState = createMockUIState({
streamingState: StreamingState.Responding, streamingState: StreamingState.Responding,
@@ -284,7 +307,7 @@ describe('Composer', () => {
expect(output).not.toContain('Should not show'); expect(output).not.toContain('Should not show');
}); });
it('suppresses thought when waiting for confirmation', () => { it('does not render LoadingIndicator when waiting for confirmation', () => {
const uiState = createMockUIState({ const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation, streamingState: StreamingState.WaitingForConfirmation,
thought: { thought: {
@@ -296,8 +319,34 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState); const { lastFrame } = renderComposer(uiState);
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('LoadingIndicator'); expect(output).not.toContain('LoadingIndicator');
expect(output).not.toContain('Should not show during confirmation'); });
it('does not render LoadingIndicator when a tool confirmation is pending', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'edit',
description: 'edit file',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: undefined,
},
],
},
],
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
expect(output).not.toContain('esc to cancel');
}); });
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => { it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
@@ -444,7 +493,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState); const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ApprovalModeIndicator'); expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
}); });
it('shows ShellModeIndicator when shell mode is active', () => { it('shows ShellModeIndicator when shell mode is active', () => {
@@ -454,7 +503,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState); const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShellModeIndicator'); expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
}); });
it('shows RawMarkdownIndicator when renderMarkdown is false', () => { it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
+145 -37
View File
@@ -5,17 +5,20 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink'; import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js'; import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js'; import { StatusDisplay } from './StatusDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { ShortcutsHint } from './ShortcutsHint.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js'; import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js'; import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js'; import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { OverflowProvider } from '../contexts/OverflowContext.js'; import { OverflowProvider } from '../contexts/OverflowContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
@@ -25,9 +28,10 @@ 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 { ApprovalMode } from '@google/gemini-cli-core'; import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js'; import { StreamingState, 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 { theme } from '../semantic-colors.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig(); const config = useConfig();
@@ -46,6 +50,31 @@ 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) =>
item.type === 'tool_group' &&
item.tools.some((tool) => tool.status === ToolCallStatus.Confirming),
);
const hasPendingActionRequired =
hasPendingToolConfirmation ||
Boolean(uiState.commandConfirmationRequest) ||
Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.proQuotaRequest) ||
Boolean(uiState.validationRequest) ||
Boolean(uiState.customDialog);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const showApprovalIndicator =
showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
!uiState.shellModeActive;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
const showEscToCancelHint =
showLoadingIndicator &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;
return ( return (
<Box <Box
@@ -54,23 +83,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0} flexGrow={0}
flexShrink={0} flexShrink={0}
> >
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
/>
)}
{(!uiState.slashCommands || {(!uiState.slashCommands ||
!uiState.isConfigInitialized || !uiState.isConfigInitialized ||
uiState.isResuming) && ( uiState.isResuming) && (
@@ -83,25 +95,121 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
<TodoTray /> <TodoTray />
<Box <Box marginTop={1} width="100%" flexDirection="column">
marginTop={1} {showEscToCancelHint && (
justifyContent={ <Box marginLeft={3}>
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between' <Text color={theme.text.secondary}>esc to cancel</Text>
} </Box>
width="100%" )}
flexDirection={isNarrow ? 'column' : 'row'} <Box
alignItems={isNarrow ? 'flex-start' : 'center'} width="100%"
> flexDirection={isNarrow ? 'column' : 'row'}
<Box marginRight={1}> alignItems={isNarrow ? 'flex-start' : 'center'}
<StatusDisplay hideContextSummary={hideContextSummary} /> justifyContent={isNarrow ? 'flex-start' : 'space-between'}
</Box> >
<Box paddingTop={isNarrow ? 1 : 0}> <Box
{showApprovalModeIndicator !== ApprovalMode.DEFAULT && marginLeft={1}
!uiState.shellModeActive && ( marginRight={isNarrow ? 0 : 1}
<ApprovalModeIndicator approvalMode={showApprovalModeIndicator} /> flexDirection="row"
alignItems="center"
flexGrow={1}
>
{showLoadingIndicator && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
showCancelAndTimer={false}
/>
)} )}
{uiState.shellModeActive && <ShellModeIndicator />} </Box>
{!uiState.renderMarkdown && <RawMarkdownIndicator />} <Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
<ShortcutsHint />
</Box>
</Box>
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
<HorizontalLine width={uiState.terminalWidth} />
<Box
justifyContent={
settings.merged.ui.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{!showLoadingIndicator && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</Box>
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box> </Box>
</Box> </Box>
+39 -1
View File
@@ -151,7 +151,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const { merged: settings } = useSettings(); const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol(); const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState(); const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions(); const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions();
const { const {
terminalWidth, terminalWidth,
activePtyId, activePtyId,
@@ -159,6 +159,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalBackgroundColor, terminalBackgroundColor,
backgroundShells, backgroundShells,
backgroundShellHeight, backgroundShellHeight,
shortcutsHelpVisible,
} = useUIState(); } = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false); const [suppressCompletion, setSuppressCompletion] = useState(false);
const escPressCount = useRef(0); const escPressCount = useRef(0);
@@ -535,6 +536,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return false; return false;
} }
// Handle escape to close shortcuts panel first, before letting it bubble
// up for cancellation. This ensures pressing Escape once closes the panel,
// and pressing again cancels the operation.
if (shortcutsHelpVisible && key.name === 'escape') {
setShortcutsHelpVisible(false);
return true;
}
if ( if (
key.name === 'escape' && key.name === 'escape' &&
(streamingState === StreamingState.Responding || (streamingState === StreamingState.Responding ||
@@ -572,6 +581,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true; return true;
} }
if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) {
setShortcutsHelpVisible(false);
buffer.handleInput(key);
return true;
}
// Escape is handled earlier to ensure it closes the panel before
// potentially cancelling an operation
if (key.name === 'backspace' || key.sequence === '\b') {
setShortcutsHelpVisible(false);
return true;
}
if (key.insertable) {
setShortcutsHelpVisible(false);
}
}
if (
key.sequence === '?' &&
key.insertable &&
!shortcutsHelpVisible &&
buffer.text.length === 0
) {
setShortcutsHelpVisible(true);
return true;
}
if (vimHandleInput && vimHandleInput(key)) { if (vimHandleInput && vimHandleInput(key)) {
return true; return true;
} }
@@ -1044,6 +1080,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandSearchActive, commandSearchActive,
commandSearchCompletion, commandSearchCompletion,
kittyProtocol.enabled, kittyProtocol.enabled,
shortcutsHelpVisible,
setShortcutsHelpVisible,
tryLoadQueuedMessages, tryLoadQueuedMessages,
setBannerVisible, setBannerVisible,
onSubmit, onSubmit,
@@ -57,9 +57,9 @@ describe('<LoadingIndicator />', () => {
elapsedTime: 5, elapsedTime: 5,
}; };
it('should not render when streamingState is Idle', () => { it('should not render when streamingState is Idle and no loading phrase or thought', () => {
const { lastFrame } = renderWithContext( const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />, <LoadingIndicator elapsedTime={5} />,
StreamingState.Idle, StreamingState.Idle,
); );
expect(lastFrame()).toBe(''); expect(lastFrame()).toBe('');
@@ -143,10 +143,10 @@ describe('<LoadingIndicator />', () => {
it('should transition correctly between states using rerender', () => { it('should transition correctly between states using rerender', () => {
const { lastFrame, rerender, unmount } = renderWithContext( const { lastFrame, rerender, unmount } = renderWithContext(
<LoadingIndicator {...defaultProps} />, <LoadingIndicator elapsedTime={5} />,
StreamingState.Idle, StreamingState.Idle,
); );
expect(lastFrame()).toBe(''); // Initial: Idle expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding // Transition to Responding
rerender( rerender(
@@ -180,10 +180,10 @@ describe('<LoadingIndicator />', () => {
// Transition back to Idle // Transition back to Idle
rerender( rerender(
<StreamingContext.Provider value={StreamingState.Idle}> <StreamingContext.Provider value={StreamingState.Idle}>
<LoadingIndicator {...defaultProps} /> <LoadingIndicator elapsedTime={5} />
</StreamingContext.Provider>, </StreamingContext.Provider>,
); );
expect(lastFrame()).toBe(''); expect(lastFrame()).toBe(''); // Idle with no loading phrase
unmount(); unmount();
}); });
@@ -19,21 +19,29 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps { interface LoadingIndicatorProps {
currentLoadingPhrase?: string; currentLoadingPhrase?: string;
elapsedTime: number; elapsedTime: number;
inline?: boolean;
rightContent?: React.ReactNode; rightContent?: React.ReactNode;
thought?: ThoughtSummary | null; thought?: ThoughtSummary | null;
showCancelAndTimer?: boolean;
} }
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase, currentLoadingPhrase,
elapsedTime, elapsedTime,
inline = false,
rightContent, rightContent,
thought, thought,
showCancelAndTimer = true,
}) => { }) => {
const streamingState = useStreamingContext(); const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize(); const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth); const isNarrow = isNarrowWidth(terminalWidth);
if (streamingState === StreamingState.Idle) { if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
!thought
) {
return null; return null;
} }
@@ -45,10 +53,38 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
: thought?.subject || currentLoadingPhrase; : thought?.subject || currentLoadingPhrase;
const cancelAndTimerContent = const cancelAndTimerContent =
showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})` ? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null; : null;
if (inline) {
return (
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
{primaryText}
</Text>
)}
{cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
</Box>
);
}
return ( return (
<Box paddingLeft={0} flexDirection="column"> <Box paddingLeft={0} flexDirection="column">
{/* Main loading line */} {/* Main loading line */}
@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { theme } from '../semantic-colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { SectionHeader } from './shared/SectionHeader.js';
type ShortcutItem = {
key: string;
description: string;
};
const buildShortcutRows = (): ShortcutItem[][] => {
const isMac = process.platform === 'darwin';
const altLabel = isMac ? 'Option' : 'Alt';
return [
[
{ key: '!', description: 'shell mode' },
{
key: 'Shift+Tab',
description: 'cycle mode',
},
{ key: 'Ctrl+V', description: 'paste images' },
],
[
{ key: '@', description: 'select file or folder' },
{ key: 'Ctrl+Y', description: 'YOLO mode' },
{ key: 'Ctrl+R', description: 'reverse-search history' },
],
[
{ key: 'Esc Esc', description: 'clear prompt / rewind' },
{ key: `${altLabel}+M`, description: 'raw markdown mode' },
{ key: 'Ctrl+X', description: 'open external editor' },
],
];
};
const renderItem = (item: ShortcutItem) => `${item.key} ${item.description}`;
const splitLongWord = (word: string, width: number) => {
if (width <= 0) return [''];
const parts: string[] = [];
let current = '';
for (const char of word) {
const next = current + char;
if (stringWidth(next) <= width) {
current = next;
continue;
}
if (current) {
parts.push(current);
}
current = char;
}
if (current) {
parts.push(current);
}
return parts.length > 0 ? parts : [''];
};
const wrapText = (text: string, width: number) => {
if (width <= 0) return [''];
const words = text.split(' ');
const lines: string[] = [];
let current = '';
for (const word of words) {
if (stringWidth(word) > width) {
if (current) {
lines.push(current);
current = '';
}
const chunks = splitLongWord(word, width);
for (const chunk of chunks) {
lines.push(chunk);
}
continue;
}
const next = current ? `${current} ${word}` : word;
if (stringWidth(next) <= width) {
current = next;
continue;
}
if (current) {
lines.push(current);
}
current = word;
}
if (current) {
lines.push(current);
}
return lines.length > 0 ? lines : [''];
};
const wrapDescription = (key: string, description: string, width: number) => {
const keyWidth = stringWidth(key);
const availableWidth = Math.max(1, width - keyWidth - 1);
const wrapped = wrapText(description, availableWidth);
return wrapped.length > 0 ? wrapped : [''];
};
const padToWidth = (text: string, width: number) => {
const padSize = Math.max(0, width - stringWidth(text));
return text + ' '.repeat(padSize);
};
export const ShortcutsHelp: React.FC = () => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const shortcutRows = buildShortcutRows();
const leftInset = 1;
const rightInset = 2;
const gap = 2;
const contentWidth = Math.max(1, terminalWidth - leftInset - rightInset);
const columnWidth = Math.max(18, Math.floor((contentWidth - gap * 2) / 3));
const keyColor = theme.text.accent;
if (isNarrow) {
return (
<Box flexDirection="column">
<SectionHeader title="Shortcuts (for more, see /help)" />
{shortcutRows.flat().map((item, index) => {
const descriptionLines = wrapDescription(
item.key,
item.description,
contentWidth,
);
const keyWidth = stringWidth(item.key);
return descriptionLines.map((line, lineIndex) => {
const rightPadding = Math.max(
0,
contentWidth - (keyWidth + 1 + stringWidth(line)),
);
return (
<Text
key={`${item.key}-${index}-${lineIndex}`}
color={theme.text.primary}
>
{lineIndex === 0 ? (
<>
{' '.repeat(leftInset)}
<Text color={keyColor}>{item.key}</Text> {line}
{' '.repeat(rightPadding + rightInset)}
</>
) : (
`${' '.repeat(leftInset)}${padToWidth(
`${' '.repeat(keyWidth + 1)}${line}`,
contentWidth,
)}${' '.repeat(rightInset)}`
)}
</Text>
);
});
})}
</Box>
);
}
return (
<Box flexDirection="column">
<SectionHeader title="Shortcuts (for more, see /help)" />
{shortcutRows.map((row, rowIndex) => {
const cellLines = row.map((item) =>
wrapText(renderItem(item), columnWidth),
);
const lineCount = Math.max(...cellLines.map((lines) => lines.length));
return Array.from({ length: lineCount }).map((_, lineIndex) => {
const segments = row.map((item, colIndex) => {
const lineText = cellLines[colIndex][lineIndex] ?? '';
const keyWidth = stringWidth(item.key);
if (lineIndex === 0) {
const rest = lineText.slice(item.key.length);
const restPadded = padToWidth(
rest,
Math.max(0, columnWidth - keyWidth),
);
return (
<Text key={`${item.key}-${colIndex}`}>
<Text color={keyColor}>{item.key}</Text>
{restPadded}
</Text>
);
}
const spacer = ' '.repeat(keyWidth);
const padded = padToWidth(`${spacer}${lineText}`, columnWidth);
return <Text key={`${item.key}-${colIndex}`}>{padded}</Text>;
});
return (
<Box
key={`row-${rowIndex}-line-${lineIndex}`}
width={terminalWidth}
flexDirection="row"
>
<Box width={leftInset}>
<Text>{' '.repeat(leftInset)}</Text>
</Box>
<Box width={columnWidth}>{segments[0]}</Box>
<Box width={gap}>
<Text>{' '.repeat(gap)}</Text>
</Box>
<Box width={columnWidth}>{segments[1]}</Box>
<Box width={gap}>
<Text>{' '.repeat(gap)}</Text>
</Box>
<Box width={columnWidth}>{segments[2]}</Box>
<Box width={rightInset}>
<Text>{' '.repeat(rightInset)}</Text>
</Box>
</Box>
);
});
})}
</Box>
);
};
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
export const ShortcutsHint: React.FC = () => {
const { shortcutsHelpVisible } = useUIState();
const highlightColor = shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return <Text color={highlightColor}> ? for shortcuts </Text>;
};
@@ -43,6 +43,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
warningMessage: null, warningMessage: null,
ctrlDPressedOnce: false, ctrlDPressedOnce: false,
showEscapePrompt: false, showEscapePrompt: false,
shortcutsHelpVisible: false,
queueErrorMessage: null, queueErrorMessage: null,
activeHooks: [], activeHooks: [],
ideContextState: null, ideContextState: null,
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { theme } from '../../semantic-colors.js';
interface HorizontalLineProps {
width?: number;
color?: string;
}
export const HorizontalLine: React.FC<HorizontalLineProps> = ({
width,
color = theme.border.default,
}) => {
const { columns } = useTerminalSize();
const resolvedWidth = Math.max(1, width ?? columns);
return <Text color={color}>{'─'.repeat(resolvedWidth)}</Text>;
};
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import stringWidth from 'string-width';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { theme } from '../../semantic-colors.js';
const buildHeaderLine = (title: string, width: number) => {
const prefix = `── ${title} `;
const prefixWidth = stringWidth(prefix);
if (width <= prefixWidth) {
return prefix.slice(0, Math.max(0, width));
}
return prefix + '─'.repeat(Math.max(0, width - prefixWidth));
};
export const SectionHeader: React.FC<{ title: string; width?: number }> = ({
title,
width,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const resolvedWidth = Math.max(10, width ?? terminalWidth);
const text = buildHeaderLine(title, resolvedWidth);
return <Text color={theme.text.secondary}>{text}</Text>;
};
@@ -67,6 +67,7 @@ export interface UIActions {
handleApiKeySubmit: (apiKey: string) => Promise<void>; handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void; handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void; setBannerVisible: (visible: boolean) => void;
setShortcutsHelpVisible: (visible: boolean) => void;
handleWarning: (message: string) => void; handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void; setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void; dismissBackgroundShell: (pid: number) => void;
@@ -108,6 +108,7 @@ export interface UIState {
ctrlCPressedOnce: boolean; ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean; ctrlDPressedOnce: boolean;
showEscapePrompt: boolean; showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
elapsedTime: number; elapsedTime: number;
currentLoadingPhrase: string; currentLoadingPhrase: string;
historyRemountKey: number; historyRemountKey: number;
@@ -214,6 +214,7 @@ describe('useSlashCommandProcessor', () => {
dispatchExtensionStateUpdate: vi.fn(), dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundShell: vi.fn(), toggleBackgroundShell: vi.fn(),
toggleShortcutsHelp: vi.fn(),
setText: vi.fn(), setText: vi.fn(),
}, },
new Map(), // extensionsUpdateState new Map(), // extensionsUpdateState
@@ -83,6 +83,7 @@ interface SlashCommandProcessorActions {
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundShell: () => void; toggleBackgroundShell: () => void;
toggleShortcutsHelp: () => void;
setText: (text: string) => void; setText: (text: string) => void;
} }
@@ -240,6 +241,7 @@ export const useSlashCommandProcessor = (
setConfirmationRequest, setConfirmationRequest,
removeComponent: () => setCustomDialog(null), removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell, toggleBackgroundShell: actions.toggleBackgroundShell,
toggleShortcutsHelp: actions.toggleShortcutsHelp,
}, },
session: { session: {
stats: session.stats, stats: session.stats,
@@ -31,5 +31,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
setConfirmationRequest: (_request) => {}, setConfirmationRequest: (_request) => {},
removeComponent: () => {}, removeComponent: () => {},
toggleBackgroundShell: () => {}, toggleBackgroundShell: () => {},
toggleShortcutsHelp: () => {},
}; };
} }