mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
[Refactor] Centralizes autocompletion logic within useCompletion (#4740)
This commit is contained in:
@@ -121,6 +121,15 @@ describe('InputPrompt', () => {
|
||||
openInExternalEditor: vi.fn(),
|
||||
newline: 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;
|
||||
|
||||
mockShellHistory = {
|
||||
@@ -137,12 +146,14 @@ describe('InputPrompt', () => {
|
||||
isLoadingSuggestions: false,
|
||||
showSuggestions: false,
|
||||
visibleStartIndex: 0,
|
||||
isPerfectMatch: false,
|
||||
navigateUp: vi.fn(),
|
||||
navigateDown: vi.fn(),
|
||||
resetCompletionState: vi.fn(),
|
||||
setActiveSuggestionIndex: vi.fn(),
|
||||
setShowSuggestions: vi.fn(),
|
||||
} as unknown as UseCompletionReturn;
|
||||
handleAutocomplete: vi.fn(),
|
||||
};
|
||||
mockedUseCompletion.mockReturnValue(mockCompletion);
|
||||
|
||||
mockInputHistory = {
|
||||
@@ -465,7 +476,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -488,7 +499,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -513,7 +524,7 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// It should NOT become '/show'. It should correctly become '/memory show'.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -533,7 +544,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -553,7 +564,7 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// The app should autocomplete the text, NOT submit.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
@@ -583,7 +594,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab for autocomplete
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/help');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -600,10 +611,29 @@ describe('InputPrompt', () => {
|
||||
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 () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
isPerfectMatch: false, // Added explicit isPerfectMatch false
|
||||
});
|
||||
props.buffer.setText('/clear');
|
||||
|
||||
@@ -632,7 +662,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -697,11 +727,10 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with true (should show completion)
|
||||
// Verify useCompletion was called with correct signature
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@src/components',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -725,9 +754,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'/memory',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -751,9 +779,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@src/file.ts hello',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
false, // shouldShowCompletion should be false
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -777,9 +804,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'/memory add',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
false, // shouldShowCompletion should be false
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -803,9 +829,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'hello world',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
false, // shouldShowCompletion should be false
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -828,10 +853,10 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with the buffer
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'first line\n/memory',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
false, // shouldShowCompletion should be false (isSlashCommand returns false because text doesn't start with /)
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -855,9 +880,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'/memory',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true (isSlashCommand returns true AND cursor is after / without space)
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -882,9 +906,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@src/file👍.txt',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -909,9 +932,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@src/file👍.txt hello',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
false, // shouldShowCompletion should be false
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -936,9 +958,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@src/my\\ file.txt',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -963,9 +984,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@path/my\\ file.txt hello',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
false, // shouldShowCompletion should be false
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -992,9 +1012,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@docs/my\\ long\\ file\\ name.md',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -1019,9 +1038,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'/memory\\ test',
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
@@ -1048,9 +1066,8 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
'@' + path.join('files', 'emoji\\ 👍\\ test.txt'),
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
true, // shouldShowCompletion should be true
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
|
||||
@@ -10,13 +10,12 @@ import { Colors } from '../colors.js';
|
||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.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 stringWidth from 'string-width';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
@@ -59,53 +58,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}) => {
|
||||
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(
|
||||
buffer.text,
|
||||
buffer,
|
||||
config.getTargetDir(),
|
||||
shouldShowCompletion(),
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
@@ -159,78 +114,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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
|
||||
const handleClipboardImage = useCallback(async () => {
|
||||
try {
|
||||
@@ -337,7 +220,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
? 0 // Default to the first if none is active
|
||||
: completion.activeSuggestionIndex;
|
||||
if (targetIndex < completion.suggestions.length) {
|
||||
handleAutocomplete(targetIndex);
|
||||
completion.handleAutocomplete(targetIndex);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -459,7 +342,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
inputHistory,
|
||||
handleAutocomplete,
|
||||
handleSubmitAndClear,
|
||||
shellHistory,
|
||||
handleClipboardImage,
|
||||
|
||||
Reference in New Issue
Block a user