Merge tag 'v0.1.18' of https://github.com/google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.18

This commit is contained in:
tanzhenxin
2025-08-13 15:11:10 +08:00
94 changed files with 5258 additions and 4724 deletions

View File

@@ -4,20 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useCallback, useMemo, useRef } from 'react';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
import {
isNodeError,
escapePath,
unescapePath,
getErrorMessage,
Config,
FileDiscoveryService,
DEFAULT_FILE_FILTERING_OPTIONS,
SHELL_SPECIAL_CHARS,
} from '@qwen-code/qwen-code-core';
import { useCallback, useMemo, useEffect } from 'react';
import { Suggestion } from '../components/SuggestionsDisplay.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import {
@@ -26,8 +13,17 @@ import {
} from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { Config } from '@qwen-code/qwen-code-core';
import { useCompletion } from './useCompletion.js';
export enum CompletionMode {
IDLE = 'IDLE',
AT = 'AT',
SLASH = 'SLASH',
}
export interface UseCommandCompletionReturn {
suggestions: Suggestion[];
activeSuggestionIndex: number;
@@ -72,541 +68,109 @@ export function useCommandCompletion(
navigateDown,
} = useCompletion();
const completionStart = useRef(-1);
const completionEnd = useRef(-1);
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
// Check if cursor is after @ or / without unescaped spaces
const commandIndex = useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return currentLine.indexOf('/');
}
// For other completions like '@', we search backwards from the cursor.
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol - 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 -1; // Inactive on unescaped space.
}
} else if (char === '@') {
// Active if we find an '@' before any unescaped space.
return i;
const { completionMode, query, completionStart, completionEnd } =
useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
}
return -1;
}, [cursorRow, cursorCol, buffer.lines]);
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}
} else if (char === '@') {
let end = codePoints.length;
for (let i = cursorCol; i < codePoints.length; i++) {
if (codePoints[i] === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
end = i;
break;
}
}
}
const pathStart = i + 1;
const partialPath = currentLine.substring(pathStart, end);
return {
completionMode: CompletionMode.AT,
query: partialPath,
completionStart: pathStart,
completionEnd: end,
};
}
}
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines]);
useAtCompletion({
enabled: completionMode === CompletionMode.AT,
pattern: query || '',
config,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
const slashCompletionRange = useSlashCompletion({
enabled: completionMode === CompletionMode.SLASH,
query,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
});
useEffect(() => {
if (commandIndex === -1 || reverseSearchActive) {
setTimeout(resetCompletionState, 0);
return;
}
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
if (codePoints[commandIndex] === '/') {
// Always reset perfect match at the beginning of processing.
setIsPerfectMatch(false);
const fullPath = currentLine.substring(commandIndex + 1);
const hasTrailingSpace = currentLine.endsWith(' ');
// Get all non-empty parts of the command.
const rawParts = fullPath.split(/\s+/).filter((p) => p);
let commandPathParts = rawParts;
let partial = '';
// If there's no trailing space, the last part is potentially a partial segment.
// We tentatively separate it.
if (!hasTrailingSpace && rawParts.length > 0) {
partial = rawParts[rawParts.length - 1];
commandPathParts = rawParts.slice(0, -1);
}
// Traverse the Command Tree using the tentative completed path
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null;
for (const part of commandPathParts) {
if (!currentLevel) {
leafCommand = null;
currentLevel = [];
break;
}
const found: SlashCommand | undefined = currentLevel.find(
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
);
if (found) {
leafCommand = found;
currentLevel = found.subCommands as
| readonly SlashCommand[]
| undefined;
} else {
leafCommand = null;
currentLevel = [];
break;
}
}
let exactMatchAsParent: SlashCommand | undefined;
// Handle the Ambiguous Case
if (!hasTrailingSpace && currentLevel) {
exactMatchAsParent = currentLevel.find(
(cmd) =>
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.subCommands,
);
if (exactMatchAsParent) {
// It's a perfect match for a parent command. Override our initial guess.
// Treat it as a completed command path.
leafCommand = exactMatchAsParent;
currentLevel = exactMatchAsParent.subCommands;
partial = ''; // We now want to suggest ALL of its sub-commands.
}
}
// Check for perfect, executable match
if (!hasTrailingSpace) {
if (leafCommand && partial === '' && leafCommand.action) {
// Case: /command<enter> - command has action, no sub-commands were suggested
setIsPerfectMatch(true);
} else if (currentLevel) {
// Case: /command subcommand<enter>
const perfectMatch = currentLevel.find(
(cmd) =>
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.action,
);
if (perfectMatch) {
setIsPerfectMatch(true);
}
}
}
const depth = commandPathParts.length;
const isArgumentCompletion =
leafCommand?.completion &&
(hasTrailingSpace ||
(rawParts.length > depth && depth > 0 && partial !== ''));
// Set completion range
if (hasTrailingSpace || exactMatchAsParent) {
completionStart.current = currentLine.length;
completionEnd.current = currentLine.length;
} else if (partial) {
if (isArgumentCompletion) {
const commandSoFar = `/${commandPathParts.join(' ')}`;
const argStartIndex =
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
completionStart.current = argStartIndex;
} else {
completionStart.current = currentLine.length - partial.length;
}
completionEnd.current = currentLine.length;
} else {
// e.g. /
completionStart.current = commandIndex + 1;
completionEnd.current = currentLine.length;
}
// Provide Suggestions based on the now-corrected context
if (isArgumentCompletion) {
const fetchAndSetSuggestions = async () => {
setIsLoadingSuggestions(true);
const argString = rawParts.slice(depth).join(' ');
const results =
(await leafCommand!.completion!(commandContext, argString)) || [];
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
setSuggestions(finalSuggestions);
setShowSuggestions(finalSuggestions.length > 0);
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
setIsLoadingSuggestions(false);
};
fetchAndSetSuggestions();
return;
}
// Command/Sub-command Completion
const commandsToSearch = currentLevel || [];
if (commandsToSearch.length > 0) {
let potentialSuggestions = commandsToSearch.filter(
(cmd) =>
cmd.description &&
(cmd.name.startsWith(partial) ||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
);
// If a user's input is an exact match and it is a leaf command,
// enter should submit immediately.
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
const perfectMatch = potentialSuggestions.find(
(s) => s.name === partial || s.altNames?.includes(partial),
);
if (perfectMatch && perfectMatch.action) {
potentialSuggestions = [];
}
}
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
value: cmd.name,
description: cmd.description,
}));
setSuggestions(finalSuggestions);
setShowSuggestions(finalSuggestions.length > 0);
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
setIsLoadingSuggestions(false);
return;
}
// If we fall through, no suggestions are available.
useEffect(() => {
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
resetCompletionState();
return;
}
// Handle At Command Completion
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 pathStart = commandIndex + 1;
const partialPath = currentLine.substring(pathStart, completionEnd.current);
const lastSlashIndex = partialPath.lastIndexOf('/');
completionStart.current =
lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
const baseDirRelative =
lastSlashIndex === -1
? '.'
: partialPath.substring(0, lastSlashIndex + 1);
const prefix = unescapePath(
lastSlashIndex === -1
? partialPath
: partialPath.substring(lastSlashIndex + 1),
);
let isMounted = true;
const findFilesRecursively = async (
startDir: string,
searchPrefix: string,
fileDiscovery: FileDiscoveryService | null,
filterOptions: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
},
currentRelativePath = '',
depth = 0,
maxDepth = 10, // Limit recursion depth
maxResults = 50, // Limit number of results
): Promise<Suggestion[]> => {
if (depth > maxDepth) {
return [];
}
const lowerSearchPrefix = searchPrefix.toLowerCase();
let foundSuggestions: Suggestion[] = [];
try {
const entries = await fs.readdir(startDir, { withFileTypes: true });
for (const entry of entries) {
if (foundSuggestions.length >= maxResults) break;
const entryPathRelative = path.join(currentRelativePath, entry.name);
const entryPathFromRoot = path.relative(
startDir,
path.join(startDir, entry.name),
);
// Conditionally ignore dotfiles
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
continue;
}
// Check if this entry should be ignored by filtering options
if (
fileDiscovery &&
fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
) {
continue;
}
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
foundSuggestions.push({
label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
value: escapePath(
entryPathRelative + (entry.isDirectory() ? '/' : ''),
),
});
}
if (
entry.isDirectory() &&
entry.name !== 'node_modules' &&
!entry.name.startsWith('.')
) {
if (foundSuggestions.length < maxResults) {
foundSuggestions = foundSuggestions.concat(
await findFilesRecursively(
path.join(startDir, entry.name),
searchPrefix, // Pass original searchPrefix for recursive calls
fileDiscovery,
filterOptions,
entryPathRelative,
depth + 1,
maxDepth,
maxResults - foundSuggestions.length,
),
);
}
}
}
} catch (_err) {
// Ignore errors like permission denied or ENOENT during recursive search
}
return foundSuggestions.slice(0, maxResults);
};
const findFilesWithGlob = async (
searchPrefix: string,
fileDiscoveryService: FileDiscoveryService,
filterOptions: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
},
searchDir: string,
maxResults = 50,
): Promise<Suggestion[]> => {
const globPattern = `**/${searchPrefix}*`;
const files = await glob(globPattern, {
cwd: searchDir,
dot: searchPrefix.startsWith('.'),
nocase: true,
});
const suggestions: Suggestion[] = files
.filter((file) => {
if (fileDiscoveryService) {
return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
}
return true;
})
.map((file: string) => {
const absolutePath = path.resolve(searchDir, file);
const label = path.relative(cwd, absolutePath);
return {
label,
value: escapePath(label),
};
})
.slice(0, maxResults);
return suggestions;
};
const fetchSuggestions = async () => {
setIsLoadingSuggestions(true);
let fetchedSuggestions: Suggestion[] = [];
const fileDiscoveryService = config ? config.getFileService() : null;
const enableRecursiveSearch =
config?.getEnableRecursiveFileSearch() ?? true;
const filterOptions =
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
try {
// If there's no slash, or it's the root, do a recursive search from workspace directories
for (const dir of dirs) {
let fetchedSuggestionsPerDir: Suggestion[] = [];
if (
partialPath.indexOf('/') === -1 &&
prefix &&
enableRecursiveSearch
) {
if (fileDiscoveryService) {
fetchedSuggestionsPerDir = await findFilesWithGlob(
prefix,
fileDiscoveryService,
filterOptions,
dir,
);
} else {
fetchedSuggestionsPerDir = await findFilesRecursively(
dir,
prefix,
null,
filterOptions,
);
}
} else {
// Original behavior: list files in the specific directory
const lowerPrefix = prefix.toLowerCase();
const baseDirAbsolute = path.resolve(dir, baseDirRelative);
const entries = await fs.readdir(baseDirAbsolute, {
withFileTypes: true,
});
// Filter entries using git-aware filtering
const filteredEntries = [];
for (const entry of entries) {
// Conditionally ignore dotfiles
if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
continue;
}
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
const relativePath = path.relative(
dir,
path.join(baseDirAbsolute, entry.name),
);
if (
fileDiscoveryService &&
fileDiscoveryService.shouldIgnoreFile(
relativePath,
filterOptions,
)
) {
continue;
}
filteredEntries.push(entry);
}
fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
const absolutePath = path.resolve(baseDirAbsolute, entry.name);
const label =
cwd === dir ? entry.name : path.relative(cwd, absolutePath);
const suggestionLabel = entry.isDirectory() ? label + '/' : label;
return {
label: suggestionLabel,
value: escapePath(suggestionLabel),
};
});
}
fetchedSuggestions = [
...fetchedSuggestions,
...fetchedSuggestionsPerDir,
];
}
// Like glob, we always return forward slashes for path separators, even on Windows.
// But preserve backslash escaping for special characters.
const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`;
const pathSeparatorRegex = new RegExp(
`\\\\${specialCharsLookahead}`,
'g',
);
fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
...suggestion,
label: suggestion.label.replace(pathSeparatorRegex, '/'),
value: suggestion.value.replace(pathSeparatorRegex, '/'),
}));
// Sort by depth, then directories first, then alphabetically
fetchedSuggestions.sort((a, b) => {
const depthA = (a.label.match(/\//g) || []).length;
const depthB = (b.label.match(/\//g) || []).length;
if (depthA !== depthB) {
return depthA - depthB;
}
const aIsDir = a.label.endsWith('/');
const bIsDir = b.label.endsWith('/');
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
// exclude extension when comparing
const filenameA = a.label.substring(
0,
a.label.length - path.extname(a.label).length,
);
const filenameB = b.label.substring(
0,
b.label.length - path.extname(b.label).length,
);
return (
filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
);
});
if (isMounted) {
setSuggestions(fetchedSuggestions);
setShowSuggestions(fetchedSuggestions.length > 0);
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (isMounted) {
setSuggestions([]);
setShowSuggestions(false);
}
} else {
console.error(
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
);
if (isMounted) {
resetCompletionState();
}
}
}
if (isMounted) {
setIsLoadingSuggestions(false);
}
};
const debounceTimeout = setTimeout(fetchSuggestions, 100);
return () => {
isMounted = false;
clearTimeout(debounceTimeout);
};
// Show suggestions if we are loading OR if there are results to display.
setShowSuggestions(isLoadingSuggestions || suggestions.length > 0);
}, [
buffer.text,
cursorRow,
cursorCol,
buffer.lines,
dirs,
cwd,
commandIndex,
resetCompletionState,
slashCommands,
commandContext,
config,
completionMode,
suggestions.length,
isLoadingSuggestions,
reverseSearchActive,
setSuggestions,
resetCompletionState,
setShowSuggestions,
setActiveSuggestionIndex,
setIsLoadingSuggestions,
setIsPerfectMatch,
setVisibleStartIndex,
]);
const handleAutocomplete = useCallback(
@@ -616,18 +180,23 @@ export function useCommandCompletion(
}
const suggestion = suggestions[indexToUse].value;
if (completionStart.current === -1 || completionEnd.current === -1) {
let start = completionStart;
let end = completionEnd;
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
}
if (start === -1 || end === -1) {
return;
}
const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/';
let suggestionText = suggestion;
if (isSlash) {
// If we are inserting (not replacing), and the preceding character is not a space, add one.
if (completionMode === CompletionMode.SLASH) {
if (
completionStart.current === completionEnd.current &&
completionStart.current > commandIndex + 1 &&
(buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
start === end &&
start > 1 &&
(buffer.lines[cursorRow] || '')[start - 1] !== ' '
) {
suggestionText = ' ' + suggestionText;
}
@@ -636,12 +205,20 @@ export function useCommandCompletion(
suggestionText += ' ';
buffer.replaceRangeByOffset(
logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
logicalPosToOffset(buffer.lines, cursorRow, start),
logicalPosToOffset(buffer.lines, cursorRow, end),
suggestionText,
);
},
[cursorRow, buffer, suggestions, commandIndex],
[
cursorRow,
buffer,
suggestions,
completionMode,
completionStart,
completionEnd,
slashCompletionRange,
],
);
return {