mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
fix(cli): Polish shell autocomplete rendering to be a little more shell native feeling. (#20931)
This commit is contained in:
@@ -279,6 +279,9 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
getCompletedText: vi.fn().mockReturnValue(null),
|
||||
completionMode: CompletionMode.IDLE,
|
||||
forceShowShellSuggestions: false,
|
||||
setForceShowShellSuggestions: vi.fn(),
|
||||
isShellSuggestionsVisible: true,
|
||||
};
|
||||
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
||||
|
||||
|
||||
@@ -301,6 +301,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const resetCommandSearchCompletionState =
|
||||
commandSearchCompletion.resetCompletionState;
|
||||
|
||||
const getActiveCompletion = useCallback(() => {
|
||||
if (commandSearchActive) return commandSearchCompletion;
|
||||
if (reverseSearchActive) return reverseSearchCompletion;
|
||||
return completion;
|
||||
}, [
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
reverseSearchActive,
|
||||
reverseSearchCompletion,
|
||||
completion,
|
||||
]);
|
||||
|
||||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
const {
|
||||
forceShowShellSuggestions,
|
||||
setForceShowShellSuggestions,
|
||||
isShellSuggestionsVisible,
|
||||
} = completion;
|
||||
|
||||
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
||||
|
||||
// Notify parent component about escape prompt state changes
|
||||
@@ -363,7 +384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
isActive:
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||
completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
currentCursorOffset: buffer.getOffset(),
|
||||
@@ -595,9 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers[Command.END](key);
|
||||
|
||||
const isSuggestionsNav =
|
||||
(completion.showSuggestions ||
|
||||
reverseSearchCompletion.showSuggestions ||
|
||||
commandSearchCompletion.showSuggestions) &&
|
||||
shouldShowSuggestions &&
|
||||
(keyMatchers[Command.COMPLETION_UP](key) ||
|
||||
keyMatchers[Command.COMPLETION_DOWN](key) ||
|
||||
keyMatchers[Command.EXPAND_SUGGESTION](key) ||
|
||||
@@ -612,6 +632,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
||||
);
|
||||
hasUserNavigatedSuggestions.current = false;
|
||||
|
||||
if (key.name !== 'tab') {
|
||||
setForceShowShellSuggestions(false);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(jacobr): this special case is likely not needed anymore.
|
||||
@@ -641,15 +665,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const isPlainTab =
|
||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||
const hasTabCompletionInteraction =
|
||||
completion.showSuggestions ||
|
||||
(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||
Boolean(completion.promptCompletion.text) ||
|
||||
reverseSearchActive ||
|
||||
commandSearchActive;
|
||||
|
||||
if (isPlainTab && shellModeActive) {
|
||||
resetPlainTabPress();
|
||||
if (!completion.showSuggestions) {
|
||||
if (!shouldShowSuggestions) {
|
||||
setSuppressCompletion(false);
|
||||
if (completion.promptCompletion.text) {
|
||||
completion.promptCompletion.accept();
|
||||
return true;
|
||||
} else if (
|
||||
completion.suggestions.length > 0 &&
|
||||
!forceShowShellSuggestions
|
||||
) {
|
||||
setForceShowShellSuggestions(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (isPlainTab) {
|
||||
if (!hasTabCompletionInteraction) {
|
||||
@@ -752,7 +786,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (
|
||||
key.sequence === '!' &&
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions
|
||||
!(completion.showSuggestions && isShellSuggestionsVisible)
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
@@ -791,15 +825,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
if (completion.showSuggestions && isShellSuggestionsVisible) {
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
resetEscapeState();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
resetEscapeState();
|
||||
return true;
|
||||
}
|
||||
@@ -895,7 +929,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.isPerfectMatch &&
|
||||
keyMatchers[Command.SUBMIT](key) &&
|
||||
recentUnsafePasteTime === null &&
|
||||
(!completion.showSuggestions ||
|
||||
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||
(completion.activeSuggestionIndex <= 0 &&
|
||||
!hasUserNavigatedSuggestions.current))
|
||||
) {
|
||||
@@ -909,7 +943,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
if (completion.showSuggestions && isShellSuggestionsVisible) {
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
@@ -1007,7 +1041,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (
|
||||
key.name === 'tab' &&
|
||||
!key.shift &&
|
||||
!completion.showSuggestions &&
|
||||
!(completion.showSuggestions && isShellSuggestionsVisible) &&
|
||||
completion.promptCompletion.text
|
||||
) {
|
||||
completion.promptCompletion.accept();
|
||||
@@ -1190,6 +1224,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
focus,
|
||||
buffer,
|
||||
completion,
|
||||
setForceShowShellSuggestions,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
@@ -1221,6 +1256,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
registerPlainTabPress,
|
||||
resetPlainTabPress,
|
||||
toggleCleanUiDetailsVisible,
|
||||
shouldShowSuggestions,
|
||||
isShellSuggestionsVisible,
|
||||
forceShowShellSuggestions,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1346,14 +1384,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
]);
|
||||
|
||||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
||||
const getActiveCompletion = () => {
|
||||
if (commandSearchActive) return commandSearchCompletion;
|
||||
if (reverseSearchActive) return reverseSearchCompletion;
|
||||
return completion;
|
||||
};
|
||||
|
||||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
const isLowColor = isLowColorDepth();
|
||||
|
||||
@@ -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