diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index e0d967da..6b7bc7ce 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 5a7b6353..db4eec1b 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -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 = ({
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 = ({
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 = ({
useEffect(() => {
if (justNavigatedHistory) {
resetCompletionState();
+ resetReverseSearchCompletionState();
setJustNavigatedHistory(false);
}
}, [
@@ -125,6 +149,7 @@ export const InputPrompt: React.FC = ({
buffer.text,
resetCompletionState,
setJustNavigatedHistory,
+ resetReverseSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -197,6 +222,19 @@ export const InputPrompt: React.FC = ({
}
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 = ({
}
}
+ 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 = ({
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 = ({
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 = ({
inputHistory,
handleSubmitAndClear,
shellHistory,
+ reverseSearchCompletion,
handleClipboardImage,
resetCompletionState,
vimHandleInput,
+ reverseSearchActive,
+ textBeforeReverseSearch,
+ cursorPosition,
],
);
@@ -385,7 +475,15 @@ export const InputPrompt: React.FC = ({
- {shellModeActive ? '! ' : '> '}
+ {shellModeActive ? (
+ reverseSearchActive ? (
+ (r:)
+ ) : (
+ '! '
+ )
+ ) : (
+ '> '
+ )}
{buffer.text.length === 0 && placeholder ? (
@@ -449,6 +547,18 @@ export const InputPrompt: React.FC = ({
/>
)}
+ {reverseSearchActive && (
+
+
+
+ )}
>
);
};
diff --git a/packages/cli/src/ui/components/PrepareLabel.tsx b/packages/cli/src/ui/components/PrepareLabel.tsx
new file mode 100644
index 00000000..652a77a6
--- /dev/null
+++ b/packages/cli/src/ui/components/PrepareLabel.tsx
@@ -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 = ({
+ label,
+ matchedIndex,
+ userInput,
+ textColor,
+ highlightColor = Colors.AccentYellow,
+}) => {
+ if (
+ matchedIndex === undefined ||
+ matchedIndex < 0 ||
+ matchedIndex >= label.length ||
+ userInput.length === 0
+ ) {
+ return {label};
+ }
+
+ const start = label.slice(0, matchedIndex);
+ const match = label.slice(matchedIndex, matchedIndex + userInput.length);
+ const end = label.slice(matchedIndex + userInput.length);
+
+ return (
+
+ {start}
+
+ {match}
+
+ {end}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
index 0620665f..9c4b5687 100644
--- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx
+++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
@@ -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 = (
+
+ );
return (
-
+
{userInput.startsWith('/') ? (
// only use box model for (/) command mode
- {suggestion.label}
+ {labelElement}
) : (
- // use regular text for other modes (@ context)
- {suggestion.label}
+ labelElement
)}
{suggestion.description ? (
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index 7790f835..242b4528 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -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>;
setActiveSuggestionIndex: React.Dispatch>;
+ setVisibleStartIndex: React.Dispatch>;
+ setIsLoadingSuggestions: React.Dispatch>;
+ setIsPerfectMatch: React.Dispatch>;
setShowSuggestions: React.Dispatch>;
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([]);
const [activeSuggestionIndex, setActiveSuggestionIndex] =
useState(-1);
@@ -60,11 +38,6 @@ export function useCompletion(
const [isLoadingSuggestions, setIsLoadingSuggestions] =
useState(false);
const [isPerfectMatch, setIsPerfectMatch] = useState(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 - command has action, no sub-commands were suggested
- setIsPerfectMatch(true);
- } else if (currentLevel) {
- // Case: /command subcommand
- 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 => {
- 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 => {
- 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,
};
}
diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx
new file mode 100644
index 00000000..373696ce
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx
@@ -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);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
new file mode 100644
index 00000000..1cc7e602
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
@@ -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((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,
+ };
+}
diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts
index 61c7207c..2e18dfbd 100644
--- a/packages/cli/src/ui/hooks/useShellHistory.ts
+++ b/packages/cli/src/ui/hooks/useShellHistory.ts
@@ -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 {
return path.join(historyDir, HISTORY_FILE);
}
+// Handle multiline commands
async function readHistoryFile(filePath: string): Promise {
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,
};
}
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
similarity index 95%
rename from packages/cli/src/ui/hooks/useCompletion.test.ts
rename to packages/cli/src/ui/hooks/useSlashCompletion.test.ts
index 3a401194..13f8c240 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
@@ -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 };
diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useSlashCompletion.tsx
new file mode 100644
index 00000000..f68d52d8
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.tsx
@@ -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>;
+ setShowSuggestions: React.Dispatch>;
+ 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 - command has action, no sub-commands were suggested
+ setIsPerfectMatch(true);
+ } else if (currentLevel) {
+ // Case: /command subcommand
+ 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 => {
+ 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 => {
+ 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,
+ };
+}