feat: Add reverse search capability for shell commands (#4793)
This commit is contained in:
parent
03ed37d0dc
commit
072d8ba289
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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}>
|
||||
|
|
|
@ -4,30 +4,12 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { glob } from 'glob';
|
||||
import {
|
||||
isNodeError,
|
||||
escapePath,
|
||||
unescapePath,
|
||||
getErrorMessage,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import {
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
Suggestion,
|
||||
} from '../components/SuggestionsDisplay.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import {
|
||||
logicalPosToOffset,
|
||||
TextBuffer,
|
||||
} from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
|
||||
export interface UseCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
|
@ -36,22 +18,18 @@ export interface UseCompletionReturn {
|
|||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
setSuggestions: React.Dispatch<React.SetStateAction<Suggestion[]>>;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setVisibleStartIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setIsLoadingSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsPerfectMatch: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (indexToUse: number) => void;
|
||||
}
|
||||
|
||||
export function useCompletion(
|
||||
buffer: TextBuffer,
|
||||
dirs: readonly string[],
|
||||
cwd: string,
|
||||
slashCommands: readonly SlashCommand[],
|
||||
commandContext: CommandContext,
|
||||
config?: Config,
|
||||
): UseCompletionReturn {
|
||||
export function useCompletion(): UseCompletionReturn {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] =
|
||||
useState<number>(-1);
|
||||
|
@ -60,11 +38,6 @@ export function useCompletion(
|
|||
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||
useState<boolean>(false);
|
||||
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
|
||||
const completionStart = useRef(-1);
|
||||
const completionEnd = useRef(-1);
|
||||
|
||||
const cursorRow = buffer.cursor[0];
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
const resetCompletionState = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
|
@ -133,560 +106,6 @@ export function useCompletion(
|
|||
return newActiveIndex;
|
||||
});
|
||||
}, [suggestions.length]);
|
||||
|
||||
// Check if cursor is after @ or / without unescaped spaces
|
||||
const commandIndex = useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return currentLine.indexOf('/');
|
||||
}
|
||||
|
||||
// For other completions like '@', we search backwards from the cursor.
|
||||
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
||||
if (char === ' ') {
|
||||
// Check for unescaped spaces.
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
return -1; // Inactive on unescaped space.
|
||||
}
|
||||
} else if (char === '@') {
|
||||
// Active if we find an '@' before any unescaped space.
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}, [cursorRow, cursorCol, buffer.lines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commandIndex === -1) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
|
||||
if (codePoints[commandIndex] === '/') {
|
||||
// Always reset perfect match at the beginning of processing.
|
||||
setIsPerfectMatch(false);
|
||||
|
||||
const fullPath = currentLine.substring(commandIndex + 1);
|
||||
const hasTrailingSpace = currentLine.endsWith(' ');
|
||||
|
||||
// Get all non-empty parts of the command.
|
||||
const rawParts = fullPath.split(/\s+/).filter((p) => p);
|
||||
|
||||
let commandPathParts = rawParts;
|
||||
let partial = '';
|
||||
|
||||
// If there's no trailing space, the last part is potentially a partial segment.
|
||||
// We tentatively separate it.
|
||||
if (!hasTrailingSpace && rawParts.length > 0) {
|
||||
partial = rawParts[rawParts.length - 1];
|
||||
commandPathParts = rawParts.slice(0, -1);
|
||||
}
|
||||
|
||||
// Traverse the Command Tree using the tentative completed path
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
if (!currentLevel) {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
const found: SlashCommand | undefined = currentLevel.find(
|
||||
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
|
||||
);
|
||||
if (found) {
|
||||
leafCommand = found;
|
||||
currentLevel = found.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
} else {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let exactMatchAsParent: SlashCommand | undefined;
|
||||
// Handle the Ambiguous Case
|
||||
if (!hasTrailingSpace && currentLevel) {
|
||||
exactMatchAsParent = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.subCommands,
|
||||
);
|
||||
|
||||
if (exactMatchAsParent) {
|
||||
// It's a perfect match for a parent command. Override our initial guess.
|
||||
// Treat it as a completed command path.
|
||||
leafCommand = exactMatchAsParent;
|
||||
currentLevel = exactMatchAsParent.subCommands;
|
||||
partial = ''; // We now want to suggest ALL of its sub-commands.
|
||||
}
|
||||
}
|
||||
|
||||
// Check for perfect, executable match
|
||||
if (!hasTrailingSpace) {
|
||||
if (leafCommand && partial === '' && leafCommand.action) {
|
||||
// Case: /command<enter> - command has action, no sub-commands were suggested
|
||||
setIsPerfectMatch(true);
|
||||
} else if (currentLevel) {
|
||||
// Case: /command subcommand<enter>
|
||||
const perfectMatch = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.action,
|
||||
);
|
||||
if (perfectMatch) {
|
||||
setIsPerfectMatch(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
const isArgumentCompletion =
|
||||
leafCommand?.completion &&
|
||||
(hasTrailingSpace ||
|
||||
(rawParts.length > depth && depth > 0 && partial !== ''));
|
||||
|
||||
// Set completion range
|
||||
if (hasTrailingSpace || exactMatchAsParent) {
|
||||
completionStart.current = currentLine.length;
|
||||
completionEnd.current = currentLine.length;
|
||||
} else if (partial) {
|
||||
if (isArgumentCompletion) {
|
||||
const commandSoFar = `/${commandPathParts.join(' ')}`;
|
||||
const argStartIndex =
|
||||
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
|
||||
completionStart.current = argStartIndex;
|
||||
} else {
|
||||
completionStart.current = currentLine.length - partial.length;
|
||||
}
|
||||
completionEnd.current = currentLine.length;
|
||||
} else {
|
||||
// e.g. /
|
||||
completionStart.current = commandIndex + 1;
|
||||
completionEnd.current = currentLine.length;
|
||||
}
|
||||
|
||||
// Provide Suggestions based on the now-corrected context
|
||||
if (isArgumentCompletion) {
|
||||
const fetchAndSetSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
const argString = rawParts.slice(depth).join(' ');
|
||||
const results =
|
||||
(await leafCommand!.completion!(commandContext, argString)) || [];
|
||||
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
};
|
||||
fetchAndSetSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Command/Sub-command Completion
|
||||
const commandsToSearch = currentLevel || [];
|
||||
if (commandsToSearch.length > 0) {
|
||||
let potentialSuggestions = commandsToSearch.filter(
|
||||
(cmd) =>
|
||||
cmd.description &&
|
||||
(cmd.name.startsWith(partial) ||
|
||||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
|
||||
);
|
||||
|
||||
// If a user's input is an exact match and it is a leaf command,
|
||||
// enter should submit immediately.
|
||||
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
||||
const perfectMatch = potentialSuggestions.find(
|
||||
(s) => s.name === partial || s.altNames?.includes(partial),
|
||||
);
|
||||
if (perfectMatch && perfectMatch.action) {
|
||||
potentialSuggestions = [];
|
||||
}
|
||||
}
|
||||
|
||||
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we fall through, no suggestions are available.
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle At Command Completion
|
||||
completionEnd.current = codePoints.length;
|
||||
for (let i = cursorCol; i < codePoints.length; i++) {
|
||||
if (codePoints[i] === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
if (backslashCount % 2 === 0) {
|
||||
completionEnd.current = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathStart = commandIndex + 1;
|
||||
const partialPath = currentLine.substring(pathStart, completionEnd.current);
|
||||
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||
completionStart.current =
|
||||
lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
|
||||
const baseDirRelative =
|
||||
lastSlashIndex === -1
|
||||
? '.'
|
||||
: partialPath.substring(0, lastSlashIndex + 1);
|
||||
const prefix = unescapePath(
|
||||
lastSlashIndex === -1
|
||||
? partialPath
|
||||
: partialPath.substring(lastSlashIndex + 1),
|
||||
);
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const findFilesRecursively = async (
|
||||
startDir: string,
|
||||
searchPrefix: string,
|
||||
fileDiscovery: FileDiscoveryService | null,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
currentRelativePath = '',
|
||||
depth = 0,
|
||||
maxDepth = 10, // Limit recursion depth
|
||||
maxResults = 50, // Limit number of results
|
||||
): Promise<Suggestion[]> => {
|
||||
if (depth > maxDepth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerSearchPrefix = searchPrefix.toLowerCase();
|
||||
let foundSuggestions: Suggestion[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(startDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (foundSuggestions.length >= maxResults) break;
|
||||
|
||||
const entryPathRelative = path.join(currentRelativePath, entry.name);
|
||||
const entryPathFromRoot = path.relative(
|
||||
startDir,
|
||||
path.join(startDir, entry.name),
|
||||
);
|
||||
|
||||
// Conditionally ignore dotfiles
|
||||
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this entry should be ignored by filtering options
|
||||
if (
|
||||
fileDiscovery &&
|
||||
fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
|
||||
foundSuggestions.push({
|
||||
label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
value: escapePath(
|
||||
entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
entry.name !== 'node_modules' &&
|
||||
!entry.name.startsWith('.')
|
||||
) {
|
||||
if (foundSuggestions.length < maxResults) {
|
||||
foundSuggestions = foundSuggestions.concat(
|
||||
await findFilesRecursively(
|
||||
path.join(startDir, entry.name),
|
||||
searchPrefix, // Pass original searchPrefix for recursive calls
|
||||
fileDiscovery,
|
||||
filterOptions,
|
||||
entryPathRelative,
|
||||
depth + 1,
|
||||
maxDepth,
|
||||
maxResults - foundSuggestions.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore errors like permission denied or ENOENT during recursive search
|
||||
}
|
||||
return foundSuggestions.slice(0, maxResults);
|
||||
};
|
||||
|
||||
const findFilesWithGlob = async (
|
||||
searchPrefix: string,
|
||||
fileDiscoveryService: FileDiscoveryService,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
searchDir: string,
|
||||
maxResults = 50,
|
||||
): Promise<Suggestion[]> => {
|
||||
const globPattern = `**/${searchPrefix}*`;
|
||||
const files = await glob(globPattern, {
|
||||
cwd: searchDir,
|
||||
dot: searchPrefix.startsWith('.'),
|
||||
nocase: true,
|
||||
});
|
||||
|
||||
const suggestions: Suggestion[] = files
|
||||
.filter((file) => {
|
||||
if (fileDiscoveryService) {
|
||||
return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((file: string) => {
|
||||
const absolutePath = path.resolve(searchDir, file);
|
||||
const label = path.relative(cwd, absolutePath);
|
||||
return {
|
||||
label,
|
||||
value: escapePath(label),
|
||||
};
|
||||
})
|
||||
.slice(0, maxResults);
|
||||
|
||||
return suggestions;
|
||||
};
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
let fetchedSuggestions: Suggestion[] = [];
|
||||
|
||||
const fileDiscoveryService = config ? config.getFileService() : null;
|
||||
const enableRecursiveSearch =
|
||||
config?.getEnableRecursiveFileSearch() ?? true;
|
||||
const filterOptions =
|
||||
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
try {
|
||||
// If there's no slash, or it's the root, do a recursive search from workspace directories
|
||||
for (const dir of dirs) {
|
||||
let fetchedSuggestionsPerDir: Suggestion[] = [];
|
||||
if (
|
||||
partialPath.indexOf('/') === -1 &&
|
||||
prefix &&
|
||||
enableRecursiveSearch
|
||||
) {
|
||||
if (fileDiscoveryService) {
|
||||
fetchedSuggestionsPerDir = await findFilesWithGlob(
|
||||
prefix,
|
||||
fileDiscoveryService,
|
||||
filterOptions,
|
||||
dir,
|
||||
);
|
||||
} else {
|
||||
fetchedSuggestionsPerDir = await findFilesRecursively(
|
||||
dir,
|
||||
prefix,
|
||||
null,
|
||||
filterOptions,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Original behavior: list files in the specific directory
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const baseDirAbsolute = path.resolve(dir, baseDirRelative);
|
||||
const entries = await fs.readdir(baseDirAbsolute, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
// Filter entries using git-aware filtering
|
||||
const filteredEntries = [];
|
||||
for (const entry of entries) {
|
||||
// Conditionally ignore dotfiles
|
||||
if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||
|
||||
const relativePath = path.relative(
|
||||
dir,
|
||||
path.join(baseDirAbsolute, entry.name),
|
||||
);
|
||||
if (
|
||||
fileDiscoveryService &&
|
||||
fileDiscoveryService.shouldIgnoreFile(
|
||||
relativePath,
|
||||
filterOptions,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filteredEntries.push(entry);
|
||||
}
|
||||
|
||||
fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
|
||||
const absolutePath = path.resolve(baseDirAbsolute, entry.name);
|
||||
const label =
|
||||
cwd === dir ? entry.name : path.relative(cwd, absolutePath);
|
||||
const suggestionLabel = entry.isDirectory() ? label + '/' : label;
|
||||
return {
|
||||
label: suggestionLabel,
|
||||
value: escapePath(suggestionLabel),
|
||||
};
|
||||
});
|
||||
}
|
||||
fetchedSuggestions = [
|
||||
...fetchedSuggestions,
|
||||
...fetchedSuggestionsPerDir,
|
||||
];
|
||||
}
|
||||
|
||||
// Like glob, we always return forwardslashes, even in windows.
|
||||
fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
|
||||
...suggestion,
|
||||
label: suggestion.label.replace(/\\/g, '/'),
|
||||
value: suggestion.value.replace(/\\/g, '/'),
|
||||
}));
|
||||
|
||||
// Sort by depth, then directories first, then alphabetically
|
||||
fetchedSuggestions.sort((a, b) => {
|
||||
const depthA = (a.label.match(/\//g) || []).length;
|
||||
const depthB = (b.label.match(/\//g) || []).length;
|
||||
|
||||
if (depthA !== depthB) {
|
||||
return depthA - depthB;
|
||||
}
|
||||
|
||||
const aIsDir = a.label.endsWith('/');
|
||||
const bIsDir = b.label.endsWith('/');
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
|
||||
// exclude extension when comparing
|
||||
const filenameA = a.label.substring(
|
||||
0,
|
||||
a.label.length - path.extname(a.label).length,
|
||||
);
|
||||
const filenameB = b.label.substring(
|
||||
0,
|
||||
b.label.length - path.extname(b.label).length,
|
||||
);
|
||||
|
||||
return (
|
||||
filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
|
||||
);
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setSuggestions(fetchedSuggestions);
|
||||
setShowSuggestions(fetchedSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (isMounted) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
if (isMounted) {
|
||||
resetCompletionState();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isMounted) {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(debounceTimeout);
|
||||
};
|
||||
}, [
|
||||
buffer.text,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
buffer.lines,
|
||||
dirs,
|
||||
cwd,
|
||||
commandIndex,
|
||||
resetCompletionState,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
(indexToUse: number) => {
|
||||
if (indexToUse < 0 || indexToUse >= suggestions.length) {
|
||||
return;
|
||||
}
|
||||
const suggestion = suggestions[indexToUse].value;
|
||||
|
||||
if (completionStart.current === -1 || completionEnd.current === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/';
|
||||
let suggestionText = suggestion;
|
||||
if (isSlash) {
|
||||
// If we are inserting (not replacing), and the preceding character is not a space, add one.
|
||||
if (
|
||||
completionStart.current === completionEnd.current &&
|
||||
completionStart.current > commandIndex + 1 &&
|
||||
(buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
|
||||
) {
|
||||
suggestionText = ' ' + suggestionText;
|
||||
}
|
||||
suggestionText += ' ';
|
||||
}
|
||||
|
||||
buffer.replaceRangeByOffset(
|
||||
logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
|
||||
logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
|
||||
suggestionText,
|
||||
);
|
||||
resetCompletionState();
|
||||
},
|
||||
[cursorRow, resetCompletionState, buffer, suggestions, commandIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
|
@ -694,11 +113,16 @@ export function useCompletion(
|
|||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
setActiveSuggestionIndex,
|
||||
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
setVisibleStartIndex,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useReverseSearchCompletion } from './useReverseSearchCompletion.js';
|
||||
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||
|
||||
describe('useReverseSearchCompletion', () => {
|
||||
function useTextBufferForTest(text: string) {
|
||||
return useTextBuffer({
|
||||
initialText: text,
|
||||
initialCursorOffset: text.length,
|
||||
viewport: { width: 80, height: 20 },
|
||||
isValidPath: () => false,
|
||||
onChange: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
describe('Core Hook Behavior', () => {
|
||||
describe('State Management', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest(''),
|
||||
mockShellHistory,
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset state when reverseSearchActive becomes false', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
const { result, rerender } = renderHook(
|
||||
({ text, active }) => {
|
||||
const textBuffer = useTextBufferForTest(text);
|
||||
return useReverseSearchCompletion(
|
||||
textBuffer,
|
||||
mockShellHistory,
|
||||
active,
|
||||
);
|
||||
},
|
||||
{ initialProps: { text: 'echo', active: true } },
|
||||
);
|
||||
|
||||
// Simulate reverseSearchActive becoming false
|
||||
rerender({ text: 'echo', active: false });
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should handle navigateUp with no suggestions', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('grep'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle navigateDown with no suggestions', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('grep'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should navigate up through suggestions with wrap-around', () => {
|
||||
const mockShellHistory = [
|
||||
'ls -l',
|
||||
'ls -la',
|
||||
'cd /some/path',
|
||||
'git status',
|
||||
'echo "Hello, World!"',
|
||||
'echo Hi',
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('echo'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(2);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should navigate down through suggestions with wrap-around', () => {
|
||||
const mockShellHistory = [
|
||||
'ls -l',
|
||||
'ls -la',
|
||||
'cd /some/path',
|
||||
'git status',
|
||||
'echo "Hello, World!"',
|
||||
'echo Hi',
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('ls'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(2);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle navigation with multiple suggestions', () => {
|
||||
const mockShellHistory = [
|
||||
'ls -l',
|
||||
'ls -la',
|
||||
'cd /some/path/l',
|
||||
'git status',
|
||||
'echo "Hello, World!"',
|
||||
'echo "Hi all"',
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('l'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(5);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle navigation with large suggestion lists and scrolling', () => {
|
||||
const largeMockCommands = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `echo ${i}`,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('echo'),
|
||||
largeMockCommands,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(15);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(14);
|
||||
expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('filters history by buffer.text and sets showSuggestions', () => {
|
||||
const history = ['foo', 'barfoo', 'baz'];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(useTextBufferForTest('foo'), history, true),
|
||||
);
|
||||
|
||||
// should only return the two entries containing "foo"
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'foo',
|
||||
'barfoo',
|
||||
]);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('hides suggestions when there are no matches', () => {
|
||||
const history = ['alpha', 'beta'];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(useTextBufferForTest('γ'), history, true),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
import { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
|
||||
export interface UseReverseSearchCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
activeSuggestionIndex: number;
|
||||
visibleStartIndex: number;
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (i: number) => void;
|
||||
resetCompletionState: () => void;
|
||||
}
|
||||
|
||||
export function useReverseSearchCompletion(
|
||||
buffer: TextBuffer,
|
||||
shellHistory: readonly string[],
|
||||
reverseSearchActive: boolean,
|
||||
): UseReverseSearchCompletionReturn {
|
||||
const {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
} = useCompletion();
|
||||
|
||||
// whenever reverseSearchActive is on, filter history
|
||||
useEffect(() => {
|
||||
if (!reverseSearchActive) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
const q = buffer.text.toLowerCase();
|
||||
const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => {
|
||||
const idx = cmd.toLowerCase().indexOf(q);
|
||||
if (idx !== -1) {
|
||||
acc.push({ label: cmd, value: cmd, matchedIndex: idx });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
setSuggestions(matches);
|
||||
setShowSuggestions(matches.length > 0);
|
||||
setActiveSuggestionIndex(matches.length > 0 ? 0 : -1);
|
||||
}, [
|
||||
buffer.text,
|
||||
shellHistory,
|
||||
reverseSearchActive,
|
||||
resetCompletionState,
|
||||
setActiveSuggestionIndex,
|
||||
setShowSuggestions,
|
||||
setSuggestions,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
(i: number) => {
|
||||
if (i < 0 || i >= suggestions.length) return;
|
||||
buffer.setText(suggestions[i].value);
|
||||
resetCompletionState();
|
||||
},
|
||||
[buffer, suggestions, resetCompletionState],
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
resetCompletionState,
|
||||
};
|
||||
}
|
|
@ -13,6 +13,7 @@ const HISTORY_FILE = 'shell_history';
|
|||
const MAX_HISTORY_LENGTH = 100;
|
||||
|
||||
export interface UseShellHistoryReturn {
|
||||
history: string[];
|
||||
addCommandToHistory: (command: string) => void;
|
||||
getPreviousCommand: () => string | null;
|
||||
getNextCommand: () => string | null;
|
||||
|
@ -24,15 +25,32 @@ async function getHistoryFilePath(projectRoot: string): Promise<string> {
|
|||
return path.join(historyDir, HISTORY_FILE);
|
||||
}
|
||||
|
||||
// Handle multiline commands
|
||||
async function readHistoryFile(filePath: string): Promise<string[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return content.split('\n').filter(Boolean);
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return [];
|
||||
const text = await fs.readFile(filePath, 'utf-8');
|
||||
const result: string[] = [];
|
||||
let cur = '';
|
||||
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
if (!raw.trim()) continue;
|
||||
const line = raw;
|
||||
|
||||
const m = cur.match(/(\\+)$/);
|
||||
if (m && m[1].length % 2) {
|
||||
// odd number of trailing '\'
|
||||
cur = cur.slice(0, -1) + ' ' + line;
|
||||
} else {
|
||||
if (cur) result.push(cur);
|
||||
cur = line;
|
||||
}
|
||||
}
|
||||
console.error('Error reading shell history:', error);
|
||||
|
||||
if (cur) result.push(cur);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') return [];
|
||||
console.error('Error reading history:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@ -101,10 +119,15 @@ export function useShellHistory(projectRoot: string): UseShellHistoryReturn {
|
|||
return history[newIndex] ?? null;
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const resetHistoryPosition = useCallback(() => {
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
history,
|
||||
addCommandToHistory,
|
||||
getPreviousCommand,
|
||||
getNextCommand,
|
||||
resetHistoryPosition: () => setHistoryIndex(-1),
|
||||
resetHistoryPosition,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
@ -16,7 +16,7 @@ import { CommandContext, SlashCommand } from '../commands/types.js';
|
|||
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
||||
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||
|
||||
describe('useCompletion', () => {
|
||||
describe('useSlashCompletion', () => {
|
||||
let testRootDir: string;
|
||||
let mockConfig: Config;
|
||||
|
||||
|
@ -50,7 +50,7 @@ describe('useCompletion', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
testRootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'completion-unit-test-'),
|
||||
path.join(os.tmpdir(), 'slash-completion-unit-test-'),
|
||||
);
|
||||
testDirs = [testRootDir];
|
||||
mockConfig = {
|
||||
|
@ -82,12 +82,13 @@ describe('useCompletion', () => {
|
|||
{ name: 'dummy', description: 'dummy' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest(''),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -112,12 +113,13 @@ describe('useCompletion', () => {
|
|||
const { result, rerender } = renderHook(
|
||||
({ text }) => {
|
||||
const textBuffer = useTextBufferForTest(text);
|
||||
return useCompletion(
|
||||
return useSlashCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
},
|
||||
|
@ -143,12 +145,13 @@ describe('useCompletion', () => {
|
|||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/help'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -176,12 +179,13 @@ describe('useCompletion', () => {
|
|||
{ name: 'dummy', description: 'dummy' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest(''),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -198,12 +202,14 @@ describe('useCompletion', () => {
|
|||
{ name: 'dummy', description: 'dummy' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest(''),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -223,12 +229,14 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/h'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -251,12 +259,14 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/h'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -280,12 +290,14 @@ describe('useCompletion', () => {
|
|||
{ name: 'chat', description: 'Manage chat' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -326,12 +338,14 @@ describe('useCompletion', () => {
|
|||
})) as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/command'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
largeMockCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -384,7 +398,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -407,7 +421,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/mem'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -431,7 +445,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/usag'), // part of the word "usage"
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -458,7 +472,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/clear'), // No trailing space
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -490,7 +504,7 @@ describe('useCompletion', () => {
|
|||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest(query),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -511,7 +525,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/clear '),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -532,7 +546,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/unknown-command'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -566,7 +580,7 @@ describe('useCompletion', () => {
|
|||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/memory'), // Note: no trailing space
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -604,7 +618,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/memory'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -640,7 +654,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/memory a'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -672,7 +686,7 @@ describe('useCompletion', () => {
|
|||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/memory dothisnow'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -715,7 +729,7 @@ describe('useCompletion', () => {
|
|||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/chat resume my-ch'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -759,7 +773,7 @@ describe('useCompletion', () => {
|
|||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/chat resume '),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -794,12 +808,14 @@ describe('useCompletion', () => {
|
|||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('/chat resume '),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -822,12 +838,14 @@ describe('useCompletion', () => {
|
|||
await createTestFile('', 'README.md');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@s'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -856,12 +874,14 @@ describe('useCompletion', () => {
|
|||
await createTestFile('', 'src', 'index.ts');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@src/comp'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -882,12 +902,14 @@ describe('useCompletion', () => {
|
|||
await createTestFile('', 'src', 'index.ts');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@.'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -914,12 +936,14 @@ describe('useCompletion', () => {
|
|||
await createEmptyDir('dist');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@d'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfigNoRecursive,
|
||||
),
|
||||
);
|
||||
|
@ -940,7 +964,7 @@ describe('useCompletion', () => {
|
|||
await createTestFile('', 'README.md');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
|
@ -975,12 +999,14 @@ describe('useCompletion', () => {
|
|||
.mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -1006,12 +1032,14 @@ describe('useCompletion', () => {
|
|||
await createEmptyDir('data');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@d'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -1040,12 +1068,14 @@ describe('useCompletion', () => {
|
|||
await createTestFile('', 'README.md');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -1073,12 +1103,14 @@ describe('useCompletion', () => {
|
|||
await createTestFile('', 'temp', 'temp.log');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
useSlashCompletion(
|
||||
useTextBufferForTest('@t'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
@ -1116,12 +1148,14 @@ describe('useCompletion', () => {
|
|||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('/mem');
|
||||
const completion = useCompletion(
|
||||
const completion = useSlashCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
|
@ -1158,12 +1192,14 @@ describe('useCompletion', () => {
|
|||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('/memory');
|
||||
const completion = useCompletion(
|
||||
const completion = useSlashCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
|
@ -1202,12 +1238,14 @@ describe('useCompletion', () => {
|
|||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('/?');
|
||||
const completion = useCompletion(
|
||||
const completion = useSlashCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
|
@ -1229,12 +1267,13 @@ describe('useCompletion', () => {
|
|||
it('should complete a file path', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('@src/fi');
|
||||
const completion = useCompletion(
|
||||
const completion = useSlashCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
|
@ -1258,12 +1297,13 @@ describe('useCompletion', () => {
|
|||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest(text, cursorOffset);
|
||||
const completion = useCompletion(
|
||||
const completion = useSlashCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
|
@ -1286,12 +1326,13 @@ describe('useCompletion', () => {
|
|||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest(text);
|
||||
const completion = useCompletion(
|
||||
const completion = useSlashCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
|
@ -0,0 +1,654 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { glob } from 'glob';
|
||||
import {
|
||||
isNodeError,
|
||||
escapePath,
|
||||
unescapePath,
|
||||
getErrorMessage,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import {
|
||||
logicalPosToOffset,
|
||||
TextBuffer,
|
||||
} from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
|
||||
export interface UseSlashCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
activeSuggestionIndex: number;
|
||||
visibleStartIndex: number;
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (indexToUse: number) => void;
|
||||
}
|
||||
|
||||
export function useSlashCompletion(
|
||||
buffer: TextBuffer,
|
||||
dirs: readonly string[],
|
||||
cwd: string,
|
||||
slashCommands: readonly SlashCommand[],
|
||||
commandContext: CommandContext,
|
||||
reverseSearchActive: boolean = false,
|
||||
config?: Config,
|
||||
): UseSlashCompletionReturn {
|
||||
const {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
setVisibleStartIndex,
|
||||
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
} = useCompletion();
|
||||
|
||||
const completionStart = useRef(-1);
|
||||
const completionEnd = useRef(-1);
|
||||
|
||||
const cursorRow = buffer.cursor[0];
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
// Check if cursor is after @ or / without unescaped spaces
|
||||
const commandIndex = useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return currentLine.indexOf('/');
|
||||
}
|
||||
|
||||
// For other completions like '@', we search backwards from the cursor.
|
||||
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
||||
if (char === ' ') {
|
||||
// Check for unescaped spaces.
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
return -1; // Inactive on unescaped space.
|
||||
}
|
||||
} else if (char === '@') {
|
||||
// Active if we find an '@' before any unescaped space.
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}, [cursorRow, cursorCol, buffer.lines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commandIndex === -1 || reverseSearchActive) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
|
||||
if (codePoints[commandIndex] === '/') {
|
||||
// Always reset perfect match at the beginning of processing.
|
||||
setIsPerfectMatch(false);
|
||||
|
||||
const fullPath = currentLine.substring(commandIndex + 1);
|
||||
const hasTrailingSpace = currentLine.endsWith(' ');
|
||||
|
||||
// Get all non-empty parts of the command.
|
||||
const rawParts = fullPath.split(/\s+/).filter((p) => p);
|
||||
|
||||
let commandPathParts = rawParts;
|
||||
let partial = '';
|
||||
|
||||
// If there's no trailing space, the last part is potentially a partial segment.
|
||||
// We tentatively separate it.
|
||||
if (!hasTrailingSpace && rawParts.length > 0) {
|
||||
partial = rawParts[rawParts.length - 1];
|
||||
commandPathParts = rawParts.slice(0, -1);
|
||||
}
|
||||
|
||||
// Traverse the Command Tree using the tentative completed path
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
if (!currentLevel) {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
const found: SlashCommand | undefined = currentLevel.find(
|
||||
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
|
||||
);
|
||||
if (found) {
|
||||
leafCommand = found;
|
||||
currentLevel = found.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
} else {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let exactMatchAsParent: SlashCommand | undefined;
|
||||
// Handle the Ambiguous Case
|
||||
if (!hasTrailingSpace && currentLevel) {
|
||||
exactMatchAsParent = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.subCommands,
|
||||
);
|
||||
|
||||
if (exactMatchAsParent) {
|
||||
// It's a perfect match for a parent command. Override our initial guess.
|
||||
// Treat it as a completed command path.
|
||||
leafCommand = exactMatchAsParent;
|
||||
currentLevel = exactMatchAsParent.subCommands;
|
||||
partial = ''; // We now want to suggest ALL of its sub-commands.
|
||||
}
|
||||
}
|
||||
|
||||
// Check for perfect, executable match
|
||||
if (!hasTrailingSpace) {
|
||||
if (leafCommand && partial === '' && leafCommand.action) {
|
||||
// Case: /command<enter> - command has action, no sub-commands were suggested
|
||||
setIsPerfectMatch(true);
|
||||
} else if (currentLevel) {
|
||||
// Case: /command subcommand<enter>
|
||||
const perfectMatch = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.action,
|
||||
);
|
||||
if (perfectMatch) {
|
||||
setIsPerfectMatch(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
const isArgumentCompletion =
|
||||
leafCommand?.completion &&
|
||||
(hasTrailingSpace ||
|
||||
(rawParts.length > depth && depth > 0 && partial !== ''));
|
||||
|
||||
// Set completion range
|
||||
if (hasTrailingSpace || exactMatchAsParent) {
|
||||
completionStart.current = currentLine.length;
|
||||
completionEnd.current = currentLine.length;
|
||||
} else if (partial) {
|
||||
if (isArgumentCompletion) {
|
||||
const commandSoFar = `/${commandPathParts.join(' ')}`;
|
||||
const argStartIndex =
|
||||
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
|
||||
completionStart.current = argStartIndex;
|
||||
} else {
|
||||
completionStart.current = currentLine.length - partial.length;
|
||||
}
|
||||
completionEnd.current = currentLine.length;
|
||||
} else {
|
||||
// e.g. /
|
||||
completionStart.current = commandIndex + 1;
|
||||
completionEnd.current = currentLine.length;
|
||||
}
|
||||
|
||||
// Provide Suggestions based on the now-corrected context
|
||||
if (isArgumentCompletion) {
|
||||
const fetchAndSetSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
const argString = rawParts.slice(depth).join(' ');
|
||||
const results =
|
||||
(await leafCommand!.completion!(commandContext, argString)) || [];
|
||||
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
};
|
||||
fetchAndSetSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Command/Sub-command Completion
|
||||
const commandsToSearch = currentLevel || [];
|
||||
if (commandsToSearch.length > 0) {
|
||||
let potentialSuggestions = commandsToSearch.filter(
|
||||
(cmd) =>
|
||||
cmd.description &&
|
||||
(cmd.name.startsWith(partial) ||
|
||||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
|
||||
);
|
||||
|
||||
// If a user's input is an exact match and it is a leaf command,
|
||||
// enter should submit immediately.
|
||||
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
||||
const perfectMatch = potentialSuggestions.find(
|
||||
(s) => s.name === partial || s.altNames?.includes(partial),
|
||||
);
|
||||
if (perfectMatch && perfectMatch.action) {
|
||||
potentialSuggestions = [];
|
||||
}
|
||||
}
|
||||
|
||||
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we fall through, no suggestions are available.
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle At Command Completion
|
||||
completionEnd.current = codePoints.length;
|
||||
for (let i = cursorCol; i < codePoints.length; i++) {
|
||||
if (codePoints[i] === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
if (backslashCount % 2 === 0) {
|
||||
completionEnd.current = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathStart = commandIndex + 1;
|
||||
const partialPath = currentLine.substring(pathStart, completionEnd.current);
|
||||
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||
completionStart.current =
|
||||
lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
|
||||
const baseDirRelative =
|
||||
lastSlashIndex === -1
|
||||
? '.'
|
||||
: partialPath.substring(0, lastSlashIndex + 1);
|
||||
const prefix = unescapePath(
|
||||
lastSlashIndex === -1
|
||||
? partialPath
|
||||
: partialPath.substring(lastSlashIndex + 1),
|
||||
);
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const findFilesRecursively = async (
|
||||
startDir: string,
|
||||
searchPrefix: string,
|
||||
fileDiscovery: FileDiscoveryService | null,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
currentRelativePath = '',
|
||||
depth = 0,
|
||||
maxDepth = 10, // Limit recursion depth
|
||||
maxResults = 50, // Limit number of results
|
||||
): Promise<Suggestion[]> => {
|
||||
if (depth > maxDepth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerSearchPrefix = searchPrefix.toLowerCase();
|
||||
let foundSuggestions: Suggestion[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(startDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (foundSuggestions.length >= maxResults) break;
|
||||
|
||||
const entryPathRelative = path.join(currentRelativePath, entry.name);
|
||||
const entryPathFromRoot = path.relative(
|
||||
startDir,
|
||||
path.join(startDir, entry.name),
|
||||
);
|
||||
|
||||
// Conditionally ignore dotfiles
|
||||
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this entry should be ignored by filtering options
|
||||
if (
|
||||
fileDiscovery &&
|
||||
fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
|
||||
foundSuggestions.push({
|
||||
label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
value: escapePath(
|
||||
entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
entry.name !== 'node_modules' &&
|
||||
!entry.name.startsWith('.')
|
||||
) {
|
||||
if (foundSuggestions.length < maxResults) {
|
||||
foundSuggestions = foundSuggestions.concat(
|
||||
await findFilesRecursively(
|
||||
path.join(startDir, entry.name),
|
||||
searchPrefix, // Pass original searchPrefix for recursive calls
|
||||
fileDiscovery,
|
||||
filterOptions,
|
||||
entryPathRelative,
|
||||
depth + 1,
|
||||
maxDepth,
|
||||
maxResults - foundSuggestions.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore errors like permission denied or ENOENT during recursive search
|
||||
}
|
||||
return foundSuggestions.slice(0, maxResults);
|
||||
};
|
||||
|
||||
const findFilesWithGlob = async (
|
||||
searchPrefix: string,
|
||||
fileDiscoveryService: FileDiscoveryService,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
searchDir: string,
|
||||
maxResults = 50,
|
||||
): Promise<Suggestion[]> => {
|
||||
const globPattern = `**/${searchPrefix}*`;
|
||||
const files = await glob(globPattern, {
|
||||
cwd: searchDir,
|
||||
dot: searchPrefix.startsWith('.'),
|
||||
nocase: true,
|
||||
});
|
||||
|
||||
const suggestions: Suggestion[] = files
|
||||
.filter((file) => {
|
||||
if (fileDiscoveryService) {
|
||||
return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((file: string) => {
|
||||
const absolutePath = path.resolve(searchDir, file);
|
||||
const label = path.relative(cwd, absolutePath);
|
||||
return {
|
||||
label,
|
||||
value: escapePath(label),
|
||||
};
|
||||
})
|
||||
.slice(0, maxResults);
|
||||
|
||||
return suggestions;
|
||||
};
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
let fetchedSuggestions: Suggestion[] = [];
|
||||
|
||||
const fileDiscoveryService = config ? config.getFileService() : null;
|
||||
const enableRecursiveSearch =
|
||||
config?.getEnableRecursiveFileSearch() ?? true;
|
||||
const filterOptions =
|
||||
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
try {
|
||||
// If there's no slash, or it's the root, do a recursive search from workspace directories
|
||||
for (const dir of dirs) {
|
||||
let fetchedSuggestionsPerDir: Suggestion[] = [];
|
||||
if (
|
||||
partialPath.indexOf('/') === -1 &&
|
||||
prefix &&
|
||||
enableRecursiveSearch
|
||||
) {
|
||||
if (fileDiscoveryService) {
|
||||
fetchedSuggestionsPerDir = await findFilesWithGlob(
|
||||
prefix,
|
||||
fileDiscoveryService,
|
||||
filterOptions,
|
||||
dir,
|
||||
);
|
||||
} else {
|
||||
fetchedSuggestionsPerDir = await findFilesRecursively(
|
||||
dir,
|
||||
prefix,
|
||||
null,
|
||||
filterOptions,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Original behavior: list files in the specific directory
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const baseDirAbsolute = path.resolve(dir, baseDirRelative);
|
||||
const entries = await fs.readdir(baseDirAbsolute, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
// Filter entries using git-aware filtering
|
||||
const filteredEntries = [];
|
||||
for (const entry of entries) {
|
||||
// Conditionally ignore dotfiles
|
||||
if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||
|
||||
const relativePath = path.relative(
|
||||
dir,
|
||||
path.join(baseDirAbsolute, entry.name),
|
||||
);
|
||||
if (
|
||||
fileDiscoveryService &&
|
||||
fileDiscoveryService.shouldIgnoreFile(
|
||||
relativePath,
|
||||
filterOptions,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filteredEntries.push(entry);
|
||||
}
|
||||
|
||||
fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
|
||||
const absolutePath = path.resolve(baseDirAbsolute, entry.name);
|
||||
const label =
|
||||
cwd === dir ? entry.name : path.relative(cwd, absolutePath);
|
||||
const suggestionLabel = entry.isDirectory() ? label + '/' : label;
|
||||
return {
|
||||
label: suggestionLabel,
|
||||
value: escapePath(suggestionLabel),
|
||||
};
|
||||
});
|
||||
}
|
||||
fetchedSuggestions = [
|
||||
...fetchedSuggestions,
|
||||
...fetchedSuggestionsPerDir,
|
||||
];
|
||||
}
|
||||
|
||||
// Like glob, we always return forwardslashes, even in windows.
|
||||
fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
|
||||
...suggestion,
|
||||
label: suggestion.label.replace(/\\/g, '/'),
|
||||
value: suggestion.value.replace(/\\/g, '/'),
|
||||
}));
|
||||
|
||||
// Sort by depth, then directories first, then alphabetically
|
||||
fetchedSuggestions.sort((a, b) => {
|
||||
const depthA = (a.label.match(/\//g) || []).length;
|
||||
const depthB = (b.label.match(/\//g) || []).length;
|
||||
|
||||
if (depthA !== depthB) {
|
||||
return depthA - depthB;
|
||||
}
|
||||
|
||||
const aIsDir = a.label.endsWith('/');
|
||||
const bIsDir = b.label.endsWith('/');
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
|
||||
// exclude extension when comparing
|
||||
const filenameA = a.label.substring(
|
||||
0,
|
||||
a.label.length - path.extname(a.label).length,
|
||||
);
|
||||
const filenameB = b.label.substring(
|
||||
0,
|
||||
b.label.length - path.extname(b.label).length,
|
||||
);
|
||||
|
||||
return (
|
||||
filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
|
||||
);
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setSuggestions(fetchedSuggestions);
|
||||
setShowSuggestions(fetchedSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (isMounted) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
if (isMounted) {
|
||||
resetCompletionState();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isMounted) {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(debounceTimeout);
|
||||
};
|
||||
}, [
|
||||
buffer.text,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
buffer.lines,
|
||||
dirs,
|
||||
cwd,
|
||||
commandIndex,
|
||||
resetCompletionState,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
reverseSearchActive,
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
setVisibleStartIndex,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
(indexToUse: number) => {
|
||||
if (indexToUse < 0 || indexToUse >= suggestions.length) {
|
||||
return;
|
||||
}
|
||||
const suggestion = suggestions[indexToUse].value;
|
||||
|
||||
if (completionStart.current === -1 || completionEnd.current === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/';
|
||||
let suggestionText = suggestion;
|
||||
if (isSlash) {
|
||||
// If we are inserting (not replacing), and the preceding character is not a space, add one.
|
||||
if (
|
||||
completionStart.current === completionEnd.current &&
|
||||
completionStart.current > commandIndex + 1 &&
|
||||
(buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
|
||||
) {
|
||||
suggestionText = ' ' + suggestionText;
|
||||
}
|
||||
suggestionText += ' ';
|
||||
}
|
||||
|
||||
buffer.replaceRangeByOffset(
|
||||
logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
|
||||
logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
|
||||
suggestionText,
|
||||
);
|
||||
resetCompletionState();
|
||||
},
|
||||
[cursorRow, resetCompletionState, buffer, suggestions, commandIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
setActiveSuggestionIndex,
|
||||
setShowSuggestions,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue