mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
Add shortcuts hint and panel for discoverability (#18035)
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -91,6 +91,7 @@ export interface CommandContext {
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -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: () => <Text>ShellModeIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHint.js', () => ({
|
||||
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHelp.js', () => ({
|
||||
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./DetailedMessagesDisplay.js', () => ({
|
||||
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
|
||||
}));
|
||||
@@ -95,7 +103,8 @@ vi.mock('../contexts/OverflowContext.js', () => ({
|
||||
// Create mock context providers
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
streamingState: null,
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfigInitialized: true,
|
||||
contextFileNames: [],
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
messageQueue: [],
|
||||
@@ -116,6 +125,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): 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', () => {
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
@@ -54,23 +83,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexGrow={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.isConfigInitialized ||
|
||||
uiState.isResuming) && (
|
||||
@@ -83,25 +95,121 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
<TodoTray />
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box marginRight={1}>
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<ApprovalModeIndicator approvalMode={showApprovalModeIndicator} />
|
||||
<Box marginTop={1} width="100%" flexDirection="column">
|
||||
{showEscToCancelHint && (
|
||||
<Box marginLeft={3}>
|
||||
<Text color={theme.text.secondary}>esc to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
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 />}
|
||||
{!uiState.renderMarkdown && <RawMarkdownIndicator />}
|
||||
</Box>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
terminalBackgroundColor,
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
shortcutsHelpVisible,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
@@ -535,6 +536,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
kittyProtocol.enabled,
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
tryLoadQueuedMessages,
|
||||
setBannerVisible,
|
||||
onSubmit,
|
||||
|
||||
@@ -57,9 +57,9 @@ describe('<LoadingIndicator />', () => {
|
||||
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(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
@@ -143,10 +143,10 @@ describe('<LoadingIndicator />', () => {
|
||||
|
||||
it('should transition correctly between states using rerender', () => {
|
||||
const { lastFrame, rerender, unmount } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe(''); // Initial: Idle
|
||||
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
|
||||
// Transition to Responding
|
||||
rerender(
|
||||
@@ -180,10 +180,10 @@ describe('<LoadingIndicator />', () => {
|
||||
// Transition back to Idle
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.Idle}>
|
||||
<LoadingIndicator {...defaultProps} />
|
||||
<LoadingIndicator elapsedTime={5} />
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
expect(lastFrame()).toBe(''); // Idle with no loading phrase
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<LoadingIndicatorProps> = ({
|
||||
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<LoadingIndicatorProps> = ({
|
||||
: thought?.subject || currentLoadingPhrase;
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
streamingState !== StreamingState.WaitingForConfirmation
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
: 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 (
|
||||
<Box paddingLeft={0} flexDirection="column">
|
||||
{/* 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,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
shortcutsHelpVisible: false,
|
||||
queueErrorMessage: null,
|
||||
activeHooks: [],
|
||||
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>;
|
||||
handleApiKeyCancel: () => void;
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => void;
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface UIState {
|
||||
ctrlCPressedOnce: boolean;
|
||||
ctrlDPressedOnce: boolean;
|
||||
showEscapePrompt: boolean;
|
||||
shortcutsHelpVisible: boolean;
|
||||
elapsedTime: number;
|
||||
currentLoadingPhrase: string;
|
||||
historyRemountKey: number;
|
||||
|
||||
@@ -214,6 +214,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
},
|
||||
new Map(), // extensionsUpdateState
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,5 +31,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
toggleShortcutsHelp: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user