diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index bad29f10..a1894002 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -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();
+ 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();
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();
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),
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 6192fb8c..9f15b56d 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -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 = ({
}) => {
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 = ({
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 = ({
? 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 = ({
setShellModeActive,
onClearScreen,
inputHistory,
- handleAutocomplete,
handleSubmitAndClear,
shellHistory,
handleClipboardImage,
diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
index 840d2814..d4c66a15 100644
--- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
@@ -16,6 +16,7 @@ import {
SlashCommand,
} from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
+import { useTextBuffer } from '../components/shared/text-buffer.js';
interface MockConfig {
getFileFilteringOptions: () => {
@@ -26,6 +27,19 @@ interface MockConfig {
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
vi.mock('fs/promises');
vi.mock('@google/gemini-cli-core', async () => {
@@ -183,16 +197,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@d',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@d');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
// Wait for async operations to complete
await act(async () => {
@@ -241,16 +255,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
// Wait for async operations to complete
await act(async () => {
@@ -323,16 +337,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@t',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@t');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
// Wait for async operations to complete
await act(async () => {
@@ -362,16 +376,16 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'dist', isDirectory: () => true },
] as unknown as Awaited>);
- renderHook(() =>
- useCompletion(
- '@d',
+ renderHook(() => {
+ const textBuffer = useTextBufferForTest('@d');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfigNoRecursive,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -390,22 +404,21 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'README.md', isDirectory: () => false },
] as unknown as Awaited>);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
undefined,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
- // Without config, should include all files
expect(result.current.suggestions).toHaveLength(3);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
@@ -424,16 +437,16 @@ describe('useCompletion git-aware filtering integration', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -470,16 +483,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@src/comp',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@src/comp');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
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`];
vi.mocked(glob).mockResolvedValue(globResults);
- const { result } = renderHook(() =>
- useCompletion(
- '@s',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@s');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -530,16 +543,16 @@ describe('useCompletion git-aware filtering integration', () => {
];
vi.mocked(glob).mockResolvedValue(globResults);
- const { result } = renderHook(() =>
- useCompletion(
- '@.',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@.');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
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 () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/mem',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/mem');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' },
@@ -578,30 +591,30 @@ describe('useCompletion git-aware filtering integration', () => {
it.each([['/?'], ['/usage']])(
'should not suggest commands when altNames is fully typed',
async (altName) => {
- const { result } = renderHook(() =>
- useCompletion(
- altName,
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest(altName);
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
},
);
it('should suggest commands based on partial altNames matches', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/usag', // part of the word "usage"
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
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 () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory a',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory a');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toEqual([
{ 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 () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory ');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
@@ -652,8 +665,9 @@ describe('useCompletion git-aware filtering integration', () => {
const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel'];
const mockCompletionFn = vi
.fn()
- .mockImplementation(async (context: CommandContext, partialArg: string) =>
- availableTags.filter((tag) => tag.startsWith(partialArg)),
+ .mockImplementation(
+ async (_context: CommandContext, partialArg: string) =>
+ availableTags.filter((tag) => tag.startsWith(partialArg)),
);
const mockCommandsWithFiltering = JSON.parse(
@@ -678,15 +692,15 @@ describe('useCompletion git-aware filtering integration', () => {
resumeCmd.completion = mockCompletionFn;
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume my-ch',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume my-ch');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockCommandsWithFiltering,
mockCommandContext,
- ),
- );
+ );
+ });
await act(async () => {
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 () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/clear ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/clear ');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should not provide suggestions for an unknown command', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/unknown-command',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/unknown-command');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory', // Note: no trailing space
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory'); // Note: no trailing space
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
// Assert that suggestions for sub-commands are shown immediately
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 () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/clear', // No trailing space
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/clear'); // No trailing space
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
@@ -787,15 +801,15 @@ describe('useCompletion git-aware filtering integration', () => {
}
resumeCommand.completion = mockCompletionFn;
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume ', // Trailing space, no partial argument
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume '); // Trailing space, no partial argument
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
isolatedMockCommands,
mockCommandContext,
- ),
- );
+ );
+ });
await act(async () => {
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 () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
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 () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory dothisnow',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory dothisnow');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts
index 19671de4..96e8f156 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.test.ts
@@ -18,6 +18,20 @@ import {
SlashCommand,
} from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
+import { useTextBuffer } from '../components/shared/text-buffer.js';
+
+// 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
vi.mock('fs/promises');
@@ -140,16 +154,16 @@ describe('useCompletion', () => {
describe('Hook initialization and state', () => {
it('should initialize with default state', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- false,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toEqual([]);
expect(result.current.activeSuggestionIndex).toBe(-1);
@@ -158,21 +172,23 @@ describe('useCompletion', () => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
- it('should reset state when isActive becomes false', () => {
+ it('should reset state when query becomes inactive', () => {
const { result, rerender } = renderHook(
- ({ isActive }) =>
- useCompletion(
- '/help',
+ ({ text }) => {
+ const textBuffer = useTextBufferForTest(text);
+ return useCompletion(
+ textBuffer,
testCwd,
- isActive,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- { initialProps: { isActive: true } },
+ );
+ },
+ { initialProps: { text: '/help' } },
);
- rerender({ isActive: false });
+ // Inactive because of the leading space
+ rerender({ text: ' /help' });
expect(result.current.suggestions).toEqual([]);
expect(result.current.activeSuggestionIndex).toBe(-1);
@@ -182,16 +198,16 @@ describe('useCompletion', () => {
});
it('should provide required functions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(typeof result.current.setActiveSuggestionIndex).toBe('function');
expect(typeof result.current.setShowSuggestions).toBe('function');
@@ -203,16 +219,16 @@ describe('useCompletion', () => {
describe('resetCompletionState', () => {
it('should reset all state to default values', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/help',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/help');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
act(() => {
result.current.setActiveSuggestionIndex(5);
@@ -233,16 +249,16 @@ describe('useCompletion', () => {
describe('Navigation functions', () => {
it('should handle navigateUp with no suggestions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
act(() => {
result.current.navigateUp();
@@ -252,16 +268,16 @@ describe('useCompletion', () => {
});
it('should handle navigateDown with no suggestions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
act(() => {
result.current.navigateDown();
@@ -271,16 +287,16 @@ describe('useCompletion', () => {
});
it('should navigate up through suggestions with wrap-around', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/h',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/h');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(1);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -293,16 +309,16 @@ describe('useCompletion', () => {
});
it('should navigate down through suggestions with wrap-around', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/h',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/h');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(1);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -315,16 +331,16 @@ describe('useCompletion', () => {
});
it('should handle navigation with multiple suggestions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(5);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -363,16 +379,16 @@ describe('useCompletion', () => {
action: vi.fn(),
}));
- const { result } = renderHook(() =>
- useCompletion(
- '/command',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/command');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
largeMockCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(15);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -389,16 +405,16 @@ describe('useCompletion', () => {
describe('Slash command completion', () => {
it('should show all commands for root slash', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(5);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -409,16 +425,16 @@ describe('useCompletion', () => {
});
it('should filter commands by prefix', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/h',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/h');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('help');
@@ -428,64 +444,64 @@ describe('useCompletion', () => {
it.each([['/?'], ['/usage']])(
'should not suggest commands when altNames is fully typed',
(altName) => {
- const { result } = renderHook(() =>
- useCompletion(
- altName,
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest(altName);
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
},
);
it('should suggest commands based on partial altNames matches', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/usag', // part of the word "usage"
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('stats');
});
it('should not show suggestions for exact leaf command match', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/clear',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/clear');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should show sub-commands for parent commands', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -494,16 +510,16 @@ describe('useCompletion', () => {
});
it('should show all sub-commands after parent command with space', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory ');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -512,32 +528,32 @@ describe('useCompletion', () => {
});
it('should filter sub-commands by prefix', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory a',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory a');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('add');
});
it('should handle unknown command gracefully', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/unknown',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/unknown');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
@@ -558,16 +574,16 @@ describe('useCompletion', () => {
resumeCommand.completion = completionFn;
}
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume ');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithCompletion,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -594,16 +610,16 @@ describe('useCompletion', () => {
resumeCommand.completion = completionFn;
}
- renderHook(() =>
- useCompletion(
- '/chat resume ar',
+ renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume ar');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithCompletion,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -625,16 +641,16 @@ describe('useCompletion', () => {
resumeCommand.completion = completionFn;
}
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume ');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithCompletion,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -673,32 +689,32 @@ describe('useCompletion', () => {
});
it('should suggest a namespaced command based on a partial match', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/git:co',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/git:co');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithNamespaces,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('git:commit');
});
it('should suggest all commands within a namespace when the namespace prefix is typed', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/git:',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/git:');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithNamespaces,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -711,16 +727,16 @@ describe('useCompletion', () => {
});
it('should not provide suggestions if the namespaced command is a perfect leaf match', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/git:commit',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/git:commit');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithNamespaces,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.showSuggestions).toBe(false);
expect(result.current.suggestions).toHaveLength(0);
@@ -738,16 +754,16 @@ describe('useCompletion', () => {
});
it('should show file completions for @ prefix', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -766,16 +782,16 @@ describe('useCompletion', () => {
`${testCwd}/file2.js`,
]);
- const { result } = renderHook(() =>
- useCompletion(
- '@file',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@file');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -791,16 +807,16 @@ describe('useCompletion', () => {
// Mock for recursive search since enableRecursiveFileSearch is true
vi.mocked(glob).mockResolvedValue([`${testCwd}/.hidden`]);
- const { result } = renderHook(() =>
- useCompletion(
- '@.',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@.');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -815,16 +831,16 @@ describe('useCompletion', () => {
(enoentError as Error & { code: string }).code = 'ENOENT';
vi.mocked(fs.readdir).mockRejectedValue(enoentError);
- const { result } = renderHook(() =>
- useCompletion(
- '@nonexistent',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@nonexistent');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -840,16 +856,16 @@ describe('useCompletion', () => {
.mockImplementation(() => {});
vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied'));
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -870,21 +886,22 @@ describe('useCompletion', () => {
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
const { rerender } = renderHook(
- ({ query }) =>
- useCompletion(
- query,
+ ({ text }) => {
+ const textBuffer = useTextBufferForTest(text);
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- { initialProps: { query: '@f' } },
+ );
+ },
+ { initialProps: { text: '@f' } },
);
- rerender({ query: '@fi' });
- rerender({ query: '@fil' });
- rerender({ query: '@file' });
+ rerender({ text: '@fi' });
+ rerender({ text: '@fil' });
+ rerender({ text: '@file' });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -896,48 +913,48 @@ describe('useCompletion', () => {
describe('Query handling edge cases', () => {
it('should handle empty query', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should handle query without slash or @', () => {
- const { result } = renderHook(() =>
- useCompletion(
- 'regular text',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('regular text');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should handle query with whitespace', () => {
- const { result } = renderHook(() =>
- useCompletion(
- ' /hel',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest(' /hel');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('help');
@@ -947,16 +964,16 @@ describe('useCompletion', () => {
// Mock for recursive search since enableRecursiveFileSearch is true
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
- const { result } = renderHook(() =>
- useCompletion(
- 'some text @',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('some text @');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
// Wait for completion
await act(async () => {
@@ -983,16 +1000,16 @@ describe('useCompletion', () => {
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
- const { result } = renderHook(() =>
- useCompletion(
- '@comp',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@comp');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -1015,22 +1032,238 @@ describe('useCompletion', () => {
});
});
+ describe('handleAutocomplete', () => {
+ it('should complete a partial command', () => {
+ // Create a mock buffer that we can spy on directly
+ const mockBuffer = {
+ text: '/mem',
+ lines: ['/mem'],
+ cursor: [0, 4],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['/mem'],
+ viewportVisualLines: ['/mem'],
+ visualCursor: [0, 4],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'memory',
+ ]);
+
+ act(() => {
+ result.current.handleAutocomplete(0);
+ });
+
+ expect(mockBuffer.setText).toHaveBeenCalledWith('/memory');
+ });
+
+ it('should append a sub-command when the parent is complete', () => {
+ const mockBuffer = {
+ text: '/memory ',
+ lines: ['/memory '],
+ cursor: [0, 8],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['/memory '],
+ viewportVisualLines: ['/memory '],
+ visualCursor: [0, 8],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ // Suggestions are populated by useEffect
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'show',
+ 'add',
+ ]);
+
+ act(() => {
+ result.current.handleAutocomplete(1); // index 1 is 'add'
+ });
+
+ expect(mockBuffer.setText).toHaveBeenCalledWith('/memory add');
+ });
+
+ it('should complete a command with an alternative name', () => {
+ const mockBuffer = {
+ text: '/?',
+ lines: ['/?'],
+ cursor: [0, 2],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['/?'],
+ viewportVisualLines: ['/?'],
+ visualCursor: [0, 2],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ result.current.suggestions.push({
+ label: 'help',
+ value: 'help',
+ description: 'Show help',
+ });
+
+ act(() => {
+ result.current.handleAutocomplete(0);
+ });
+
+ expect(mockBuffer.setText).toHaveBeenCalledWith('/help');
+ });
+
+ it('should complete a file path', async () => {
+ const mockBuffer = {
+ text: '@src/fi',
+ lines: ['@src/fi'],
+ cursor: [0, 7],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['@src/fi'],
+ viewportVisualLines: ['@src/fi'],
+ visualCursor: [0, 7],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ result.current.suggestions.push({
+ label: 'file1.txt',
+ value: 'file1.txt',
+ });
+
+ act(() => {
+ result.current.handleAutocomplete(0);
+ });
+
+ expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
+ 5, // after '@src/'
+ mockBuffer.text.length,
+ 'file1.txt',
+ );
+ });
+ });
+
describe('Config and FileDiscoveryService integration', () => {
it('should work without config', async () => {
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'file1.txt', isDirectory: () => false },
] as unknown as Awaited>);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
undefined,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -1050,16 +1283,16 @@ describe('useCompletion', () => {
(path: string) => path.includes('.log'),
);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index aacc111d..f4ebfac3 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -4,7 +4,7 @@
* 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 path from 'path';
import { glob } from 'glob';
@@ -22,6 +22,9 @@ import {
Suggestion,
} from '../components/SuggestionsDisplay.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 {
suggestions: Suggestion[];
@@ -35,12 +38,12 @@ export interface UseCompletionReturn {
resetCompletionState: () => void;
navigateUp: () => void;
navigateDown: () => void;
+ handleAutocomplete: (indexToUse: number) => void;
}
export function useCompletion(
- query: string,
+ buffer: TextBuffer,
cwd: string,
- isActive: boolean,
slashCommands: readonly SlashCommand[],
commandContext: CommandContext,
config?: Config,
@@ -122,13 +125,45 @@ export function useCompletion(
});
}, [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(() => {
if (!isActive) {
resetCompletionState();
return;
}
- const trimmedQuery = query.trimStart();
+ const trimmedQuery = buffer.text.trimStart();
if (trimmedQuery.startsWith('/')) {
// Always reset perfect match at the beginning of processing.
@@ -275,13 +310,13 @@ export function useCompletion(
}
// Handle At Command Completion
- const atIndex = query.lastIndexOf('@');
+ const atIndex = buffer.text.lastIndexOf('@');
if (atIndex === -1) {
resetCompletionState();
return;
}
- const partialPath = query.substring(atIndex + 1);
+ const partialPath = buffer.text.substring(atIndex + 1);
const lastSlashIndex = partialPath.lastIndexOf('/');
const baseDirRelative =
lastSlashIndex === -1
@@ -545,7 +580,7 @@ export function useCompletion(
clearTimeout(debounceTimeout);
};
}, [
- query,
+ buffer.text,
cwd,
isActive,
resetCompletionState,
@@ -554,6 +589,77 @@ export function useCompletion(
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 {
suggestions,
activeSuggestionIndex,
@@ -566,5 +672,6 @@ export function useCompletion(
resetCompletionState,
navigateUp,
navigateDown,
+ handleAutocomplete,
};
}