From 072d8ba2899f2601dad6d4b0333fdcb80555a7dd Mon Sep 17 00:00:00 2001 From: Ayesha Shafique <79274585+Aisha630@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:53:24 +0500 Subject: [PATCH] feat: Add reverse search capability for shell commands (#4793) --- .../src/ui/components/InputPrompt.test.tsx | 282 +++++--- .../cli/src/ui/components/InputPrompt.tsx | 126 +++- .../cli/src/ui/components/PrepareLabel.tsx | 48 ++ .../src/ui/components/SuggestionsDisplay.tsx | 17 +- packages/cli/src/ui/hooks/useCompletion.ts | 604 +--------------- .../hooks/useReverseSearchCompletion.test.tsx | 260 +++++++ .../ui/hooks/useReverseSearchCompletion.tsx | 91 +++ packages/cli/src/ui/hooks/useShellHistory.ts | 37 +- ...ion.test.ts => useSlashCompletion.test.ts} | 123 ++-- .../cli/src/ui/hooks/useSlashCompletion.tsx | 654 ++++++++++++++++++ 10 files changed, 1505 insertions(+), 737 deletions(-) create mode 100644 packages/cli/src/ui/components/PrepareLabel.tsx create mode 100644 packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx create mode 100644 packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx rename packages/cli/src/ui/hooks/{useCompletion.test.ts => useSlashCompletion.test.ts} (95%) create mode 100644 packages/cli/src/ui/hooks/useSlashCompletion.tsx 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, + }; +}