diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 1576cef2e8..ccf212ce8e 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -411,6 +411,73 @@ describe('InputPrompt', () => { unmount(); }); + it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 0, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press Enter without navigating — should dismiss suggestions and fall + // through to the main submit handler. + await act(async () => { + stdin.write('\r'); + }); + await waitFor(() => { + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed) + }); + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); + unmount(); + }); + + it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 1, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press ArrowDown to navigate, then Enter to accept + await act(async () => { + stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions + }); + await waitFor(() => + expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(), + ); + + await act(async () => { + stdin.write('\r'); // Enter — should accept navigated suggestion + }); + await waitFor(() => { + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + }); + expect(props.onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + it('should NOT call shell history methods when not in shell mode', async () => { props.buffer.setText('some text'); const { stdin, unmount } = renderWithProviders(, { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 689df105ca..7d7c9b5709 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -259,6 +259,7 @@ export const InputPrompt: React.FC = ({ >(null); const pasteTimeoutRef = useRef(null); const innerBoxRef = useRef(null); + const hasUserNavigatedSuggestions = useRef(false); const [reverseSearchActive, setReverseSearchActive] = useState(false); const [commandSearchActive, setCommandSearchActive] = useState(false); @@ -615,6 +616,7 @@ export const InputPrompt: React.FC = ({ setSuppressCompletion( isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key), ); + hasUserNavigatedSuggestions.current = false; } // TODO(jacobr): this special case is likely not needed anymore. @@ -648,7 +650,13 @@ export const InputPrompt: React.FC = ({ Boolean(completion.promptCompletion.text) || reverseSearchActive || commandSearchActive; - if (isPlainTab) { + + if (isPlainTab && shellModeActive) { + resetPlainTabPress(); + if (!completion.showSuggestions) { + setSuppressCompletion(false); + } + } else if (isPlainTab) { if (!hasTabCompletionInteraction) { if (registerPlainTabPress() === 2) { toggleCleanUiDetailsVisible(); @@ -908,11 +916,13 @@ export const InputPrompt: React.FC = ({ if (completion.suggestions.length > 1) { if (keyMatchers[Command.COMPLETION_UP](key)) { completion.navigateUp(); + hasUserNavigatedSuggestions.current = true; setExpandedSuggestionIndex(-1); // Reset expansion when navigating return true; } if (keyMatchers[Command.COMPLETION_DOWN](key)) { completion.navigateDown(); + hasUserNavigatedSuggestions.current = true; setExpandedSuggestionIndex(-1); // Reset expansion when navigating return true; } @@ -930,6 +940,24 @@ export const InputPrompt: React.FC = ({ const isEnterKey = key.name === 'return' && !key.ctrl; + if (isEnterKey && shellModeActive) { + if (hasUserNavigatedSuggestions.current) { + completion.handleAutocomplete( + completion.activeSuggestionIndex, + ); + setExpandedSuggestionIndex(-1); + hasUserNavigatedSuggestions.current = false; + return true; + } + completion.resetCompletionState(); + setExpandedSuggestionIndex(-1); + hasUserNavigatedSuggestions.current = false; + if (buffer.text.trim()) { + handleSubmit(buffer.text); + } + return true; + } + if (isEnterKey && buffer.text.startsWith('/')) { const { isArgumentCompletion, leafCommand } = completion.slashCompletionRange; @@ -1386,7 +1414,8 @@ export const InputPrompt: React.FC = ({ scrollOffset={activeCompletion.visibleStartIndex} userInput={buffer.text} mode={ - completion.completionMode === CompletionMode.AT + completion.completionMode === CompletionMode.AT || + completion.completionMode === CompletionMode.SHELL ? 'reverse' : buffer.text.startsWith('/') && !reverseSearchActive && diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 6a9bf5aeac..a433295cd2 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -78,6 +78,27 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub " `; +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 2b0bad2743..c7bb2afb50 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -40,6 +40,16 @@ vi.mock('./useSlashCompletion', () => ({ })), })); +vi.mock('./useShellCompletion', async () => { + const actual = await vi.importActual< + typeof import('./useShellCompletion.js') + >('./useShellCompletion'); + return { + ...actual, + useShellCompletion: vi.fn(), + }; +}); + // Helper to set up mocks in a consistent way for both child hooks const setupMocks = ({ atSuggestions = [], @@ -94,6 +104,7 @@ const setupMocks = ({ describe('useCommandCompletion', () => { const mockCommandContext = {} as CommandContext; const mockConfig = { + getEnablePromptCompletion: () => false, getGeminiClient: vi.fn(), } as unknown as Config; const testRootDir = '/'; @@ -498,6 +509,7 @@ describe('useCommandCompletion', () => { describe('prompt completion filtering', () => { it('should not trigger prompt completion for line comments', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; @@ -530,6 +542,7 @@ describe('useCommandCompletion', () => { it('should not trigger prompt completion for block comments', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; @@ -564,6 +577,7 @@ describe('useCommandCompletion', () => { it('should trigger prompt completion for regular text when enabled', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index b9fcb95626..c13834a537 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; +import { useShellCompletion, getTokenAtCursor } from './useShellCompletion.js'; import type { PromptCompletion } from './usePromptCompletion.js'; import { usePromptCompletion, @@ -26,6 +27,7 @@ export enum CompletionMode { AT = 'AT', SLASH = 'SLASH', PROMPT = 'PROMPT', + SHELL = 'SHELL', } export interface UseCommandCompletionReturn { @@ -99,85 +101,114 @@ export function useCommandCompletion({ const cursorRow = buffer.cursor[0]; const cursorCol = buffer.cursor[1]; - const { completionMode, query, completionStart, completionEnd } = - useMemo(() => { - const currentLine = buffer.lines[cursorRow] || ''; - const codePoints = toCodePoints(currentLine); + const { + completionMode, + query, + completionStart, + completionEnd, + shellTokenIsCommand, + } = useMemo(() => { + const currentLine = buffer.lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); - // FIRST: Check for @ completion (scan backwards from cursor) - // This must happen before slash command check so that `/cmd @file` - // triggers file completion, not just slash command completion. - for (let i = cursorCol - 1; i >= 0; i--) { - const char = codePoints[i]; + if (shellModeActive) { + const tokenInfo = getTokenAtCursor(currentLine, cursorCol); + if (tokenInfo) { + return { + completionMode: CompletionMode.SHELL, + query: tokenInfo.token, + completionStart: tokenInfo.start, + completionEnd: tokenInfo.end, + shellTokenIsCommand: tokenInfo.isFirstToken, + }; + } + return { + completionMode: CompletionMode.SHELL, + query: '', + completionStart: cursorCol, + completionEnd: cursorCol, + shellTokenIsCommand: currentLine.trim().length === 0, + }; + } - if (char === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - if (backslashCount % 2 === 0) { - break; - } - } else if (char === '@') { - let end = 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++; - } + // FIRST: Check for @ completion (scan backwards from cursor) + // This must happen before slash command check so that `/cmd @file` + // triggers file completion, not just slash command completion. + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; - if (backslashCount % 2 === 0) { - end = i; - break; - } + if (char === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + break; + } + } else if (char === '@') { + let end = 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) { + end = i; + break; } } - const pathStart = i + 1; - const partialPath = currentLine.substring(pathStart, end); - return { - completionMode: CompletionMode.AT, - query: partialPath, - completionStart: pathStart, - completionEnd: end, - }; } - } - - // THEN: Check for slash command (only if no @ completion is active) - if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + const pathStart = i + 1; + const partialPath = currentLine.substring(pathStart, end); return { - completionMode: CompletionMode.SLASH, - query: currentLine, - completionStart: 0, - completionEnd: currentLine.length, - }; - } - - // Check for prompt completion - only if enabled - const trimmedText = buffer.text.trim(); - const isPromptCompletionEnabled = false; - if ( - isPromptCompletionEnabled && - trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && - !isSlashCommand(trimmedText) && - !trimmedText.includes('@') - ) { - return { - completionMode: CompletionMode.PROMPT, - query: trimmedText, - completionStart: 0, - completionEnd: trimmedText.length, + completionMode: CompletionMode.AT, + query: partialPath, + completionStart: pathStart, + completionEnd: end, + shellTokenIsCommand: false, }; } + } + // THEN: Check for slash command (only if no @ completion is active) + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { return { - completionMode: CompletionMode.IDLE, - query: null, - completionStart: -1, - completionEnd: -1, + completionMode: CompletionMode.SLASH, + query: currentLine, + completionStart: 0, + completionEnd: currentLine.length, + shellTokenIsCommand: false, }; - }, [cursorRow, cursorCol, buffer.lines, buffer.text]); + } + + // Check for prompt completion - only if enabled + const trimmedText = buffer.text.trim(); + const isPromptCompletionEnabled = false; + if ( + isPromptCompletionEnabled && + trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && + !isSlashCommand(trimmedText) && + !trimmedText.includes('@') + ) { + return { + completionMode: CompletionMode.PROMPT, + query: trimmedText, + completionStart: 0, + completionEnd: trimmedText.length, + shellTokenIsCommand: false, + }; + } + + return { + completionMode: CompletionMode.IDLE, + query: null, + completionStart: -1, + completionEnd: -1, + shellTokenIsCommand: false, + }; + }, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]); useAtCompletion({ enabled: active && completionMode === CompletionMode.AT, @@ -199,9 +230,17 @@ export function useCommandCompletion({ setIsPerfectMatch, }); + useShellCompletion({ + enabled: active && completionMode === CompletionMode.SHELL, + query: query || '', + isCommandPosition: shellTokenIsCommand, + cwd, + setSuggestions, + setIsLoadingSuggestions, + }); + const promptCompletion = usePromptCompletion({ buffer, - config, }); useEffect(() => { diff --git a/packages/cli/src/ui/hooks/useShellCompletion.test.ts b/packages/cli/src/ui/hooks/useShellCompletion.test.ts new file mode 100644 index 0000000000..e5e8f1e05c --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellCompletion.test.ts @@ -0,0 +1,280 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + getTokenAtCursor, + escapeShellPath, + resolvePathCompletions, + scanPathExecutables, +} from './useShellCompletion.js'; +import type { FileSystemStructure } from '@google/gemini-cli-test-utils'; +import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; + +describe('useShellCompletion utilities', () => { + describe('getTokenAtCursor', () => { + it('should return null for empty line', () => { + expect(getTokenAtCursor('', 0)).toBeNull(); + }); + + it('should extract the first token at cursor position 0', () => { + const result = getTokenAtCursor('git status', 3); + expect(result).toEqual({ + token: 'git', + start: 0, + end: 3, + isFirstToken: true, + }); + }); + + it('should extract the second token when cursor is on it', () => { + const result = getTokenAtCursor('git status', 7); + expect(result).toEqual({ + token: 'status', + start: 4, + end: 10, + isFirstToken: false, + }); + }); + + it('should handle cursor at start of second token', () => { + const result = getTokenAtCursor('git status', 4); + expect(result).toEqual({ + token: 'status', + start: 4, + end: 10, + isFirstToken: false, + }); + }); + + it('should handle escaped spaces', () => { + const result = getTokenAtCursor('cat my\\ file.txt', 16); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 16, + isFirstToken: false, + }); + }); + + it('should handle single-quoted strings', () => { + const result = getTokenAtCursor("cat 'my file.txt'", 17); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 17, + isFirstToken: false, + }); + }); + + it('should handle double-quoted strings', () => { + const result = getTokenAtCursor('cat "my file.txt"', 17); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 17, + isFirstToken: false, + }); + }); + + it('should handle cursor past all tokens (trailing space)', () => { + const result = getTokenAtCursor('git ', 4); + expect(result).toEqual({ + token: '', + start: 4, + end: 4, + isFirstToken: false, + }); + }); + + it('should handle cursor in the middle of a word', () => { + const result = getTokenAtCursor('git checkout main', 7); + expect(result).toEqual({ + token: 'checkout', + start: 4, + end: 12, + isFirstToken: false, + }); + }); + + it('should mark isFirstToken correctly for first word', () => { + const result = getTokenAtCursor('gi', 2); + expect(result?.isFirstToken).toBe(true); + }); + + it('should mark isFirstToken correctly for second word', () => { + const result = getTokenAtCursor('git sta', 7); + expect(result?.isFirstToken).toBe(false); + }); + }); + + describe('escapeShellPath', () => { + it('should escape spaces', () => { + expect(escapeShellPath('my file.txt')).toBe('my\\ file.txt'); + }); + + it('should escape parentheses', () => { + expect(escapeShellPath('file (copy).txt')).toBe('file\\ \\(copy\\).txt'); + }); + + it('should not escape normal characters', () => { + expect(escapeShellPath('normal-file.txt')).toBe('normal-file.txt'); + }); + + it('should handle empty string', () => { + expect(escapeShellPath('')).toBe(''); + }); + }); + + describe('resolvePathCompletions', () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await cleanupTmpDir(tmpDir); + } + }); + + it('should list directory contents for empty partial', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + subdir: {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + const values = results.map((s) => s.label); + expect(values).toContain('subdir/'); + expect(values).toContain('file.txt'); + }); + + it('should filter by prefix', async () => { + const structure: FileSystemStructure = { + 'abc.txt': '', + 'def.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('a', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('abc.txt'); + }); + + it('should match case-insensitively', async () => { + const structure: FileSystemStructure = { + Desktop: {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('desk', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('Desktop/'); + }); + + it('should append trailing slash to directories', async () => { + const structure: FileSystemStructure = { + mydir: {}, + 'myfile.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('my', tmpDir); + const dirSuggestion = results.find((s) => s.label.startsWith('mydir')); + expect(dirSuggestion?.label).toBe('mydir/'); + expect(dirSuggestion?.description).toBe('directory'); + }); + + it('should hide dotfiles by default', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).not.toContain('.hidden'); + expect(labels).toContain('visible'); + }); + + it('should show dotfiles when query starts with a dot', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + '.bashrc': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('.h', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.hidden'); + }); + + it('should return empty array for non-existent directory', async () => { + const results = await resolvePathCompletions( + '/nonexistent/path/foo', + '/tmp', + ); + expect(results).toEqual([]); + }); + + it('should handle tilde expansion', async () => { + // Just ensure ~ doesn't throw + const results = await resolvePathCompletions('~/', '/tmp'); + // We can't assert specific files since it depends on the test runner's home + expect(Array.isArray(results)).toBe(true); + }); + + it('should escape special characters in results', async () => { + const structure: FileSystemStructure = { + 'my file.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('my', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].value).toBe('my\\ file.txt'); + }); + + it('should sort directories before files', async () => { + const structure: FileSystemStructure = { + 'b-file.txt': '', + 'a-dir': {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + expect(results[0].description).toBe('directory'); + expect(results[1].description).toBe('file'); + }); + }); + + describe('scanPathExecutables', () => { + it('should return an array of executables', async () => { + const results = await scanPathExecutables(); + expect(Array.isArray(results)).toBe(true); + // Very basic sanity check: common commands should be found + if (process.platform !== 'win32') { + expect(results).toContain('ls'); + } + }); + + it('should support abort signal', async () => { + const controller = new AbortController(); + controller.abort(); + const results = await scanPathExecutables(controller.signal); + // May return empty or partial depending on timing + expect(Array.isArray(results)).toBe(true); + }); + + it('should handle empty PATH', async () => { + vi.stubEnv('PATH', ''); + const results = await scanPathExecutables(); + expect(results).toEqual([]); + vi.unstubAllEnvs(); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts new file mode 100644 index 0000000000..070d0a6046 --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellCompletion.ts @@ -0,0 +1,466 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useCallback } from 'react'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +/** + * Maximum number of suggestions to return to avoid freezing the React Ink UI. + */ +const MAX_SHELL_SUGGESTIONS = 100; + +/** + * Debounce interval (ms) for file system completions. + */ +const FS_COMPLETION_DEBOUNCE_MS = 50; + +// Backslash-quote shell metacharacters on non-Windows platforms. + +// On Unix, backslash-quote shell metacharacters (spaces, parens, etc.). +// On Windows, cmd.exe doesn't use backslash-quoting and `\` is the path +// separator, so we leave the path as-is. +const UNIX_SHELL_SPECIAL_CHARS = /[ '"()&|;<>!#$`{}[\]*?]/g; + +/** + * Escapes special shell characters in a path segment. + */ +export function escapeShellPath(segment: string): string { + if (process.platform === 'win32') { + return segment; + } + return segment.replace(UNIX_SHELL_SPECIAL_CHARS, '\\$&'); +} + +export interface TokenInfo { + /** The raw token text (without surrounding quotes but with internal escapes). */ + token: string; + /** Offset in the original line where this token begins. */ + start: number; + /** Offset in the original line where this token ends (exclusive). */ + end: number; + /** Whether this is the first token (command position). */ + isFirstToken: boolean; +} + +export function getTokenAtCursor( + line: string, + cursorCol: number, +): TokenInfo | null { + const tokens: Array<{ token: string; start: number; end: number }> = []; + let i = 0; + + while (i < line.length) { + // Skip whitespace + if (line[i] === ' ' || line[i] === '\t') { + i++; + continue; + } + + const tokenStart = i; + let token = ''; + + while (i < line.length) { + const ch = line[i]; + + // Backslash escape: consume the next char literally + if (ch === '\\' && i + 1 < line.length) { + token += line[i + 1]; + i += 2; + continue; + } + + // Single-quoted string + if (ch === "'") { + i++; // skip opening quote + while (i < line.length && line[i] !== "'") { + token += line[i]; + i++; + } + if (i < line.length) i++; // skip closing quote + continue; + } + + // Double-quoted string + if (ch === '"') { + i++; // skip opening quote + while (i < line.length && line[i] !== '"') { + if (line[i] === '\\' && i + 1 < line.length) { + token += line[i + 1]; + i += 2; + } else { + token += line[i]; + i++; + } + } + if (i < line.length) i++; // skip closing quote + continue; + } + + // Unquoted whitespace ends the token + if (ch === ' ' || ch === '\t') { + break; + } + + token += ch; + i++; + } + + tokens.push({ token, start: tokenStart, end: i }); + } + + if (tokens.length === 0) { + return null; + } + + // Find the token that contains or is immediately adjacent to the cursor + for (let idx = 0; idx < tokens.length; idx++) { + const t = tokens[idx]; + if (cursorCol >= t.start && cursorCol <= t.end) { + return { + token: t.token, + start: t.start, + end: t.end, + isFirstToken: idx === 0, + }; + } + } + + // Cursor is past all tokens — treat as starting a new token at cursor position + // (useful when the user types a space and then presses Tab) + return { + token: '', + start: cursorCol, + end: cursorCol, + isFirstToken: tokens.length === 0, + }; +} + +export async function scanPathExecutables( + signal?: AbortSignal, +): Promise { + const pathEnv = process.env['PATH'] ?? ''; + const dirs = pathEnv.split(path.delimiter).filter(Boolean); + const isWindows = process.platform === 'win32'; + const pathExtList = isWindows + ? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') + .split(';') + .filter(Boolean) + .map((e) => e.toLowerCase()) + : []; + + const seen = new Set(); + const executables: string[] = []; + + const dirResults = await Promise.all( + dirs.map(async (dir) => { + if (signal?.aborted) return []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const validEntries: string[] = []; + + // Check executability in parallel (batched per directory) + await Promise.all( + entries.map(async (entry) => { + if (signal?.aborted) return; + if (!entry.isFile() && !entry.isSymbolicLink()) return; + + const name = entry.name; + if (isWindows) { + const ext = path.extname(name).toLowerCase(); + if (pathExtList.length > 0 && !pathExtList.includes(ext)) return; + } + + try { + await fs.access( + path.join(dir, name), + fs.constants.R_OK | fs.constants.X_OK, + ); + validEntries.push(name); + } catch { + // Not executable — skip + } + }), + ); + + return validEntries; + } catch { + // EACCES, ENOENT, etc. — skip this directory + return []; + } + }), + ); + + for (const names of dirResults) { + for (const name of names) { + if (!seen.has(name)) { + seen.add(name); + executables.push(name); + } + } + } + + executables.sort(); + return executables; +} + +function expandTilde(inputPath: string): [string, boolean] { + if ( + inputPath === '~' || + inputPath.startsWith('~/') || + inputPath.startsWith('~' + path.sep) + ) { + return [path.join(os.homedir(), inputPath.slice(1)), true]; + } + return [inputPath, false]; +} + +export async function resolvePathCompletions( + partial: string, + cwd: string, + signal?: AbortSignal, +): Promise { + if (partial == null) return []; + + const [expandedPartial, didExpandTilde] = expandTilde(partial); + + // Determine the directory to list and the prefix to match + const resolvedPath = path.isAbsolute(expandedPartial) + ? expandedPartial + : path.resolve(cwd, expandedPartial); + + // If the partial ends with a separator, list that directory directly. + // Otherwise, list the parent and filter by the basename prefix. + const endsWithSep = + partial.endsWith('/') || partial.endsWith(path.sep) || partial === ''; + const dirToRead = endsWithSep ? resolvedPath : path.dirname(resolvedPath); + const prefix = endsWithSep ? '' : path.basename(resolvedPath); + const prefixLower = prefix.toLowerCase(); + + // Determine whether to show dotfiles + const showDotfiles = prefix.startsWith('.'); + + let entries: Array; + try { + if (signal?.aborted) return []; + entries = await fs.readdir(dirToRead, { withFileTypes: true }); + } catch { + // EACCES, ENOENT, etc. + return []; + } + + if (signal?.aborted) return []; + + const suggestions: Suggestion[] = []; + for (const entry of entries) { + if (signal?.aborted) break; + + const name = entry.name; + + // Hide dotfiles unless query starts with '.' + if (name.startsWith('.') && !showDotfiles) continue; + + // Case-insensitive matching + if (!name.toLowerCase().startsWith(prefixLower)) continue; + + const isDir = entry.isDirectory(); + const displayName = isDir ? name + path.sep : name; + + // Build the completion value relative to what the user typed + let completionValue: string; + if (endsWithSep) { + completionValue = partial + displayName; + } else { + // Replace the basename portion + const parentPart = partial.slice( + 0, + partial.length - path.basename(partial).length, + ); + completionValue = parentPart + displayName; + } + + // Restore tilde if we expanded it + if (didExpandTilde) { + const homeDir = os.homedir(); + if (completionValue.startsWith(homeDir)) { + completionValue = '~' + completionValue.slice(homeDir.length); + } + } + + // Escape special characters in the completion value + const escapedValue = escapeShellPath(completionValue); + + suggestions.push({ + label: displayName, + value: escapedValue, + description: isDir ? 'directory' : 'file', + }); + + if (suggestions.length >= MAX_SHELL_SUGGESTIONS) break; + } + + // Sort: directories first, then alphabetically + suggestions.sort((a, b) => { + const aIsDir = a.description === 'directory'; + const bIsDir = b.description === 'directory'; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.label.localeCompare(b.label); + }); + + return suggestions; +} + +export interface UseShellCompletionProps { + /** Whether shell completion is active. */ + enabled: boolean; + /** The partial query string (the token under the cursor). */ + query: string; + /** Whether the token is in command position (first word). */ + isCommandPosition: boolean; + /** The current working directory for path resolution. */ + cwd: string; + /** Callback to set suggestions on the parent state. */ + setSuggestions: (suggestions: Suggestion[]) => void; + /** Callback to set loading state on the parent. */ + setIsLoadingSuggestions: (isLoading: boolean) => void; +} + +export function useShellCompletion({ + enabled, + query, + isCommandPosition, + cwd, + setSuggestions, + setIsLoadingSuggestions, +}: UseShellCompletionProps): void { + const pathCacheRef = useRef(null); + const pathEnvRef = useRef(process.env['PATH'] ?? ''); + const abortRef = useRef(null); + const debounceRef = useRef(null); + + // Invalidate PATH cache when $PATH changes + useEffect(() => { + const currentPath = process.env['PATH'] ?? ''; + if (currentPath !== pathEnvRef.current) { + pathCacheRef.current = null; + pathEnvRef.current = currentPath; + } + }); + + const performCompletion = useCallback(async () => { + if (!enabled) { + setSuggestions([]); + return; + } + + // Skip flags + if (query.startsWith('-')) { + setSuggestions([]); + return; + } + + // Cancel any in-flight request + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + + try { + let results: Suggestion[]; + + if (isCommandPosition) { + setIsLoadingSuggestions(true); + + if (!pathCacheRef.current) { + pathCacheRef.current = await scanPathExecutables(signal); + } + + if (signal.aborted) return; + + const queryLower = query.toLowerCase(); + results = pathCacheRef.current + .filter((cmd) => cmd.toLowerCase().startsWith(queryLower)) + .slice(0, MAX_SHELL_SUGGESTIONS) + .map((cmd) => ({ + label: cmd, + value: cmd, + description: 'command', + })); + } else { + results = await resolvePathCompletions(query, cwd, signal); + } + + if (signal.aborted) return; + + setSuggestions(results); + } catch (error) { + if ( + !( + signal.aborted || + (error instanceof Error && error.name === 'AbortError') + ) + ) { + debugLogger.warn( + `[WARN] shell completion failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!signal.aborted) { + setSuggestions([]); + } + } finally { + if (!signal.aborted) { + setIsLoadingSuggestions(false); + } + } + }, [ + enabled, + query, + isCommandPosition, + cwd, + setSuggestions, + setIsLoadingSuggestions, + ]); + + // Debounced effect to trigger completion + useEffect(() => { + if (!enabled) { + setSuggestions([]); + setIsLoadingSuggestions(false); + return; + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + performCompletion(); + }, FS_COMPLETION_DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [enabled, performCompletion, setSuggestions, setIsLoadingSuggestions]); + + // Cleanup on unmount + useEffect( + () => () => { + abortRef.current?.abort(); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }, + [], + ); +}