summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components')
-rw-r--r--packages/cli/src/ui/components/Help.tsx26
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx223
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx292
3 files changed, 367 insertions, 174 deletions
diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx
index 3a3d7bd1..5a04514a 100644
--- a/packages/cli/src/ui/components/Help.tsx
+++ b/packages/cli/src/ui/components/Help.tsx
@@ -7,7 +7,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
-import { SlashCommand } from '../hooks/slashCommandProcessor.js';
+import { SlashCommand } from '../commands/types.js';
interface Help {
commands: SlashCommand[];
@@ -67,13 +67,25 @@ export const Help: React.FC<Help> = ({ commands }) => (
{commands
.filter((command) => command.description)
.map((command: SlashCommand) => (
- <Text key={command.name} color={Colors.Foreground}>
- <Text bold color={Colors.AccentPurple}>
- {' '}
- /{command.name}
+ <Box key={command.name} flexDirection="column">
+ <Text color={Colors.Foreground}>
+ <Text bold color={Colors.AccentPurple}>
+ {' '}
+ /{command.name}
+ </Text>
+ {command.description && ' - ' + command.description}
</Text>
- {command.description && ' - ' + command.description}
- </Text>
+ {command.subCommands &&
+ command.subCommands.map((subCommand) => (
+ <Text key={subCommand.name} color={Colors.Foreground}>
+ <Text> </Text>
+ <Text bold color={Colors.AccentPurple}>
+ {subCommand.name}
+ </Text>
+ {subCommand.description && ' - ' + subCommand.description}
+ </Text>
+ ))}
+ </Box>
))}
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 7d0cfcbb..6f3f996d 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -8,10 +8,12 @@ import { render } from 'ink-testing-library';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@google/gemini-cli-core';
+import { CommandContext, SlashCommand } from '../commands/types.js';
import { vi } from 'vitest';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
@@ -21,12 +23,38 @@ type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
type MockedUseCompletion = ReturnType<typeof useCompletion>;
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
+const mockSlashCommands: SlashCommand[] = [
+ { name: 'clear', description: 'Clear screen', action: vi.fn() },
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory', action: vi.fn() },
+ { name: 'add', description: 'Add to memory', action: vi.fn() },
+ { name: 'refresh', description: 'Refresh memory', action: vi.fn() },
+ ],
+ },
+ {
+ name: 'chat',
+ description: 'Manage chats',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a chat',
+ action: vi.fn(),
+ completion: async () => ['fix-foo', 'fix-bar'],
+ },
+ ],
+ },
+];
+
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: MockedUseShellHistory;
let mockCompletion: MockedUseCompletion;
let mockInputHistory: MockedUseInputHistory;
let mockBuffer: TextBuffer;
+ let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCompletion = vi.mocked(useCompletion);
@@ -35,6 +63,8 @@ describe('InputPrompt', () => {
beforeEach(() => {
vi.resetAllMocks();
+ mockCommandContext = createMockCommandContext();
+
mockBuffer = {
text: '',
cursor: [0, 0],
@@ -99,12 +129,15 @@ describe('InputPrompt', () => {
getTargetDir: () => '/test/project/src',
} as unknown as Config,
slashCommands: [],
+ commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
};
+
+ props.slashCommands = mockSlashCommands;
});
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -184,4 +217,194 @@ describe('InputPrompt', () => {
expect(props.onSubmit).toHaveBeenCalledWith('some text');
unmount();
});
+
+ it('should complete a partial parent command and add a space', async () => {
+ // SCENARIO: /mem -> Tab
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/mem');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
+ unmount();
+ });
+
+ it('should append a sub-command when the parent command is already complete with a space', async () => {
+ // SCENARIO: /memory -> Tab (to accept 'add')
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'show', value: 'show' },
+ { label: 'add', value: 'add' },
+ ],
+ activeSuggestionIndex: 1, // 'add' is highlighted
+ });
+ props.buffer.setText('/memory ');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
+ unmount();
+ });
+
+ it('should handle the "backspace" edge case correctly', async () => {
+ // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
+ // This is the critical bug we fixed.
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'show', value: 'show' },
+ { label: 'add', value: 'add' },
+ ],
+ activeSuggestionIndex: 0, // 'show' is highlighted
+ });
+ // The user has backspaced, so the query is now just '/memory'
+ props.buffer.setText('/memory');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ // It should NOT become '/show '. It should correctly become '/memory show '.
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
+ unmount();
+ });
+
+ it('should complete a partial argument for a command', async () => {
+ // SCENARIO: /chat resume fi- -> Tab
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/chat resume fi-');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
+ unmount();
+ });
+
+ it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'memory', value: 'memory' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/mem');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r');
+ await wait();
+
+ // The app should autocomplete the text, NOT submit.
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should complete a command based on its altName', async () => {
+ // Add a command with an altName to our mock for this test
+ props.slashCommands.push({
+ name: 'help',
+ altName: '?',
+ description: '...',
+ });
+
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'help', value: 'help' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/?');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
+ unmount();
+ });
+
+ // ADD this test for defensive coverage
+
+ it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
+ props.buffer.setText(' '); // Set buffer to whitespace
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r'); // Press Enter
+ await wait();
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should submit directly on Enter when a complete leaf command is typed', async () => {
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: false,
+ });
+ props.buffer.setText('/clear');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r');
+ await wait();
+
+ expect(props.onSubmit).toHaveBeenCalledWith('/clear');
+ expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
+ unmount();
+ });
+
+ it('should autocomplete an @-path on Enter without submitting', async () => {
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'index.ts', value: 'index.ts' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('@src/components/');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r');
+ await wait();
+
+ expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
});
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 763d4e7e..3771f5b9 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -13,12 +13,11 @@ import { TextBuffer } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
-import process from 'node:process';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
-import { SlashCommand } from '../hooks/slashCommandProcessor.js';
+import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core';
export interface InputPromptProps {
@@ -26,8 +25,9 @@ export interface InputPromptProps {
onSubmit: (value: string) => void;
userMessages: readonly string[];
onClearScreen: () => void;
- config: Config; // Added config for useCompletion
- slashCommands: SlashCommand[]; // Added slashCommands for useCompletion
+ config: Config;
+ slashCommands: SlashCommand[];
+ commandContext: CommandContext;
placeholder?: string;
focus?: boolean;
inputWidth: number;
@@ -43,6 +43,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onClearScreen,
config,
slashCommands,
+ commandContext,
placeholder = ' Type your message or @path/to/file',
focus = true,
inputWidth,
@@ -57,6 +58,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
config.getTargetDir(),
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
slashCommands,
+ commandContext,
config,
);
@@ -116,28 +118,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const suggestion = completionSuggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
- const parts = query.trimStart().substring(1).split(' ');
- const commandName = parts[0];
- const slashIndex = query.indexOf('/');
- const base = query.substring(0, slashIndex + 1);
+ const hasTrailingSpace = query.endsWith(' ');
+ const parts = query
+ .trimStart()
+ .substring(1)
+ .split(/\s+/)
+ .filter(Boolean);
- const command = slashCommands.find((cmd) => cmd.name === commandName);
- // Make sure completion isn't the original command when command.completion hasn't happened yet.
- if (command && command.completion && suggestion !== commandName) {
- const newValue = `${base}${commandName} ${suggestion}`;
- if (newValue === query) {
- handleSubmitAndClear(newValue);
- } else {
- buffer.setText(newValue);
- }
- } else {
- const newValue = base + suggestion;
- if (newValue === query) {
- handleSubmitAndClear(newValue);
- } else {
- buffer.setText(newValue);
+ let isParentPath = false;
+ // If there's no trailing space, we need to check if the current query
+ // is already a complete path to a parent command.
+ if (!hasTrailingSpace) {
+ let currentLevel: SlashCommand[] | undefined = slashCommands;
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const found: SlashCommand | undefined = currentLevel?.find(
+ (cmd) => cmd.name === part || cmd.altName === part,
+ );
+
+ if (found) {
+ if (i === parts.length - 1 && found.subCommands) {
+ isParentPath = true;
+ }
+ currentLevel = found.subCommands;
+ } else {
+ // Path is invalid, so it can't be a parent path.
+ currentLevel = undefined;
+ break;
+ }
}
}
+
+ // Determine the base path of the command.
+ // - If there's a trailing space, the whole command is the base.
+ // - If it's a known parent path, the whole command is the base.
+ // - Otherwise, the base is everything EXCEPT the last partial part.
+ const basePath =
+ hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
+ const newValue = `/${[...basePath, suggestion].join(' ')} `;
+
+ buffer.setText(newValue);
} else {
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) return;
@@ -155,13 +175,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
resetCompletionState();
},
- [
- resetCompletionState,
- handleSubmitAndClear,
- buffer,
- completionSuggestions,
- slashCommands,
- ],
+ [resetCompletionState, buffer, completionSuggestions, slashCommands],
);
const handleInput = useCallback(
@@ -169,12 +183,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (!focus) {
return;
}
- const query = buffer.text;
- if (key.sequence === '!' && query === '' && !completion.showSuggestions) {
+ if (
+ key.sequence === '!' &&
+ buffer.text === '' &&
+ !completion.showSuggestions
+ ) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
- return true;
+ return;
+ }
+
+ if (key.name === 'escape') {
+ if (shellModeActive) {
+ setShellModeActive(false);
+ return;
+ }
+
+ if (completion.showSuggestions) {
+ completion.resetCompletionState();
+ return;
+ }
+ }
+
+ if (key.ctrl && key.name === 'l') {
+ onClearScreen();
+ return;
}
if (completion.showSuggestions) {
@@ -186,11 +220,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.navigateDown();
return;
}
- if (key.name === 'tab') {
+
+ if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
if (completion.suggestions.length > 0) {
const targetIndex =
completion.activeSuggestionIndex === -1
- ? 0
+ ? 0 // Default to the first if none is active
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
handleAutocomplete(targetIndex);
@@ -198,67 +233,72 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
return;
}
- if (key.name === 'return') {
- if (completion.activeSuggestionIndex >= 0) {
- handleAutocomplete(completion.activeSuggestionIndex);
- } else if (query.trim()) {
- handleSubmitAndClear(query);
- }
- return;
- }
} else {
- // Keybindings when suggestions are not shown
- if (key.ctrl && key.name === 'l') {
- onClearScreen();
- return;
- }
- if (key.ctrl && key.name === 'p') {
- inputHistory.navigateUp();
- return;
- }
- if (key.ctrl && key.name === 'n') {
- inputHistory.navigateDown();
- return;
- }
- if (key.name === 'escape') {
- if (shellModeActive) {
- setShellModeActive(false);
+ if (!shellModeActive) {
+ if (key.ctrl && key.name === 'p') {
+ inputHistory.navigateUp();
return;
}
- completion.resetCompletionState();
+ if (key.ctrl && key.name === 'n') {
+ inputHistory.navigateDown();
+ return;
+ }
+ // Handle arrow-up/down for history on single-line or at edges
+ if (
+ key.name === 'up' &&
+ (buffer.allVisualLines.length === 1 ||
+ (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
+ ) {
+ inputHistory.navigateUp();
+ return;
+ }
+ if (
+ key.name === 'down' &&
+ (buffer.allVisualLines.length === 1 ||
+ buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
+ ) {
+ inputHistory.navigateDown();
+ return;
+ }
+ } else {
+ // Shell History Navigation
+ if (key.name === 'up') {
+ const prevCommand = shellHistory.getPreviousCommand();
+ if (prevCommand !== null) buffer.setText(prevCommand);
+ return;
+ }
+ if (key.name === 'down') {
+ const nextCommand = shellHistory.getNextCommand();
+ if (nextCommand !== null) buffer.setText(nextCommand);
+ return;
+ }
+ }
+
+ if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
+ if (buffer.text.trim()) {
+ handleSubmitAndClear(buffer.text);
+ }
return;
}
}
- // Ctrl+A (Home)
+ // Newline insertion
+ if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
+ buffer.newline();
+ return;
+ }
+
+ // Ctrl+A (Home) / Ctrl+E (End)
if (key.ctrl && key.name === 'a') {
buffer.move('home');
- buffer.moveToOffset(0);
return;
}
- // Ctrl+E (End)
if (key.ctrl && key.name === 'e') {
buffer.move('end');
- buffer.moveToOffset(cpLen(buffer.text));
- return;
- }
- // Ctrl+L (Clear Screen)
- if (key.ctrl && key.name === 'l') {
- onClearScreen();
- return;
- }
- // Ctrl+P (History Up)
- if (key.ctrl && key.name === 'p' && !completion.showSuggestions) {
- inputHistory.navigateUp();
- return;
- }
- // Ctrl+N (History Down)
- if (key.ctrl && key.name === 'n' && !completion.showSuggestions) {
- inputHistory.navigateDown();
return;
}
- // Core text editing from MultilineTextEditor's useInput
+ // Kill line commands
if (key.ctrl && key.name === 'k') {
buffer.killLineRight();
return;
@@ -267,97 +307,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.killLineLeft();
return;
}
- const isCtrlX =
- (key.ctrl && (key.name === 'x' || key.sequence === '\x18')) ||
- key.sequence === '\x18';
- const isCtrlEFromEditor =
- (key.ctrl && (key.name === 'e' || key.sequence === '\x05')) ||
- key.sequence === '\x05' ||
- (!key.ctrl &&
- key.name === 'e' &&
- key.sequence.length === 1 &&
- key.sequence.charCodeAt(0) === 5);
-
- if (isCtrlX || isCtrlEFromEditor) {
- if (isCtrlEFromEditor && !(key.ctrl && key.name === 'e')) {
- // Avoid double handling Ctrl+E
- buffer.openInExternalEditor();
- return;
- }
- if (isCtrlX) {
- buffer.openInExternalEditor();
- return;
- }
- }
-
- if (
- process.env['TEXTBUFFER_DEBUG'] === '1' ||
- process.env['TEXTBUFFER_DEBUG'] === 'true'
- ) {
- console.log('[InputPromptCombined] event', { key });
- }
-
- // Ctrl+Enter for newline, Enter for submit
- if (key.name === 'return') {
- const [row, col] = buffer.cursor;
- const line = buffer.lines[row];
- const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
- if (key.ctrl || key.meta || charBefore === '\\' || key.paste) {
- // Ctrl+Enter or escaped newline
- if (charBefore === '\\') {
- buffer.backspace();
- }
- buffer.newline();
- } else {
- // Enter for submit
- if (query.trim()) {
- handleSubmitAndClear(query);
- }
- }
- return;
- }
- // Standard arrow navigation within the buffer
- if (key.name === 'up' && !completion.showSuggestions) {
- if (shellModeActive) {
- const prevCommand = shellHistory.getPreviousCommand();
- if (prevCommand !== null) {
- buffer.setText(prevCommand);
- }
- return;
- }
- if (
- (buffer.allVisualLines.length === 1 || // Always navigate for single line
- (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) &&
- inputHistory.navigateUp
- ) {
- inputHistory.navigateUp();
- } else {
- buffer.move('up');
- }
- return;
- }
- if (key.name === 'down' && !completion.showSuggestions) {
- if (shellModeActive) {
- const nextCommand = shellHistory.getNextCommand();
- if (nextCommand !== null) {
- buffer.setText(nextCommand);
- }
- return;
- }
- if (
- (buffer.allVisualLines.length === 1 || // Always navigate for single line
- buffer.visualCursor[0] === buffer.allVisualLines.length - 1) &&
- inputHistory.navigateDown
- ) {
- inputHistory.navigateDown();
- } else {
- buffer.move('down');
- }
+ // External editor
+ const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
+ if (isCtrlX) {
+ buffer.openInExternalEditor();
return;
}
- // Fallback to buffer's default input handling
+ // Fallback to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
[