diff --git a/docs/cli/commands.md b/docs/cli/commands.md
index 5dec6fb5db..6e563cda11 100644
--- a/docs/cli/commands.md
+++ b/docs/cli/commands.md
@@ -113,10 +113,14 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Lists all active extensions in the current Gemini CLI
session. See [Gemini CLI Extensions](../extensions/index.md).
-- **`/help`** (or **`/?`**)
+- **`/help`**
- **Description:** Display help information about Gemini CLI, including
available commands and their usage.
+- **`/shortcuts`**
+ - **Description:** Toggle the shortcuts panel above the input.
+ - **Shortcut:** Press `?` when the prompt is empty.
+
- **`/hooks`**
- **Description:** Manage hooks, which allow you to intercept and customize
Gemini CLI behavior at specific lifecycle events.
diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md
index 69ab0af2a1..f6cd545438 100644
--- a/docs/cli/keyboard-shortcuts.md
+++ b/docs/cli/keyboard-shortcuts.md
@@ -128,6 +128,9 @@ available combinations.
- `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.
- `!` 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
mode.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts
index 2f7a2a5c8a..1246ee0532 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.test.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts
@@ -85,6 +85,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: () => ({}),
}));
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/modelCommand.js', () => ({
modelCommand: { name: 'model' },
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 3c9b09e739..0ae9ef3598 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
+import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
@@ -116,6 +117,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
]
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
+ shortcutsCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
await ideCommand(),
diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts
index 928d04c7a1..b3dc0b9f7f 100644
--- a/packages/cli/src/test-utils/mockCommandContext.ts
+++ b/packages/cli/src/test-utils/mockCommandContext.ts
@@ -60,6 +60,7 @@ export const createMockCommandContext = (
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
+ toggleShortcutsHelp: vi.fn(),
toggleVimEnabled: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index e3aeca6e45..c0bcfd6b95 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -191,6 +191,7 @@ const mockUIActions: UIActions = {
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
+ setShortcutsHelpVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 3ae3b3c87f..84b51e5f2d 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -760,6 +760,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
+ const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
const slashCommandActions = useMemo(
() => ({
@@ -795,6 +796,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
},
+ toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible),
setText: stableSetText,
}),
[
@@ -813,6 +815,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
+ setShortcutsHelpVisible,
stableSetText,
],
);
@@ -1840,6 +1843,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlCPressedOnce: ctrlCPressCount >= 1,
ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt,
+ shortcutsHelpVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
@@ -1945,6 +1949,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlCPressCount,
ctrlDPressCount,
showEscapePrompt,
+ shortcutsHelpVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
@@ -2044,6 +2049,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
+ setShortcutsHelpVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
@@ -2120,6 +2126,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
+ setShortcutsHelpVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts
index cacebafe01..ce2ff36d9c 100644
--- a/packages/cli/src/ui/commands/helpCommand.ts
+++ b/packages/cli/src/ui/commands/helpCommand.ts
@@ -10,7 +10,6 @@ import { MessageType, type HistoryItemHelp } from '../types.js';
export const helpCommand: SlashCommand = {
name: 'help',
- altNames: ['?'],
kind: CommandKind.BUILT_IN,
description: 'For help on gemini-cli',
autoExecute: true,
diff --git a/packages/cli/src/ui/commands/shortcutsCommand.ts b/packages/cli/src/ui/commands/shortcutsCommand.ts
new file mode 100644
index 0000000000..49dc869e6b
--- /dev/null
+++ b/packages/cli/src/ui/commands/shortcutsCommand.ts
@@ -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();
+ },
+};
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
index c01bee21d5..2cbb9da9a7 100644
--- a/packages/cli/src/ui/commands/types.ts
+++ b/packages/cli/src/ui/commands/types.ts
@@ -91,6 +91,7 @@ export interface CommandContext {
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
+ toggleShortcutsHelp: () => void;
};
// Session-specific data
session: {
diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
index 1d97c978d2..d9094c6ae5 100644
--- a/packages/cli/src/ui/components/Composer.test.tsx
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -24,7 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
})),
}));
import { ApprovalMode } from '@google/gemini-cli-core';
-import { StreamingState } from '../types.js';
+import { StreamingState, ToolCallStatus } from '../types.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -49,6 +49,14 @@ vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => ShellModeIndicator,
}));
+vi.mock('./ShortcutsHint.js', () => ({
+ ShortcutsHint: () => ShortcutsHint,
+}));
+
+vi.mock('./ShortcutsHelp.js', () => ({
+ ShortcutsHelp: () => ShortcutsHelp,
+}));
+
vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => DetailedMessagesDisplay,
}));
@@ -95,7 +103,8 @@ vi.mock('../contexts/OverflowContext.js', () => ({
// Create mock context providers
const createMockUIState = (overrides: Partial = {}): UIState =>
({
- streamingState: null,
+ streamingState: StreamingState.Idle,
+ isConfigInitialized: true,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
messageQueue: [],
@@ -116,6 +125,7 @@ const createMockUIState = (overrides: Partial = {}): UIState =>
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
+ shortcutsHelpVisible: false,
ideContextState: null,
geminiMdFileCount: 0,
renderMarkdown: true,
@@ -268,6 +278,19 @@ describe('Composer', () => {
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', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
@@ -284,7 +307,7 @@ describe('Composer', () => {
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({
streamingState: StreamingState.WaitingForConfirmation,
thought: {
@@ -296,8 +319,34 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
- expect(output).toContain('LoadingIndicator');
- expect(output).not.toContain('Should not show during confirmation');
+ expect(output).not.toContain('LoadingIndicator');
+ });
+
+ 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', () => {
@@ -444,7 +493,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
- expect(lastFrame()).toContain('ApprovalModeIndicator');
+ expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
});
it('shows ShellModeIndicator when shell mode is active', () => {
@@ -454,7 +503,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
- expect(lastFrame()).toContain('ShellModeIndicator');
+ expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
});
it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index d366516a94..57afdde943 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -5,17 +5,20 @@
*/
import { useState } from 'react';
-import { Box, useIsScreenReaderEnabled } from 'ink';
+import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
+import { ShortcutsHint } from './ShortcutsHint.js';
+import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
+import { HorizontalLine } from './shared/HorizontalLine.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -25,9 +28,10 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
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 { TodoTray } from './messages/Todo.js';
+import { theme } from '../semantic-colors.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig();
@@ -46,6 +50,31 @@ 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 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 (
{
flexGrow={0}
flexShrink={0}
>
- {(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
-
- )}
-
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
@@ -83,25 +95,121 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
-
-
-
-
-
- {showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
- !uiState.shellModeActive && (
-
+
+ {showEscToCancelHint && (
+
+ esc to cancel
+
+ )}
+
+
+ {showLoadingIndicator && (
+
)}
- {uiState.shellModeActive && }
- {!uiState.renderMarkdown && }
+
+
+
+
+
+ {uiState.shortcutsHelpVisible && }
+
+
+
+ {!showLoadingIndicator && (
+
+ {showApprovalIndicator && (
+
+ )}
+ {uiState.shellModeActive && (
+
+
+
+ )}
+ {showRawMarkdownIndicator && (
+
+
+
+ )}
+
+ )}
+
+
+
+ {!showLoadingIndicator && (
+
+ )}
+
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 62f6f18e15..df50365400 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -151,7 +151,7 @@ export const InputPrompt: React.FC = ({
const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
- const { setEmbeddedShellFocused } = useUIActions();
+ const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions();
const {
terminalWidth,
activePtyId,
@@ -159,6 +159,7 @@ export const InputPrompt: React.FC = ({
terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
+ shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const escPressCount = useRef(0);
@@ -535,6 +536,14 @@ export const InputPrompt: React.FC = ({
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 (
key.name === 'escape' &&
(streamingState === StreamingState.Responding ||
@@ -572,6 +581,33 @@ export const InputPrompt: React.FC = ({
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)) {
return true;
}
@@ -1044,6 +1080,8 @@ export const InputPrompt: React.FC = ({
commandSearchActive,
commandSearchCompletion,
kittyProtocol.enabled,
+ shortcutsHelpVisible,
+ setShortcutsHelpVisible,
tryLoadQueuedMessages,
setBannerVisible,
onSubmit,
diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
index f56fe80039..e76c4d49f3 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
@@ -57,9 +57,9 @@ describe('', () => {
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(
- ,
+ ,
StreamingState.Idle,
);
expect(lastFrame()).toBe('');
@@ -143,10 +143,10 @@ describe('', () => {
it('should transition correctly between states using rerender', () => {
const { lastFrame, rerender, unmount } = renderWithContext(
- ,
+ ,
StreamingState.Idle,
);
- expect(lastFrame()).toBe(''); // Initial: Idle
+ expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
rerender(
@@ -180,10 +180,10 @@ describe('', () => {
// Transition back to Idle
rerender(
-
+
,
);
- expect(lastFrame()).toBe('');
+ expect(lastFrame()).toBe(''); // Idle with no loading phrase
unmount();
});
diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx
index 4917946d3a..18e71b7a4b 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.tsx
@@ -19,21 +19,29 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
elapsedTime: number;
+ inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
+ showCancelAndTimer?: boolean;
}
export const LoadingIndicator: React.FC = ({
currentLoadingPhrase,
elapsedTime,
+ inline = false,
rightContent,
thought,
+ showCancelAndTimer = true,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
- if (streamingState === StreamingState.Idle) {
+ if (
+ streamingState === StreamingState.Idle &&
+ !currentLoadingPhrase &&
+ !thought
+ ) {
return null;
}
@@ -45,10 +53,38 @@ export const LoadingIndicator: React.FC = ({
: thought?.subject || currentLoadingPhrase;
const cancelAndTimerContent =
+ showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
+ if (inline) {
+ return (
+
+
+
+
+ {primaryText && (
+
+ {primaryText}
+
+ )}
+ {cancelAndTimerContent && (
+ <>
+
+ {cancelAndTimerContent}
+ >
+ )}
+
+ );
+ }
+
return (
{/* Main loading line */}
diff --git a/packages/cli/src/ui/components/ShortcutsHelp.tsx b/packages/cli/src/ui/components/ShortcutsHelp.tsx
new file mode 100644
index 0000000000..8efcb646a1
--- /dev/null
+++ b/packages/cli/src/ui/components/ShortcutsHelp.tsx
@@ -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 (
+
+
+ {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 (
+
+ {lineIndex === 0 ? (
+ <>
+ {' '.repeat(leftInset)}
+ {item.key} {line}
+ {' '.repeat(rightPadding + rightInset)}
+ >
+ ) : (
+ `${' '.repeat(leftInset)}${padToWidth(
+ `${' '.repeat(keyWidth + 1)}${line}`,
+ contentWidth,
+ )}${' '.repeat(rightInset)}`
+ )}
+
+ );
+ });
+ })}
+
+ );
+ }
+
+ return (
+
+
+ {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 (
+
+ {item.key}
+ {restPadded}
+
+ );
+ }
+
+ const spacer = ' '.repeat(keyWidth);
+ const padded = padToWidth(`${spacer}${lineText}`, columnWidth);
+ return {padded};
+ });
+
+ return (
+
+
+ {' '.repeat(leftInset)}
+
+ {segments[0]}
+
+ {' '.repeat(gap)}
+
+ {segments[1]}
+
+ {' '.repeat(gap)}
+
+ {segments[2]}
+
+ {' '.repeat(rightInset)}
+
+
+ );
+ });
+ })}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ShortcutsHint.tsx b/packages/cli/src/ui/components/ShortcutsHint.tsx
new file mode 100644
index 0000000000..70b72e902e
--- /dev/null
+++ b/packages/cli/src/ui/components/ShortcutsHint.tsx
@@ -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 ? for shortcuts ;
+};
diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx
index e7f3e1fff9..6c3eb42248 100644
--- a/packages/cli/src/ui/components/StatusDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx
@@ -43,6 +43,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
warningMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
+ shortcutsHelpVisible: false,
queueErrorMessage: null,
activeHooks: [],
ideContextState: null,
diff --git a/packages/cli/src/ui/components/shared/HorizontalLine.tsx b/packages/cli/src/ui/components/shared/HorizontalLine.tsx
new file mode 100644
index 0000000000..3d9bacbb44
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/HorizontalLine.tsx
@@ -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 = ({
+ width,
+ color = theme.border.default,
+}) => {
+ const { columns } = useTerminalSize();
+ const resolvedWidth = Math.max(1, width ?? columns);
+
+ return {'─'.repeat(resolvedWidth)};
+};
diff --git a/packages/cli/src/ui/components/shared/SectionHeader.tsx b/packages/cli/src/ui/components/shared/SectionHeader.tsx
new file mode 100644
index 0000000000..83a698afc1
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/SectionHeader.tsx
@@ -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};
+};
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index 3852dc887d..a0dd1b3152 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -67,6 +67,7 @@ export interface UIActions {
handleApiKeySubmit: (apiKey: string) => Promise;
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
+ setShortcutsHelpVisible: (visible: boolean) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void;
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index 5ba697c85d..45111a29cc 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -108,6 +108,7 @@ export interface UIState {
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
+ shortcutsHelpVisible: boolean;
elapsedTime: number;
currentLoadingPhrase: string;
historyRemountKey: number;
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
index 9d963a9e63..049720d58a 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
@@ -214,6 +214,7 @@ describe('useSlashCommandProcessor', () => {
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundShell: vi.fn(),
+ toggleShortcutsHelp: vi.fn(),
setText: vi.fn(),
},
new Map(), // extensionsUpdateState
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index acd7749d5d..c6d5f1decc 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -83,6 +83,7 @@ interface SlashCommandProcessorActions {
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundShell: () => void;
+ toggleShortcutsHelp: () => void;
setText: (text: string) => void;
}
@@ -240,6 +241,7 @@ export const useSlashCommandProcessor = (
setConfirmationRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell,
+ toggleShortcutsHelp: actions.toggleShortcutsHelp,
},
session: {
stats: session.stats,
diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
index aca12dc306..8daa3a8a0a 100644
--- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
+++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
@@ -31,5 +31,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
setConfirmationRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundShell: () => {},
+ toggleShortcutsHelp: () => {},
};
}