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: () => {}, }; }