feat(cli): Improve @ autocompletion for mid-sentence edits (#5321)
This commit is contained in:
parent
37a3f1e6b6
commit
32809a7be7
|
@ -14,7 +14,7 @@ import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
||||||
import { useTextBuffer, TextBuffer } from '../components/shared/text-buffer.js';
|
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||||
|
|
||||||
describe('useCompletion', () => {
|
describe('useCompletion', () => {
|
||||||
let testRootDir: string;
|
let testRootDir: string;
|
||||||
|
@ -38,10 +38,10 @@ describe('useCompletion', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create real TextBuffer objects within renderHook
|
// Helper to create real TextBuffer objects within renderHook
|
||||||
function useTextBufferForTest(text: string) {
|
function useTextBufferForTest(text: string, cursorOffset?: number) {
|
||||||
return useTextBuffer({
|
return useTextBuffer({
|
||||||
initialText: text,
|
initialText: text,
|
||||||
initialCursorOffset: text.length,
|
initialCursorOffset: cursorOffset ?? text.length,
|
||||||
viewport: { width: 80, height: 20 },
|
viewport: { width: 80, height: 20 },
|
||||||
isValidPath: () => false,
|
isValidPath: () => false,
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
|
@ -1113,22 +1113,19 @@ describe('useCompletion', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] as unknown as SlashCommand[];
|
] as unknown as SlashCommand[];
|
||||||
// Create a mock buffer that we can spy on directly
|
|
||||||
const mockBuffer = {
|
|
||||||
text: '/mem',
|
|
||||||
setText: vi.fn(),
|
|
||||||
} as unknown as TextBuffer;
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/mem');
|
||||||
mockBuffer,
|
const completion = useCompletion(
|
||||||
|
textBuffer,
|
||||||
testDirs,
|
testDirs,
|
||||||
testRootDir,
|
testRootDir,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig,
|
mockConfig,
|
||||||
),
|
);
|
||||||
);
|
return { ...completion, textBuffer };
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||||
'memory',
|
'memory',
|
||||||
|
@ -1138,14 +1135,10 @@ describe('useCompletion', () => {
|
||||||
result.current.handleAutocomplete(0);
|
result.current.handleAutocomplete(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockBuffer.setText).toHaveBeenCalledWith('/memory ');
|
expect(result.current.textBuffer.text).toBe('/memory ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should append a sub-command when the parent is complete', () => {
|
it('should append a sub-command when the parent is complete', () => {
|
||||||
const mockBuffer = {
|
|
||||||
text: '/memory',
|
|
||||||
setText: vi.fn(),
|
|
||||||
} as unknown as TextBuffer;
|
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
{
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
|
@ -1163,16 +1156,18 @@ describe('useCompletion', () => {
|
||||||
},
|
},
|
||||||
] as unknown as SlashCommand[];
|
] as unknown as SlashCommand[];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/memory');
|
||||||
mockBuffer,
|
const completion = useCompletion(
|
||||||
|
textBuffer,
|
||||||
testDirs,
|
testDirs,
|
||||||
testRootDir,
|
testRootDir,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig,
|
mockConfig,
|
||||||
),
|
);
|
||||||
);
|
return { ...completion, textBuffer };
|
||||||
|
});
|
||||||
|
|
||||||
// Suggestions are populated by useEffect
|
// Suggestions are populated by useEffect
|
||||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||||
|
@ -1184,14 +1179,10 @@ describe('useCompletion', () => {
|
||||||
result.current.handleAutocomplete(1); // index 1 is 'add'
|
result.current.handleAutocomplete(1); // index 1 is 'add'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockBuffer.setText).toHaveBeenCalledWith('/memory add ');
|
expect(result.current.textBuffer.text).toBe('/memory add ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should complete a command with an alternative name', () => {
|
it('should complete a command with an alternative name', () => {
|
||||||
const mockBuffer = {
|
|
||||||
text: '/?',
|
|
||||||
setText: vi.fn(),
|
|
||||||
} as unknown as TextBuffer;
|
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
{
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
|
@ -1209,16 +1200,18 @@ describe('useCompletion', () => {
|
||||||
},
|
},
|
||||||
] as unknown as SlashCommand[];
|
] as unknown as SlashCommand[];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/?');
|
||||||
mockBuffer,
|
const completion = useCompletion(
|
||||||
|
textBuffer,
|
||||||
testDirs,
|
testDirs,
|
||||||
testRootDir,
|
testRootDir,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig,
|
mockConfig,
|
||||||
),
|
);
|
||||||
);
|
return { ...completion, textBuffer };
|
||||||
|
});
|
||||||
|
|
||||||
result.current.suggestions.push({
|
result.current.suggestions.push({
|
||||||
label: 'help',
|
label: 'help',
|
||||||
|
@ -1230,44 +1223,22 @@ describe('useCompletion', () => {
|
||||||
result.current.handleAutocomplete(0);
|
result.current.handleAutocomplete(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockBuffer.setText).toHaveBeenCalledWith('/help ');
|
expect(result.current.textBuffer.text).toBe('/help ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should complete a file path', async () => {
|
it('should complete a file path', () => {
|
||||||
const mockBuffer = {
|
const { result } = renderHook(() => {
|
||||||
text: '@src/fi',
|
const textBuffer = useTextBufferForTest('@src/fi');
|
||||||
lines: ['@src/fi'],
|
const completion = useCompletion(
|
||||||
cursor: [0, 7],
|
textBuffer,
|
||||||
setText: vi.fn(),
|
|
||||||
replaceRangeByOffset: vi.fn(),
|
|
||||||
} as unknown as TextBuffer;
|
|
||||||
const slashCommands = [
|
|
||||||
{
|
|
||||||
name: 'memory',
|
|
||||||
description: 'Manage memory',
|
|
||||||
subCommands: [
|
|
||||||
{
|
|
||||||
name: 'show',
|
|
||||||
description: 'Show memory',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'add',
|
|
||||||
description: 'Add to memory',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as unknown as SlashCommand[];
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useCompletion(
|
|
||||||
mockBuffer,
|
|
||||||
testDirs,
|
testDirs,
|
||||||
testRootDir,
|
testRootDir,
|
||||||
slashCommands,
|
[],
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig,
|
mockConfig,
|
||||||
),
|
);
|
||||||
);
|
return { ...completion, textBuffer };
|
||||||
|
});
|
||||||
|
|
||||||
result.current.suggestions.push({
|
result.current.suggestions.push({
|
||||||
label: 'file1.txt',
|
label: 'file1.txt',
|
||||||
|
@ -1278,11 +1249,64 @@ describe('useCompletion', () => {
|
||||||
result.current.handleAutocomplete(0);
|
result.current.handleAutocomplete(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
|
expect(result.current.textBuffer.text).toBe('@src/file1.txt');
|
||||||
5, // after '@src/'
|
});
|
||||||
mockBuffer.text.length,
|
|
||||||
'file1.txt',
|
it('should complete a file path when cursor is not at the end of the line', () => {
|
||||||
);
|
const text = '@src/fi le.txt';
|
||||||
|
const cursorOffset = 7; // after "i"
|
||||||
|
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const textBuffer = useTextBufferForTest(text, cursorOffset);
|
||||||
|
const completion = useCompletion(
|
||||||
|
textBuffer,
|
||||||
|
testDirs,
|
||||||
|
testRootDir,
|
||||||
|
[],
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
return { ...completion, textBuffer };
|
||||||
|
});
|
||||||
|
|
||||||
|
result.current.suggestions.push({
|
||||||
|
label: 'file1.txt',
|
||||||
|
value: 'file1.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleAutocomplete(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete the correct file path with multiple @-commands', () => {
|
||||||
|
const text = '@file1.txt @src/fi';
|
||||||
|
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const textBuffer = useTextBufferForTest(text);
|
||||||
|
const completion = useCompletion(
|
||||||
|
textBuffer,
|
||||||
|
testDirs,
|
||||||
|
testRootDir,
|
||||||
|
[],
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
return { ...completion, textBuffer };
|
||||||
|
});
|
||||||
|
|
||||||
|
result.current.suggestions.push({
|
||||||
|
label: 'file2.txt',
|
||||||
|
value: 'file2.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleAutocomplete(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } 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,7 +22,10 @@ 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 {
|
||||||
|
logicalPosToOffset,
|
||||||
|
TextBuffer,
|
||||||
|
} from '../components/shared/text-buffer.js';
|
||||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||||
import { toCodePoints } from '../utils/textUtils.js';
|
import { toCodePoints } from '../utils/textUtils.js';
|
||||||
|
|
||||||
|
@ -57,6 +60,11 @@ export function useCompletion(
|
||||||
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
|
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
|
||||||
|
const completionStart = useRef(-1);
|
||||||
|
const completionEnd = useRef(-1);
|
||||||
|
|
||||||
|
const cursorRow = buffer.cursor[0];
|
||||||
|
const cursorCol = buffer.cursor[1];
|
||||||
|
|
||||||
const resetCompletionState = useCallback(() => {
|
const resetCompletionState = useCallback(() => {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
@ -127,17 +135,15 @@ export function useCompletion(
|
||||||
}, [suggestions.length]);
|
}, [suggestions.length]);
|
||||||
|
|
||||||
// Check if cursor is after @ or / without unescaped spaces
|
// Check if cursor is after @ or / without unescaped spaces
|
||||||
const isActive = useMemo(() => {
|
const commandIndex = useMemo(() => {
|
||||||
if (isSlashCommand(buffer.text.trim())) {
|
if (isSlashCommand(buffer.text.trim())) {
|
||||||
return true;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other completions like '@', we search backwards from the cursor.
|
// 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 codePoints = toCodePoints(buffer.lines[cursorRow] || '');
|
||||||
|
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||||
const char = codePoints[i];
|
const char = codePoints[i];
|
||||||
|
|
||||||
if (char === ' ') {
|
if (char === ' ') {
|
||||||
|
@ -147,19 +153,19 @@ export function useCompletion(
|
||||||
backslashCount++;
|
backslashCount++;
|
||||||
}
|
}
|
||||||
if (backslashCount % 2 === 0) {
|
if (backslashCount % 2 === 0) {
|
||||||
return false; // Inactive on unescaped space.
|
return -1; // Inactive on unescaped space.
|
||||||
}
|
}
|
||||||
} else if (char === '@') {
|
} else if (char === '@') {
|
||||||
// Active if we find an '@' before any unescaped space.
|
// Active if we find an '@' before any unescaped space.
|
||||||
return true;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return -1;
|
||||||
}, [buffer.text, buffer.cursor, buffer.lines]);
|
}, [buffer.text, cursorRow, cursorCol, buffer.lines]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) {
|
if (commandIndex === -1) {
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -311,14 +317,29 @@ export function useCompletion(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle At Command Completion
|
// Handle At Command Completion
|
||||||
const atIndex = buffer.text.lastIndexOf('@');
|
const currentLine = buffer.lines[cursorRow] || '';
|
||||||
if (atIndex === -1) {
|
const codePoints = toCodePoints(currentLine);
|
||||||
resetCompletionState();
|
|
||||||
return;
|
completionEnd.current = codePoints.length;
|
||||||
|
for (let i = cursorCol; i < codePoints.length; i++) {
|
||||||
|
if (codePoints[i] === ' ') {
|
||||||
|
let backslashCount = 0;
|
||||||
|
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||||
|
backslashCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backslashCount % 2 === 0) {
|
||||||
|
completionEnd.current = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const partialPath = buffer.text.substring(atIndex + 1);
|
const pathStart = commandIndex + 1;
|
||||||
|
const partialPath = currentLine.substring(pathStart, completionEnd.current);
|
||||||
const lastSlashIndex = partialPath.lastIndexOf('/');
|
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||||
|
completionStart.current =
|
||||||
|
lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
|
||||||
const baseDirRelative =
|
const baseDirRelative =
|
||||||
lastSlashIndex === -1
|
lastSlashIndex === -1
|
||||||
? '.'
|
? '.'
|
||||||
|
@ -601,9 +622,12 @@ export function useCompletion(
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
buffer.text,
|
buffer.text,
|
||||||
|
cursorRow,
|
||||||
|
cursorCol,
|
||||||
|
buffer.lines,
|
||||||
dirs,
|
dirs,
|
||||||
cwd,
|
cwd,
|
||||||
isActive,
|
commandIndex,
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
commandContext,
|
commandContext,
|
||||||
|
@ -669,23 +693,19 @@ export function useCompletion(
|
||||||
|
|
||||||
buffer.setText(newValue);
|
buffer.setText(newValue);
|
||||||
} else {
|
} else {
|
||||||
const atIndex = query.lastIndexOf('@');
|
if (completionStart.current === -1 || completionEnd.current === -1) {
|
||||||
if (atIndex === -1) return;
|
return;
|
||||||
const pathPart = query.substring(atIndex + 1);
|
|
||||||
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
|
|
||||||
let autoCompleteStartIndex = atIndex + 1;
|
|
||||||
if (lastSlashIndexInPath !== -1) {
|
|
||||||
autoCompleteStartIndex += lastSlashIndexInPath + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.replaceRangeByOffset(
|
buffer.replaceRangeByOffset(
|
||||||
autoCompleteStartIndex,
|
logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
|
||||||
buffer.text.length,
|
logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
|
||||||
suggestion,
|
suggestion,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
},
|
},
|
||||||
[resetCompletionState, buffer, suggestions, slashCommands],
|
[cursorRow, resetCompletionState, buffer, suggestions, slashCommands],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
Loading…
Reference in New Issue