feat: Add reverse search capability for shell commands (#4793)

This commit is contained in:
Ayesha Shafique
2025-08-04 00:53:24 +05:00
committed by GitHub
parent c0b4fc9506
commit 0335ce5ecc
10 changed files with 1505 additions and 737 deletions

View File

@@ -19,7 +19,10 @@ import {
useShellHistory,
UseShellHistoryReturn,
} from '../hooks/useShellHistory.js';
import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js';
import {
useSlashCompletion,
UseSlashCompletionReturn,
} from '../hooks/useSlashCompletion.js';
import {
useInputHistory,
UseInputHistoryReturn,
@@ -28,7 +31,7 @@ import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
vi.mock('../hooks/useSlashCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../utils/clipboardUtils.js');
@@ -83,13 +86,13 @@ const mockSlashCommands: SlashCommand[] = [
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: UseShellHistoryReturn;
let mockCompletion: UseCompletionReturn;
let mockSlashCompletion: UseSlashCompletionReturn;
let mockInputHistory: UseInputHistoryReturn;
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCompletion = vi.mocked(useCompletion);
const mockedUseSlashCompletion = vi.mocked(useSlashCompletion);
const mockedUseInputHistory = vi.mocked(useInputHistory);
beforeEach(() => {
@@ -115,7 +118,9 @@ describe('InputPrompt', () => {
visualScrollRow: 0,
handleInput: vi.fn(),
move: vi.fn(),
moveToOffset: vi.fn(),
moveToOffset: (offset: number) => {
mockBuffer.cursor = [0, offset];
},
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
@@ -133,6 +138,7 @@ describe('InputPrompt', () => {
} as unknown as TextBuffer;
mockShellHistory = {
history: [],
addCommandToHistory: vi.fn(),
getPreviousCommand: vi.fn().mockReturnValue(null),
getNextCommand: vi.fn().mockReturnValue(null),
@@ -140,7 +146,7 @@ describe('InputPrompt', () => {
};
mockedUseShellHistory.mockReturnValue(mockShellHistory);
mockCompletion = {
mockSlashCompletion = {
suggestions: [],
activeSuggestionIndex: -1,
isLoadingSuggestions: false,
@@ -154,7 +160,7 @@ describe('InputPrompt', () => {
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
};
mockedUseCompletion.mockReturnValue(mockCompletion);
mockedUseSlashCompletion.mockReturnValue(mockSlashCompletion);
mockInputHistory = {
navigateUp: vi.fn(),
@@ -265,8 +271,8 @@ describe('InputPrompt', () => {
});
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
@@ -285,15 +291,15 @@ describe('InputPrompt', () => {
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockSlashCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
@@ -311,15 +317,15 @@ describe('InputPrompt', () => {
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockSlashCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled();
unmount();
});
it('should NOT call completion navigation when suggestions are not showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
});
props.buffer.setText('some text');
@@ -336,8 +342,8 @@ describe('InputPrompt', () => {
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
@@ -466,8 +472,8 @@ describe('InputPrompt', () => {
it('should complete a partial parent command', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
activeSuggestionIndex: 0,
@@ -480,14 +486,14 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should append a sub-command when the parent command is already complete', async () => {
// SCENARIO: /memory -> Tab (to accept 'add')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
@@ -503,14 +509,14 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
unmount();
});
it('should handle the "backspace" edge case correctly', async () => {
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
@@ -528,14 +534,14 @@ describe('InputPrompt', () => {
await wait();
// It should NOT become '/show'. It should correctly become '/memory show'.
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
@@ -548,13 +554,13 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory' }],
activeSuggestionIndex: 0,
@@ -568,7 +574,7 @@ describe('InputPrompt', () => {
await wait();
// The app should autocomplete the text, NOT submit.
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
@@ -584,8 +590,8 @@ describe('InputPrompt', () => {
},
];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'help', value: 'help' }],
activeSuggestionIndex: 0,
@@ -598,7 +604,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab for autocomplete
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
@@ -616,8 +622,8 @@ describe('InputPrompt', () => {
});
it('should submit directly on Enter when isPerfectMatch is true', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
isPerfectMatch: true,
});
@@ -634,8 +640,8 @@ describe('InputPrompt', () => {
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
isPerfectMatch: false, // Added explicit isPerfectMatch false
});
@@ -652,8 +658,8 @@ describe('InputPrompt', () => {
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
activeSuggestionIndex: 0,
@@ -666,7 +672,7 @@ describe('InputPrompt', () => {
stdin.write('\r');
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
@@ -698,7 +704,7 @@ describe('InputPrompt', () => {
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCompletion.resetCompletionState).toHaveBeenCalled();
expect(mockSlashCompletion.resetCompletionState).toHaveBeenCalled();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
@@ -722,8 +728,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/components'];
mockBuffer.cursor = [0, 15];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
});
@@ -732,12 +738,13 @@ describe('InputPrompt', () => {
await wait();
// Verify useCompletion was called with correct signature
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -749,8 +756,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
@@ -758,12 +765,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -775,8 +783,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file.ts hello'];
mockBuffer.cursor = [0, 18];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -784,12 +792,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -801,8 +810,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory add'];
mockBuffer.cursor = [0, 11];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -810,12 +819,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -827,8 +837,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['hello world'];
mockBuffer.cursor = [0, 5];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -836,12 +846,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -853,8 +864,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['first line', '/memory'];
mockBuffer.cursor = [1, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -863,12 +874,13 @@ describe('InputPrompt', () => {
await wait();
// Verify useCompletion was called with the buffer
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -880,8 +892,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
@@ -889,12 +901,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -907,8 +920,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file👍.txt'];
mockBuffer.cursor = [0, 14]; // After the emoji character
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
});
@@ -916,12 +929,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -934,8 +948,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file👍.txt hello'];
mockBuffer.cursor = [0, 20]; // After the space
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -943,12 +957,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -961,8 +976,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/my\\ file.txt'];
mockBuffer.cursor = [0, 16]; // After the escaped space and filename
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
});
@@ -970,12 +985,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -988,8 +1004,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@path/my\\ file.txt hello'];
mockBuffer.cursor = [0, 24]; // After "hello"
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -997,12 +1013,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1015,8 +1032,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md'];
mockBuffer.cursor = [0, 29]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [
{ label: 'my long file name.md', value: 'my long file name.md' },
@@ -1026,12 +1043,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1044,8 +1062,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory\\ test'];
mockBuffer.cursor = [0, 13]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [{ label: 'test-command', value: 'test-command' }],
});
@@ -1053,12 +1071,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1071,8 +1090,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')];
mockBuffer.cursor = [0, 25]; // After the escaped space and emoji
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseSlashCompletion.mockReturnValue({
...mockSlashCompletion,
showSuggestions: true,
suggestions: [
{ label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' },
@@ -1082,12 +1101,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseSlashCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1169,4 +1189,92 @@ describe('InputPrompt', () => {
unmount();
});
});
describe('reverse search', () => {
beforeEach(async () => {
props.shellModeActive = true;
vi.mocked(useShellHistory).mockReturnValue({
history: ['echo hello', 'echo world', 'ls'],
getPreviousCommand: vi.fn(),
getNextCommand: vi.fn(),
addCommandToHistory: vi.fn(),
resetHistoryPosition: vi.fn(),
});
});
it('invokes reverse search on Ctrl+R', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x12');
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain('(r:)');
expect(frame).toContain('echo hello');
expect(frame).toContain('echo world');
expect(frame).toContain('ls');
unmount();
});
it('resets reverse search state on Escape', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x12');
await wait();
stdin.write('\x1B');
await wait();
const frame = stdout.lastFrame();
expect(frame).not.toContain('(r:)');
expect(frame).not.toContain('echo hello');
unmount();
});
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
stdin.write('\t');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
unmount();
});
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\r');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.onSubmit).toHaveBeenCalledWith('echo hello');
unmount();
});
it('text and cursor position should be restored after reverse search', async () => {
props.buffer.setText('initial text');
props.buffer.cursor = [0, 3];
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\x1B');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.buffer.text).toBe('initial text');
expect(props.buffer.cursor).toEqual([0, 3]);
unmount();
});
});
});

View File

@@ -9,12 +9,13 @@ import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer } from './shared/text-buffer.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useSlashCompletion } from '../hooks/useSlashCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core';
@@ -69,18 +70,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setDirs(dirsChanged);
}
}, [dirs.length, dirsChanged]);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
const shellHistory = useShellHistory(config.getProjectRoot());
const historyData = shellHistory.history;
const completion = useCompletion(
const completion = useSlashCompletion(
buffer,
dirs,
config.getTargetDir(),
slashCommands,
commandContext,
reverseSearchActive,
config,
);
const reverseSearchCompletion = useReverseSearchCompletion(
buffer,
historyData,
reverseSearchActive,
);
const resetCompletionState = completion.resetCompletionState;
const shellHistory = useShellHistory(config.getProjectRoot());
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
@@ -92,8 +107,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.setText('');
onSubmit(submittedValue);
resetCompletionState();
resetReverseSearchCompletionState();
},
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
[
onSubmit,
buffer,
resetCompletionState,
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
],
);
const customSetTextAndResetCompletionSignal = useCallback(
@@ -118,6 +141,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useEffect(() => {
if (justNavigatedHistory) {
resetCompletionState();
resetReverseSearchCompletionState();
setJustNavigatedHistory(false);
}
}, [
@@ -125,6 +149,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.text,
resetCompletionState,
setJustNavigatedHistory,
resetReverseSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -197,6 +222,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (key.name === 'escape') {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
buffer.setText(textBeforeReverseSearch);
const offset = logicalPosToOffset(
buffer.lines,
cursorPosition[0],
cursorPosition[1],
);
buffer.moveToOffset(offset);
return;
}
if (shellModeActive) {
setShellModeActive(false);
return;
@@ -208,11 +246,61 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
if (shellModeActive && key.ctrl && key.name === 'r') {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (key.ctrl && key.name === 'l') {
onClearScreen();
return;
}
if (reverseSearchActive) {
const {
activeSuggestionIndex,
navigateUp,
navigateDown,
showSuggestions,
suggestions,
} = reverseSearchCompletion;
if (showSuggestions) {
if (key.name === 'up') {
navigateUp();
return;
}
if (key.name === 'down') {
navigateDown();
return;
}
if (key.name === 'tab') {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
}
if (key.name === 'return' && !key.ctrl) {
const textToSubmit =
showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
// Prevent up/down from falling through to regular history navigation
if (key.name === 'up' || key.name === 'down') {
return;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') {
handleSubmitAndClear(buffer.text);
@@ -272,7 +360,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
} else {
// Shell History Navigation
if (key.name === 'up') {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
@@ -284,7 +371,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
@@ -362,9 +448,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory,
handleSubmitAndClear,
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
resetCompletionState,
vimHandleInput,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
],
);
@@ -385,7 +475,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
>
{shellModeActive ? '! ' : '> '}
{shellModeActive ? (
reverseSearchActive ? (
<Text color={Colors.AccentCyan}>(r:) </Text>
) : (
'! '
)
) : (
'> '
)}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
@@ -449,6 +547,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
</Box>
)}
{reverseSearchActive && (
<Box>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
isLoading={reverseSearchCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={reverseSearchCompletion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
</>
);
};

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
interface PrepareLabelProps {
label: string;
matchedIndex?: number;
userInput: string;
textColor: string;
highlightColor?: string;
}
export const PrepareLabel: React.FC<PrepareLabelProps> = ({
label,
matchedIndex,
userInput,
textColor,
highlightColor = Colors.AccentYellow,
}) => {
if (
matchedIndex === undefined ||
matchedIndex < 0 ||
matchedIndex >= label.length ||
userInput.length === 0
) {
return <Text color={textColor}>{label}</Text>;
}
const start = label.slice(0, matchedIndex);
const match = label.slice(matchedIndex, matchedIndex + userInput.length);
const end = label.slice(matchedIndex + userInput.length);
return (
<Text>
<Text color={textColor}>{start}</Text>
<Text color="black" bold backgroundColor={highlightColor}>
{match}
</Text>
<Text color={textColor}>{end}</Text>
</Text>
);
};

View File

@@ -6,10 +6,12 @@
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { PrepareLabel } from './PrepareLabel.js';
export interface Suggestion {
label: string;
value: string;
description?: string;
matchedIndex?: number;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
@@ -58,18 +60,25 @@ export function SuggestionsDisplay({
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const textColor = isActive ? Colors.AccentPurple : Colors.Gray;
const labelElement = (
<PrepareLabel
label={suggestion.label}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
textColor={textColor}
/>
);
return (
<Box key={`${suggestion}-${originalIndex}`} width={width}>
<Box key={`${suggestion.value}-${originalIndex}`} width={width}>
<Box flexDirection="row">
{userInput.startsWith('/') ? (
// only use box model for (/) command mode
<Box width={20} flexShrink={0}>
<Text color={textColor}>{suggestion.label}</Text>
{labelElement}
</Box>
) : (
// use regular text for other modes (@ context)
<Text color={textColor}>{suggestion.label}</Text>
labelElement
)}
{suggestion.description ? (
<Box flexGrow={1}>