mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
fix(cli): Polish shell autocomplete rendering to be a little more shell native feeling. (#20931)
This commit is contained in:
@@ -46,6 +46,7 @@ vi.mock('./useShellCompletion', () => ({
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
query: '',
|
||||
activeStart: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -57,7 +58,12 @@ const setupMocks = ({
|
||||
isLoading = false,
|
||||
isPerfectMatch = false,
|
||||
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
||||
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
|
||||
shellCompletionRange = {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
query: '',
|
||||
activeStart: 0,
|
||||
},
|
||||
}: {
|
||||
atSuggestions?: Suggestion[];
|
||||
slashSuggestions?: Suggestion[];
|
||||
@@ -69,6 +75,7 @@ const setupMocks = ({
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
query: string;
|
||||
activeStart?: number;
|
||||
};
|
||||
}) => {
|
||||
// Mock for @-completions
|
||||
@@ -116,7 +123,10 @@ const setupMocks = ({
|
||||
setSuggestions(shellSuggestions);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
return shellCompletionRange;
|
||||
return {
|
||||
...shellCompletionRange,
|
||||
activeStart: shellCompletionRange.activeStart ?? 0,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -139,38 +149,57 @@ describe('useCommandCompletion', () => {
|
||||
});
|
||||
}
|
||||
|
||||
let hookResult: ReturnType<typeof useCommandCompletion> & {
|
||||
textBuffer: ReturnType<typeof useTextBuffer>;
|
||||
};
|
||||
|
||||
function TestComponent({
|
||||
initialText,
|
||||
cursorOffset,
|
||||
shellModeActive,
|
||||
active,
|
||||
}: {
|
||||
initialText: string;
|
||||
cursorOffset?: number;
|
||||
shellModeActive: boolean;
|
||||
active: boolean;
|
||||
}) {
|
||||
const textBuffer = useTextBufferForTest(initialText, cursorOffset);
|
||||
const completion = useCommandCompletion({
|
||||
buffer: textBuffer,
|
||||
cwd: testRootDir,
|
||||
slashCommands: [],
|
||||
commandContext: mockCommandContext,
|
||||
reverseSearchActive: false,
|
||||
shellModeActive,
|
||||
config: mockConfig,
|
||||
active,
|
||||
});
|
||||
hookResult = { ...completion, textBuffer };
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCommandCompletionHook = (
|
||||
initialText: string,
|
||||
cursorOffset?: number,
|
||||
shellModeActive = false,
|
||||
active = true,
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useCommandCompletion> & {
|
||||
textBuffer: ReturnType<typeof useTextBuffer>;
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
const textBuffer = useTextBufferForTest(initialText, cursorOffset);
|
||||
const completion = useCommandCompletion({
|
||||
buffer: textBuffer,
|
||||
cwd: testRootDir,
|
||||
slashCommands: [],
|
||||
commandContext: mockCommandContext,
|
||||
reverseSearchActive: false,
|
||||
shellModeActive,
|
||||
config: mockConfig,
|
||||
active,
|
||||
});
|
||||
hookResult = { ...completion, textBuffer };
|
||||
return null;
|
||||
}
|
||||
renderWithProviders(<TestComponent />);
|
||||
const renderResult = renderWithProviders(
|
||||
<TestComponent
|
||||
initialText={initialText}
|
||||
cursorOffset={cursorOffset}
|
||||
shellModeActive={shellModeActive}
|
||||
active={active}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
...renderResult,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -524,6 +553,129 @@ describe('useCommandCompletion', () => {
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('@src\\components\\');
|
||||
});
|
||||
|
||||
it('should show ghost text for a single shell completion', async () => {
|
||||
const text = 'l';
|
||||
setupMocks({
|
||||
shellSuggestions: [{ label: 'ls', value: 'ls' }],
|
||||
shellCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 1,
|
||||
query: 'l',
|
||||
activeStart: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
text,
|
||||
text.length,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
// Should show "ls " as ghost text (including trailing space)
|
||||
expect(result.current.promptCompletion.text).toBe('ls ');
|
||||
});
|
||||
|
||||
it('should not show ghost text if there are multiple completions', async () => {
|
||||
const text = 'l';
|
||||
setupMocks({
|
||||
shellSuggestions: [
|
||||
{ label: 'ls', value: 'ls' },
|
||||
{ label: 'ln', value: 'ln' },
|
||||
],
|
||||
shellCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 1,
|
||||
query: 'l',
|
||||
activeStart: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
text,
|
||||
text.length,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
});
|
||||
|
||||
it('should not show ghost text if the typed text extends past the completion', async () => {
|
||||
// "ls " is already typed.
|
||||
const text = 'ls ';
|
||||
const cursorOffset = text.length;
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
text,
|
||||
cursorOffset,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
});
|
||||
|
||||
it('should clear ghost text after user types a space when exact match ghost text was showing', async () => {
|
||||
const textWithoutSpace = 'ls';
|
||||
|
||||
setupMocks({
|
||||
shellSuggestions: [{ label: 'ls', value: 'ls' }],
|
||||
shellCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 2,
|
||||
query: 'ls',
|
||||
activeStart: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
textWithoutSpace,
|
||||
textWithoutSpace.length,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
// Initially no ghost text because "ls" perfectly matches "ls"
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
|
||||
// Now simulate typing a space.
|
||||
// In the real app, shellCompletionRange.completionStart would change immediately to 3,
|
||||
// but suggestions (and activeStart) would still be from the previous token for a few ms.
|
||||
setupMocks({
|
||||
shellSuggestions: [{ label: 'ls', value: 'ls' }], // Stale suggestions
|
||||
shellCompletionRange: {
|
||||
completionStart: 3, // New token position
|
||||
completionEnd: 3,
|
||||
query: '',
|
||||
activeStart: 0, // Stale active start
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.textBuffer.setText('ls ', 'end');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
// Should STILL be empty because completionStart (3) !== activeStart (0)
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt completion filtering', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useEffect } from 'react';
|
||||
import { useCallback, useMemo, useEffect, useState } from 'react';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
@@ -37,6 +37,9 @@ export interface UseCommandCompletionReturn {
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
forceShowShellSuggestions: boolean;
|
||||
setForceShowShellSuggestions: (value: boolean) => void;
|
||||
isShellSuggestionsVisible: boolean;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
@@ -80,6 +83,9 @@ export function useCommandCompletion({
|
||||
config,
|
||||
active,
|
||||
}: UseCommandCompletionOptions): UseCommandCompletionReturn {
|
||||
const [forceShowShellSuggestions, setForceShowShellSuggestions] =
|
||||
useState(false);
|
||||
|
||||
const {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
@@ -93,11 +99,16 @@ export function useCommandCompletion({
|
||||
setIsPerfectMatch,
|
||||
setVisibleStartIndex,
|
||||
|
||||
resetCompletionState,
|
||||
resetCompletionState: baseResetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
} = useCompletion();
|
||||
|
||||
const resetCompletionState = useCallback(() => {
|
||||
baseResetCompletionState();
|
||||
setForceShowShellSuggestions(false);
|
||||
}, [baseResetCompletionState]);
|
||||
|
||||
const cursorRow = buffer.cursor[0];
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
@@ -231,10 +242,73 @@ export function useCommandCompletion({
|
||||
? shellCompletionRange.query
|
||||
: memoQuery;
|
||||
|
||||
const promptCompletion = usePromptCompletion({
|
||||
const basePromptCompletion = usePromptCompletion({
|
||||
buffer,
|
||||
});
|
||||
|
||||
const isShellSuggestionsVisible =
|
||||
completionMode !== CompletionMode.SHELL || forceShowShellSuggestions;
|
||||
|
||||
const promptCompletion = useMemo(() => {
|
||||
if (
|
||||
completionMode === CompletionMode.SHELL &&
|
||||
suggestions.length === 1 &&
|
||||
query != null &&
|
||||
shellCompletionRange.completionStart === shellCompletionRange.activeStart
|
||||
) {
|
||||
const suggestion = suggestions[0];
|
||||
const textToInsertBase = suggestion.value;
|
||||
|
||||
if (
|
||||
textToInsertBase.startsWith(query) &&
|
||||
textToInsertBase.length > query.length
|
||||
) {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
const start = shellCompletionRange.completionStart;
|
||||
const end = shellCompletionRange.completionEnd;
|
||||
|
||||
let textToInsert = textToInsertBase;
|
||||
const charAfterCompletion = currentLine[end];
|
||||
if (
|
||||
charAfterCompletion !== ' ' &&
|
||||
!textToInsert.endsWith('/') &&
|
||||
!textToInsert.endsWith('\\')
|
||||
) {
|
||||
textToInsert += ' ';
|
||||
}
|
||||
|
||||
const newText =
|
||||
currentLine.substring(0, start) +
|
||||
textToInsert +
|
||||
currentLine.substring(end);
|
||||
|
||||
return {
|
||||
text: newText,
|
||||
isActive: true,
|
||||
isLoading: false,
|
||||
accept: () => {
|
||||
buffer.replaceRangeByOffset(
|
||||
logicalPosToOffset(buffer.lines, cursorRow, start),
|
||||
logicalPosToOffset(buffer.lines, cursorRow, end),
|
||||
textToInsert,
|
||||
);
|
||||
},
|
||||
clear: () => {},
|
||||
markSelected: () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
return basePromptCompletion;
|
||||
}, [
|
||||
completionMode,
|
||||
suggestions,
|
||||
query,
|
||||
basePromptCompletion,
|
||||
buffer,
|
||||
cursorRow,
|
||||
shellCompletionRange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
@@ -271,6 +345,7 @@ export function useCommandCompletion({
|
||||
active &&
|
||||
completionMode !== CompletionMode.IDLE &&
|
||||
!reverseSearchActive &&
|
||||
isShellSuggestionsVisible &&
|
||||
(isLoadingSuggestions || suggestions.length > 0);
|
||||
|
||||
/**
|
||||
@@ -395,6 +470,9 @@ export function useCommandCompletion({
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
forceShowShellSuggestions,
|
||||
setForceShowShellSuggestions,
|
||||
isShellSuggestionsVisible,
|
||||
setActiveSuggestionIndex,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -435,6 +435,7 @@ export interface UseShellCompletionReturn {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
query: string;
|
||||
activeStart: number;
|
||||
}
|
||||
|
||||
const EMPTY_TOKENS: string[] = [];
|
||||
@@ -451,6 +452,7 @@ export function useShellCompletion({
|
||||
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [activeStart, setActiveStart] = useState<number>(-1);
|
||||
|
||||
const tokenInfo = useMemo(
|
||||
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
|
||||
@@ -467,6 +469,14 @@ export function useShellCompletion({
|
||||
commandToken = '',
|
||||
} = tokenInfo || {};
|
||||
|
||||
// Immediately clear suggestions if the token range has changed.
|
||||
// This avoids a frame of flickering with stale suggestions (e.g. "ls ls")
|
||||
// when moving to a new token.
|
||||
if (enabled && activeStart !== -1 && completionStart !== activeStart) {
|
||||
setSuggestions([]);
|
||||
setActiveStart(-1);
|
||||
}
|
||||
|
||||
// Invalidate PATH cache when $PATH changes
|
||||
useEffect(() => {
|
||||
const currentPath = process.env['PATH'] ?? '';
|
||||
@@ -558,6 +568,7 @@ export function useShellCompletion({
|
||||
if (signal.aborted) return;
|
||||
|
||||
setSuggestions(results);
|
||||
setActiveStart(completionStart);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(
|
||||
@@ -571,6 +582,7 @@ export function useShellCompletion({
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
setSuggestions([]);
|
||||
setActiveStart(completionStart);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
@@ -586,6 +598,7 @@ export function useShellCompletion({
|
||||
cursorIndex,
|
||||
commandToken,
|
||||
cwd,
|
||||
completionStart,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
]);
|
||||
@@ -594,6 +607,7 @@ export function useShellCompletion({
|
||||
if (!enabled) {
|
||||
abortRef.current?.abort();
|
||||
setSuggestions([]);
|
||||
setActiveStart(-1);
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
@@ -633,5 +647,6 @@ export function useShellCompletion({
|
||||
completionStart,
|
||||
completionEnd,
|
||||
query,
|
||||
activeStart,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user