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