[Refactor] Centralizes autocompletion logic within useCompletion (#4740)

This commit is contained in:
Sandy Tao 2025-07-24 21:41:35 -07:00 committed by GitHub
parent 273e74c09d
commit 1d7eb0d250
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 786 additions and 533 deletions

View File

@ -121,6 +121,15 @@ describe('InputPrompt', () => {
openInExternalEditor: vi.fn(), openInExternalEditor: vi.fn(),
newline: vi.fn(), newline: vi.fn(),
backspace: vi.fn(), backspace: vi.fn(),
preferredCol: null,
selectionAnchor: null,
insert: vi.fn(),
del: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
replaceRange: vi.fn(),
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
} as unknown as TextBuffer; } as unknown as TextBuffer;
mockShellHistory = { mockShellHistory = {
@ -137,12 +146,14 @@ describe('InputPrompt', () => {
isLoadingSuggestions: false, isLoadingSuggestions: false,
showSuggestions: false, showSuggestions: false,
visibleStartIndex: 0, visibleStartIndex: 0,
isPerfectMatch: false,
navigateUp: vi.fn(), navigateUp: vi.fn(),
navigateDown: vi.fn(), navigateDown: vi.fn(),
resetCompletionState: vi.fn(), resetCompletionState: vi.fn(),
setActiveSuggestionIndex: vi.fn(), setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(), setShowSuggestions: vi.fn(),
} as unknown as UseCompletionReturn; handleAutocomplete: vi.fn(),
};
mockedUseCompletion.mockReturnValue(mockCompletion); mockedUseCompletion.mockReturnValue(mockCompletion);
mockInputHistory = { mockInputHistory = {
@ -465,7 +476,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab stdin.write('\t'); // Press Tab
await wait(); await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory'); expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount(); unmount();
}); });
@ -488,7 +499,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab stdin.write('\t'); // Press Tab
await wait(); await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add'); expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
unmount(); unmount();
}); });
@ -513,7 +524,7 @@ describe('InputPrompt', () => {
await wait(); await wait();
// It should NOT become '/show'. It should correctly become '/memory show'. // It should NOT become '/show'. It should correctly become '/memory show'.
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show'); expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount(); unmount();
}); });
@ -533,7 +544,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab stdin.write('\t'); // Press Tab
await wait(); await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo'); expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount(); unmount();
}); });
@ -553,7 +564,7 @@ describe('InputPrompt', () => {
await wait(); await wait();
// The app should autocomplete the text, NOT submit. // The app should autocomplete the text, NOT submit.
expect(props.buffer.setText).toHaveBeenCalledWith('/memory'); expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled(); expect(props.onSubmit).not.toHaveBeenCalled();
unmount(); unmount();
@ -583,7 +594,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab for autocomplete stdin.write('\t'); // Press Tab for autocomplete
await wait(); await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/help'); expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount(); unmount();
}); });
@ -600,10 +611,29 @@ describe('InputPrompt', () => {
unmount(); unmount();
}); });
it('should submit directly on Enter when isPerfectMatch is true', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
isPerfectMatch: true,
});
props.buffer.setText('/clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
unmount();
});
it('should submit directly on Enter when a complete leaf command is typed', async () => { it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCompletion.mockReturnValue({ mockedUseCompletion.mockReturnValue({
...mockCompletion, ...mockCompletion,
showSuggestions: false, showSuggestions: false,
isPerfectMatch: false, // Added explicit isPerfectMatch false
}); });
props.buffer.setText('/clear'); props.buffer.setText('/clear');
@ -632,7 +662,7 @@ describe('InputPrompt', () => {
stdin.write('\r'); stdin.write('\r');
await wait(); await wait();
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled(); expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled(); expect(props.onSubmit).not.toHaveBeenCalled();
unmount(); unmount();
}); });
@ -697,11 +727,10 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />); const { unmount } = render(<InputPrompt {...props} />);
await wait(); await wait();
// Verify useCompletion was called with true (should show completion) // Verify useCompletion was called with correct signature
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@src/components', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -725,9 +754,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'/memory', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -751,9 +779,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@src/file.ts hello', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
false, // shouldShowCompletion should be false
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -777,9 +804,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'/memory add', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
false, // shouldShowCompletion should be false
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -803,9 +829,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'hello world', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
false, // shouldShowCompletion should be false
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -828,10 +853,10 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />); const { unmount } = render(<InputPrompt {...props} />);
await wait(); await wait();
// Verify useCompletion was called with the buffer
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'first line\n/memory', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
false, // shouldShowCompletion should be false (isSlashCommand returns false because text doesn't start with /)
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -855,9 +880,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'/memory', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true (isSlashCommand returns true AND cursor is after / without space)
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -882,9 +906,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@src/file👍.txt', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -909,9 +932,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@src/file👍.txt hello', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
false, // shouldShowCompletion should be false
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -936,9 +958,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@src/my\\ file.txt', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -963,9 +984,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@path/my\\ file.txt hello', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
false, // shouldShowCompletion should be false
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -992,9 +1012,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@docs/my\\ long\\ file\\ name.md', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -1019,9 +1038,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'/memory\\ test', mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),
@ -1048,9 +1066,8 @@ describe('InputPrompt', () => {
await wait(); await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith( expect(mockedUseCompletion).toHaveBeenCalledWith(
'@' + path.join('files', 'emoji\\ 👍\\ test.txt'), mockBuffer,
path.join('test', 'project', 'src'), path.join('test', 'project', 'src'),
true, // shouldShowCompletion should be true
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
expect.any(Object), expect.any(Object),

View File

@ -10,13 +10,12 @@ import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js'; import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer } from './shared/text-buffer.js'; import { TextBuffer } from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js'; import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk'; import chalk from 'chalk';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js'; import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js'; import { useCompletion } from '../hooks/useCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js'; import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { CommandContext, SlashCommand } from '../commands/types.js'; import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core'; import { Config } from '@google/gemini-cli-core';
import { import {
@ -59,53 +58,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => { }) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
// Check if cursor is after @ or / without unescaped spaces
const isCursorAfterCommandWithoutSpace = useCallback(() => {
const [row, col] = buffer.cursor;
const currentLine = buffer.lines[row] || '';
// Convert current line to code points for Unicode-aware processing
const codePoints = toCodePoints(currentLine);
// Search backwards from cursor position within the current line only
for (let i = col - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
// Check if this space is escaped by counting backslashes before it
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
// If there's an odd number of backslashes, the space is escaped
const isEscaped = backslashCount % 2 === 1;
if (!isEscaped) {
// Found unescaped space before @ or /, return false
return false;
}
// If escaped, continue searching backwards
} else if (char === '@' || char === '/') {
// Found @ or / without unescaped space in between
return true;
}
}
return false;
}, [buffer.cursor, buffer.lines]);
const shouldShowCompletion = useCallback(
() =>
(isAtCommand(buffer.text) || isSlashCommand(buffer.text)) &&
isCursorAfterCommandWithoutSpace(),
[buffer.text, isCursorAfterCommandWithoutSpace],
);
const completion = useCompletion( const completion = useCompletion(
buffer.text, buffer,
config.getTargetDir(), config.getTargetDir(),
shouldShowCompletion(),
slashCommands, slashCommands,
commandContext, commandContext,
config, config,
@ -159,78 +114,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setJustNavigatedHistory, setJustNavigatedHistory,
]); ]);
const completionSuggestions = completion.suggestions;
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= completionSuggestions.length) {
return;
}
const query = buffer.text;
const suggestion = completionSuggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
const hasTrailingSpace = query.endsWith(' ');
const parts = query
.trimStart()
.substring(1)
.split(/\s+/)
.filter(Boolean);
let isParentPath = false;
// If there's no trailing space, we need to check if the current query
// is already a complete path to a parent command.
if (!hasTrailingSpace) {
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const found: SlashCommand | undefined = currentLevel?.find(
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
);
if (found) {
if (i === parts.length - 1 && found.subCommands) {
isParentPath = true;
}
currentLevel = found.subCommands as
| readonly SlashCommand[]
| undefined;
} else {
// Path is invalid, so it can't be a parent path.
currentLevel = undefined;
break;
}
}
}
// Determine the base path of the command.
// - If there's a trailing space, the whole command is the base.
// - If it's a known parent path, the whole command is the base.
// - Otherwise, the base is everything EXCEPT the last partial part.
const basePath =
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
const newValue = `/${[...basePath, suggestion].join(' ')}`;
buffer.setText(newValue);
} else {
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) return;
const pathPart = query.substring(atIndex + 1);
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
let autoCompleteStartIndex = atIndex + 1;
if (lastSlashIndexInPath !== -1) {
autoCompleteStartIndex += lastSlashIndexInPath + 1;
}
buffer.replaceRangeByOffset(
autoCompleteStartIndex,
buffer.text.length,
suggestion,
);
}
resetCompletionState();
},
[resetCompletionState, buffer, completionSuggestions, slashCommands],
);
// Handle clipboard image pasting with Ctrl+V // Handle clipboard image pasting with Ctrl+V
const handleClipboardImage = useCallback(async () => { const handleClipboardImage = useCallback(async () => {
try { try {
@ -337,7 +220,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
? 0 // Default to the first if none is active ? 0 // Default to the first if none is active
: completion.activeSuggestionIndex; : completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) { if (targetIndex < completion.suggestions.length) {
handleAutocomplete(targetIndex); completion.handleAutocomplete(targetIndex);
} }
} }
return; return;
@ -459,7 +342,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setShellModeActive, setShellModeActive,
onClearScreen, onClearScreen,
inputHistory, inputHistory,
handleAutocomplete,
handleSubmitAndClear, handleSubmitAndClear,
shellHistory, shellHistory,
handleClipboardImage, handleClipboardImage,

View File

@ -16,6 +16,7 @@ import {
SlashCommand, SlashCommand,
} from '../commands/types.js'; } from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
import { useTextBuffer } from '../components/shared/text-buffer.js';
interface MockConfig { interface MockConfig {
getFileFilteringOptions: () => { getFileFilteringOptions: () => {
@ -26,6 +27,19 @@ interface MockConfig {
getFileService: () => FileDiscoveryService | null; getFileService: () => FileDiscoveryService | null;
} }
// Helper to create real TextBuffer objects within renderHook
const useTextBufferForTest = (text: string) => {
const cursorOffset = text.length;
return useTextBuffer({
initialText: text,
initialCursorOffset: cursorOffset,
viewport: { width: 80, height: 20 },
isValidPath: () => false,
onChange: () => {},
});
};
// Mock dependencies // Mock dependencies
vi.mock('fs/promises'); vi.mock('fs/promises');
vi.mock('@google/gemini-cli-core', async () => { vi.mock('@google/gemini-cli-core', async () => {
@ -183,16 +197,16 @@ describe('useCompletion git-aware filtering integration', () => {
}, },
); );
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@d');
'@d', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfig as Config, mockConfig as Config,
),
); );
});
// Wait for async operations to complete // Wait for async operations to complete
await act(async () => { await act(async () => {
@ -241,16 +255,16 @@ describe('useCompletion git-aware filtering integration', () => {
}, },
); );
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@');
'@', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfig as Config, mockConfig as Config,
),
); );
});
// Wait for async operations to complete // Wait for async operations to complete
await act(async () => { await act(async () => {
@ -323,16 +337,16 @@ describe('useCompletion git-aware filtering integration', () => {
}, },
); );
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@t');
'@t', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfig as Config, mockConfig as Config,
),
); );
});
// Wait for async operations to complete // Wait for async operations to complete
await act(async () => { await act(async () => {
@ -362,16 +376,16 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'dist', isDirectory: () => true }, { name: 'dist', isDirectory: () => true },
] as unknown as Awaited<ReturnType<typeof fs.readdir>>); ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
renderHook(() => renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@d');
'@d', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfigNoRecursive, mockConfigNoRecursive,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
@ -390,22 +404,21 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'README.md', isDirectory: () => false }, { name: 'README.md', isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof fs.readdir>>); ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@');
'@', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
undefined, undefined,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
}); });
// Without config, should include all files
expect(result.current.suggestions).toHaveLength(3); expect(result.current.suggestions).toHaveLength(3);
expect(result.current.suggestions).toEqual( expect(result.current.suggestions).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@ -424,16 +437,16 @@ describe('useCompletion git-aware filtering integration', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@');
'@', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfig as Config, mockConfig as Config,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
@ -470,16 +483,16 @@ describe('useCompletion git-aware filtering integration', () => {
}, },
); );
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@src/comp');
'@src/comp', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfig as Config, mockConfig as Config,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
@ -495,16 +508,16 @@ describe('useCompletion git-aware filtering integration', () => {
const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`]; const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`];
vi.mocked(glob).mockResolvedValue(globResults); vi.mocked(glob).mockResolvedValue(globResults);
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@s');
'@s', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfig as Config, mockConfig as Config,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
@ -530,16 +543,16 @@ describe('useCompletion git-aware filtering integration', () => {
]; ];
vi.mocked(glob).mockResolvedValue(globResults); vi.mocked(glob).mockResolvedValue(globResults);
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('@.');
'@.', return useCompletion(
textBuffer,
testCwd, testCwd,
true,
slashCommands, slashCommands,
mockCommandContext, mockCommandContext,
mockConfig as Config, mockConfig as Config,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
@ -559,15 +572,15 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it('should suggest top-level command names based on partial input', async () => { it('should suggest top-level command names based on partial input', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/mem');
'/mem', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toEqual([ expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' }, { label: 'memory', value: 'memory', description: 'Manage memory' },
@ -578,30 +591,30 @@ describe('useCompletion git-aware filtering integration', () => {
it.each([['/?'], ['/usage']])( it.each([['/?'], ['/usage']])(
'should not suggest commands when altNames is fully typed', 'should not suggest commands when altNames is fully typed',
async (altName) => { async (altName) => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest(altName);
altName, return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toHaveLength(0); expect(result.current.suggestions).toHaveLength(0);
}, },
); );
it('should suggest commands based on partial altNames matches', async () => { it('should suggest commands based on partial altNames matches', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
'/usag', // part of the word "usage" return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toEqual([ expect(result.current.suggestions).toEqual([
{ {
@ -613,15 +626,15 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it('should suggest sub-command names for a parent command', async () => { it('should suggest sub-command names for a parent command', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/memory a');
'/memory a', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toEqual([ expect(result.current.suggestions).toEqual([
{ label: 'add', value: 'add', description: 'Add to memory' }, { label: 'add', value: 'add', description: 'Add to memory' },
@ -629,15 +642,15 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/memory ');
'/memory ', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual( expect(result.current.suggestions).toEqual(
@ -652,7 +665,8 @@ describe('useCompletion git-aware filtering integration', () => {
const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel']; const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel'];
const mockCompletionFn = vi const mockCompletionFn = vi
.fn() .fn()
.mockImplementation(async (context: CommandContext, partialArg: string) => .mockImplementation(
async (_context: CommandContext, partialArg: string) =>
availableTags.filter((tag) => tag.startsWith(partialArg)), availableTags.filter((tag) => tag.startsWith(partialArg)),
); );
@ -678,15 +692,15 @@ describe('useCompletion git-aware filtering integration', () => {
resumeCmd.completion = mockCompletionFn; resumeCmd.completion = mockCompletionFn;
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/chat resume my-ch');
'/chat resume my-ch', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockCommandsWithFiltering, mockCommandsWithFiltering,
mockCommandContext, mockCommandContext,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
@ -701,45 +715,45 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/clear ');
'/clear ', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toHaveLength(0); expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false); expect(result.current.showSuggestions).toBe(false);
}); });
it('should not provide suggestions for an unknown command', async () => { it('should not provide suggestions for an unknown command', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/unknown-command');
'/unknown-command', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toHaveLength(0); expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false); expect(result.current.showSuggestions).toBe(false);
}); });
it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => { it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/memory'); // Note: no trailing space
'/memory', // Note: no trailing space return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
// Assert that suggestions for sub-commands are shown immediately // Assert that suggestions for sub-commands are shown immediately
expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions).toHaveLength(2);
@ -753,15 +767,15 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/clear'); // No trailing space
'/clear', // No trailing space return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toHaveLength(0); expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false); expect(result.current.showSuggestions).toBe(false);
@ -787,15 +801,15 @@ describe('useCompletion git-aware filtering integration', () => {
} }
resumeCommand.completion = mockCompletionFn; resumeCommand.completion = mockCompletionFn;
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/chat resume '); // Trailing space, no partial argument
'/chat resume ', // Trailing space, no partial argument return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
isolatedMockCommands, isolatedMockCommands,
mockCommandContext, mockCommandContext,
),
); );
});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
@ -807,15 +821,15 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it('should suggest all top-level commands for the root slash', async () => { it('should suggest all top-level commands for the root slash', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/');
'/', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions.length).toBe(mockSlashCommands.length); expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual( expect(result.current.suggestions.map((s) => s.label)).toEqual(
@ -824,15 +838,15 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it('should provide no suggestions for an invalid sub-command', async () => { it('should provide no suggestions for an invalid sub-command', async () => {
const { result } = renderHook(() => const { result } = renderHook(() => {
useCompletion( const textBuffer = useTextBufferForTest('/memory dothisnow');
'/memory dothisnow', return useCompletion(
textBuffer,
'/test/cwd', '/test/cwd',
true,
mockSlashCommands, mockSlashCommands,
mockCommandContext, mockCommandContext,
),
); );
});
expect(result.current.suggestions).toHaveLength(0); expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false); expect(result.current.showSuggestions).toBe(false);

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { glob } from 'glob'; import { glob } from 'glob';
@ -22,6 +22,9 @@ import {
Suggestion, Suggestion,
} from '../components/SuggestionsDisplay.js'; } from '../components/SuggestionsDisplay.js';
import { CommandContext, SlashCommand } from '../commands/types.js'; import { CommandContext, SlashCommand } from '../commands/types.js';
import { TextBuffer } from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
export interface UseCompletionReturn { export interface UseCompletionReturn {
suggestions: Suggestion[]; suggestions: Suggestion[];
@ -35,12 +38,12 @@ export interface UseCompletionReturn {
resetCompletionState: () => void; resetCompletionState: () => void;
navigateUp: () => void; navigateUp: () => void;
navigateDown: () => void; navigateDown: () => void;
handleAutocomplete: (indexToUse: number) => void;
} }
export function useCompletion( export function useCompletion(
query: string, buffer: TextBuffer,
cwd: string, cwd: string,
isActive: boolean,
slashCommands: readonly SlashCommand[], slashCommands: readonly SlashCommand[],
commandContext: CommandContext, commandContext: CommandContext,
config?: Config, config?: Config,
@ -122,13 +125,45 @@ export function useCompletion(
}); });
}, [suggestions.length]); }, [suggestions.length]);
// Check if cursor is after @ or / without unescaped spaces
const isActive = useMemo(() => {
if (isSlashCommand(buffer.text.trim())) {
return true;
}
// For other completions like '@', we search backwards from the cursor.
const [row, col] = buffer.cursor;
const currentLine = buffer.lines[row] || '';
const codePoints = toCodePoints(currentLine);
for (let i = col - 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 false; // Inactive on unescaped space.
}
} else if (char === '@') {
// Active if we find an '@' before any unescaped space.
return true;
}
}
return false;
}, [buffer.text, buffer.cursor, buffer.lines]);
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
resetCompletionState(); resetCompletionState();
return; return;
} }
const trimmedQuery = query.trimStart(); const trimmedQuery = buffer.text.trimStart();
if (trimmedQuery.startsWith('/')) { if (trimmedQuery.startsWith('/')) {
// Always reset perfect match at the beginning of processing. // Always reset perfect match at the beginning of processing.
@ -275,13 +310,13 @@ export function useCompletion(
} }
// Handle At Command Completion // Handle At Command Completion
const atIndex = query.lastIndexOf('@'); const atIndex = buffer.text.lastIndexOf('@');
if (atIndex === -1) { if (atIndex === -1) {
resetCompletionState(); resetCompletionState();
return; return;
} }
const partialPath = query.substring(atIndex + 1); const partialPath = buffer.text.substring(atIndex + 1);
const lastSlashIndex = partialPath.lastIndexOf('/'); const lastSlashIndex = partialPath.lastIndexOf('/');
const baseDirRelative = const baseDirRelative =
lastSlashIndex === -1 lastSlashIndex === -1
@ -545,7 +580,7 @@ export function useCompletion(
clearTimeout(debounceTimeout); clearTimeout(debounceTimeout);
}; };
}, [ }, [
query, buffer.text,
cwd, cwd,
isActive, isActive,
resetCompletionState, resetCompletionState,
@ -554,6 +589,77 @@ export function useCompletion(
config, config,
]); ]);
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= suggestions.length) {
return;
}
const query = buffer.text;
const suggestion = suggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
const hasTrailingSpace = query.endsWith(' ');
const parts = query
.trimStart()
.substring(1)
.split(/\s+/)
.filter(Boolean);
let isParentPath = false;
// If there's no trailing space, we need to check if the current query
// is already a complete path to a parent command.
if (!hasTrailingSpace) {
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const found: SlashCommand | undefined = currentLevel?.find(
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
);
if (found) {
if (i === parts.length - 1 && found.subCommands) {
isParentPath = true;
}
currentLevel = found.subCommands as
| readonly SlashCommand[]
| undefined;
} else {
// Path is invalid, so it can't be a parent path.
currentLevel = undefined;
break;
}
}
}
// Determine the base path of the command.
// - If there's a trailing space, the whole command is the base.
// - If it's a known parent path, the whole command is the base.
// - Otherwise, the base is everything EXCEPT the last partial part.
const basePath =
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
const newValue = `/${[...basePath, suggestion].join(' ')}`;
buffer.setText(newValue);
} else {
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) return;
const pathPart = query.substring(atIndex + 1);
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
let autoCompleteStartIndex = atIndex + 1;
if (lastSlashIndexInPath !== -1) {
autoCompleteStartIndex += lastSlashIndexInPath + 1;
}
buffer.replaceRangeByOffset(
autoCompleteStartIndex,
buffer.text.length,
suggestion,
);
}
resetCompletionState();
},
[resetCompletionState, buffer, suggestions, slashCommands],
);
return { return {
suggestions, suggestions,
activeSuggestionIndex, activeSuggestionIndex,
@ -566,5 +672,6 @@ export function useCompletion(
resetCompletionState, resetCompletionState,
navigateUp, navigateUp,
navigateDown, navigateDown,
handleAutocomplete,
}; };
} }