mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -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),
|
getCompletedText: vi.fn().mockReturnValue(null),
|
||||||
completionMode: CompletionMode.IDLE,
|
completionMode: CompletionMode.IDLE,
|
||||||
|
forceShowShellSuggestions: false,
|
||||||
|
setForceShowShellSuggestions: vi.fn(),
|
||||||
|
isShellSuggestionsVisible: true,
|
||||||
};
|
};
|
||||||
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
||||||
|
|
||||||
|
|||||||
@@ -301,6 +301,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
const resetCommandSearchCompletionState =
|
const resetCommandSearchCompletionState =
|
||||||
commandSearchCompletion.resetCompletionState;
|
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;
|
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
||||||
|
|
||||||
// Notify parent component about escape prompt state changes
|
// Notify parent component about escape prompt state changes
|
||||||
@@ -363,7 +384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
userMessages,
|
userMessages,
|
||||||
onSubmit: handleSubmitAndClear,
|
onSubmit: handleSubmitAndClear,
|
||||||
isActive:
|
isActive:
|
||||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||||
|
completion.suggestions.length === 1) &&
|
||||||
!shellModeActive,
|
!shellModeActive,
|
||||||
currentQuery: buffer.text,
|
currentQuery: buffer.text,
|
||||||
currentCursorOffset: buffer.getOffset(),
|
currentCursorOffset: buffer.getOffset(),
|
||||||
@@ -595,9 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
keyMatchers[Command.END](key);
|
keyMatchers[Command.END](key);
|
||||||
|
|
||||||
const isSuggestionsNav =
|
const isSuggestionsNav =
|
||||||
(completion.showSuggestions ||
|
shouldShowSuggestions &&
|
||||||
reverseSearchCompletion.showSuggestions ||
|
|
||||||
commandSearchCompletion.showSuggestions) &&
|
|
||||||
(keyMatchers[Command.COMPLETION_UP](key) ||
|
(keyMatchers[Command.COMPLETION_UP](key) ||
|
||||||
keyMatchers[Command.COMPLETION_DOWN](key) ||
|
keyMatchers[Command.COMPLETION_DOWN](key) ||
|
||||||
keyMatchers[Command.EXPAND_SUGGESTION](key) ||
|
keyMatchers[Command.EXPAND_SUGGESTION](key) ||
|
||||||
@@ -612,6 +632,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
||||||
);
|
);
|
||||||
hasUserNavigatedSuggestions.current = false;
|
hasUserNavigatedSuggestions.current = false;
|
||||||
|
|
||||||
|
if (key.name !== 'tab') {
|
||||||
|
setForceShowShellSuggestions(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(jacobr): this special case is likely not needed anymore.
|
// TODO(jacobr): this special case is likely not needed anymore.
|
||||||
@@ -641,15 +665,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
const isPlainTab =
|
const isPlainTab =
|
||||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||||
const hasTabCompletionInteraction =
|
const hasTabCompletionInteraction =
|
||||||
completion.showSuggestions ||
|
(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||||
Boolean(completion.promptCompletion.text) ||
|
Boolean(completion.promptCompletion.text) ||
|
||||||
reverseSearchActive ||
|
reverseSearchActive ||
|
||||||
commandSearchActive;
|
commandSearchActive;
|
||||||
|
|
||||||
if (isPlainTab && shellModeActive) {
|
if (isPlainTab && shellModeActive) {
|
||||||
resetPlainTabPress();
|
resetPlainTabPress();
|
||||||
if (!completion.showSuggestions) {
|
if (!shouldShowSuggestions) {
|
||||||
setSuppressCompletion(false);
|
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) {
|
} else if (isPlainTab) {
|
||||||
if (!hasTabCompletionInteraction) {
|
if (!hasTabCompletionInteraction) {
|
||||||
@@ -752,7 +786,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
if (
|
if (
|
||||||
key.sequence === '!' &&
|
key.sequence === '!' &&
|
||||||
buffer.text === '' &&
|
buffer.text === '' &&
|
||||||
!completion.showSuggestions
|
!(completion.showSuggestions && isShellSuggestionsVisible)
|
||||||
) {
|
) {
|
||||||
setShellModeActive(!shellModeActive);
|
setShellModeActive(!shellModeActive);
|
||||||
buffer.setText(''); // Clear the '!' from input
|
buffer.setText(''); // Clear the '!' from input
|
||||||
@@ -791,15 +825,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shellModeActive) {
|
if (completion.showSuggestions && isShellSuggestionsVisible) {
|
||||||
setShellModeActive(false);
|
completion.resetCompletionState();
|
||||||
|
setExpandedSuggestionIndex(-1);
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (shellModeActive) {
|
||||||
completion.resetCompletionState();
|
setShellModeActive(false);
|
||||||
setExpandedSuggestionIndex(-1);
|
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -895,7 +929,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
completion.isPerfectMatch &&
|
completion.isPerfectMatch &&
|
||||||
keyMatchers[Command.SUBMIT](key) &&
|
keyMatchers[Command.SUBMIT](key) &&
|
||||||
recentUnsafePasteTime === null &&
|
recentUnsafePasteTime === null &&
|
||||||
(!completion.showSuggestions ||
|
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||||
(completion.activeSuggestionIndex <= 0 &&
|
(completion.activeSuggestionIndex <= 0 &&
|
||||||
!hasUserNavigatedSuggestions.current))
|
!hasUserNavigatedSuggestions.current))
|
||||||
) {
|
) {
|
||||||
@@ -909,7 +943,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions && isShellSuggestionsVisible) {
|
||||||
if (completion.suggestions.length > 1) {
|
if (completion.suggestions.length > 1) {
|
||||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||||
completion.navigateUp();
|
completion.navigateUp();
|
||||||
@@ -1007,7 +1041,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
if (
|
if (
|
||||||
key.name === 'tab' &&
|
key.name === 'tab' &&
|
||||||
!key.shift &&
|
!key.shift &&
|
||||||
!completion.showSuggestions &&
|
!(completion.showSuggestions && isShellSuggestionsVisible) &&
|
||||||
completion.promptCompletion.text
|
completion.promptCompletion.text
|
||||||
) {
|
) {
|
||||||
completion.promptCompletion.accept();
|
completion.promptCompletion.accept();
|
||||||
@@ -1190,6 +1224,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
focus,
|
focus,
|
||||||
buffer,
|
buffer,
|
||||||
completion,
|
completion,
|
||||||
|
setForceShowShellSuggestions,
|
||||||
shellModeActive,
|
shellModeActive,
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
onClearScreen,
|
onClearScreen,
|
||||||
@@ -1221,6 +1256,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
registerPlainTabPress,
|
registerPlainTabPress,
|
||||||
resetPlainTabPress,
|
resetPlainTabPress,
|
||||||
toggleCleanUiDetailsVisible,
|
toggleCleanUiDetailsVisible,
|
||||||
|
shouldShowSuggestions,
|
||||||
|
isShellSuggestionsVisible,
|
||||||
|
forceShowShellSuggestions,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1346,14 +1384,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
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 useBackgroundColor = config.getUseBackgroundColor();
|
||||||
const isLowColor = isLowColorDepth();
|
const isLowColor = isLowColorDepth();
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ vi.mock('./useShellCompletion', () => ({
|
|||||||
completionStart: 0,
|
completionStart: 0,
|
||||||
completionEnd: 0,
|
completionEnd: 0,
|
||||||
query: '',
|
query: '',
|
||||||
|
activeStart: 0,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -57,7 +58,12 @@ const setupMocks = ({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
isPerfectMatch = false,
|
isPerfectMatch = false,
|
||||||
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
||||||
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
|
shellCompletionRange = {
|
||||||
|
completionStart: 0,
|
||||||
|
completionEnd: 0,
|
||||||
|
query: '',
|
||||||
|
activeStart: 0,
|
||||||
|
},
|
||||||
}: {
|
}: {
|
||||||
atSuggestions?: Suggestion[];
|
atSuggestions?: Suggestion[];
|
||||||
slashSuggestions?: Suggestion[];
|
slashSuggestions?: Suggestion[];
|
||||||
@@ -69,6 +75,7 @@ const setupMocks = ({
|
|||||||
completionStart: number;
|
completionStart: number;
|
||||||
completionEnd: number;
|
completionEnd: number;
|
||||||
query: string;
|
query: string;
|
||||||
|
activeStart?: number;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
// Mock for @-completions
|
// Mock for @-completions
|
||||||
@@ -116,7 +123,10 @@ const setupMocks = ({
|
|||||||
setSuggestions(shellSuggestions);
|
setSuggestions(shellSuggestions);
|
||||||
}
|
}
|
||||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
}, [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 = (
|
const renderCommandCompletionHook = (
|
||||||
initialText: string,
|
initialText: string,
|
||||||
cursorOffset?: number,
|
cursorOffset?: number,
|
||||||
shellModeActive = false,
|
shellModeActive = false,
|
||||||
active = true,
|
active = true,
|
||||||
) => {
|
) => {
|
||||||
let hookResult: ReturnType<typeof useCommandCompletion> & {
|
const renderResult = renderWithProviders(
|
||||||
textBuffer: ReturnType<typeof useTextBuffer>;
|
<TestComponent
|
||||||
};
|
initialText={initialText}
|
||||||
|
cursorOffset={cursorOffset}
|
||||||
function TestComponent() {
|
shellModeActive={shellModeActive}
|
||||||
const textBuffer = useTextBufferForTest(initialText, cursorOffset);
|
active={active}
|
||||||
const completion = useCommandCompletion({
|
/>,
|
||||||
buffer: textBuffer,
|
);
|
||||||
cwd: testRootDir,
|
|
||||||
slashCommands: [],
|
|
||||||
commandContext: mockCommandContext,
|
|
||||||
reverseSearchActive: false,
|
|
||||||
shellModeActive,
|
|
||||||
config: mockConfig,
|
|
||||||
active,
|
|
||||||
});
|
|
||||||
hookResult = { ...completion, textBuffer };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
renderWithProviders(<TestComponent />);
|
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
get current() {
|
get current() {
|
||||||
return hookResult;
|
return hookResult;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...renderResult,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -524,6 +553,129 @@ describe('useCommandCompletion', () => {
|
|||||||
|
|
||||||
expect(result.current.textBuffer.text).toBe('@src\\components\\');
|
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', () => {
|
describe('prompt completion filtering', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||||
@@ -37,6 +37,9 @@ export interface UseCommandCompletionReturn {
|
|||||||
showSuggestions: boolean;
|
showSuggestions: boolean;
|
||||||
isLoadingSuggestions: boolean;
|
isLoadingSuggestions: boolean;
|
||||||
isPerfectMatch: boolean;
|
isPerfectMatch: boolean;
|
||||||
|
forceShowShellSuggestions: boolean;
|
||||||
|
setForceShowShellSuggestions: (value: boolean) => void;
|
||||||
|
isShellSuggestionsVisible: boolean;
|
||||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
resetCompletionState: () => void;
|
resetCompletionState: () => void;
|
||||||
navigateUp: () => void;
|
navigateUp: () => void;
|
||||||
@@ -80,6 +83,9 @@ export function useCommandCompletion({
|
|||||||
config,
|
config,
|
||||||
active,
|
active,
|
||||||
}: UseCommandCompletionOptions): UseCommandCompletionReturn {
|
}: UseCommandCompletionOptions): UseCommandCompletionReturn {
|
||||||
|
const [forceShowShellSuggestions, setForceShowShellSuggestions] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
suggestions,
|
suggestions,
|
||||||
activeSuggestionIndex,
|
activeSuggestionIndex,
|
||||||
@@ -93,11 +99,16 @@ export function useCommandCompletion({
|
|||||||
setIsPerfectMatch,
|
setIsPerfectMatch,
|
||||||
setVisibleStartIndex,
|
setVisibleStartIndex,
|
||||||
|
|
||||||
resetCompletionState,
|
resetCompletionState: baseResetCompletionState,
|
||||||
navigateUp,
|
navigateUp,
|
||||||
navigateDown,
|
navigateDown,
|
||||||
} = useCompletion();
|
} = useCompletion();
|
||||||
|
|
||||||
|
const resetCompletionState = useCallback(() => {
|
||||||
|
baseResetCompletionState();
|
||||||
|
setForceShowShellSuggestions(false);
|
||||||
|
}, [baseResetCompletionState]);
|
||||||
|
|
||||||
const cursorRow = buffer.cursor[0];
|
const cursorRow = buffer.cursor[0];
|
||||||
const cursorCol = buffer.cursor[1];
|
const cursorCol = buffer.cursor[1];
|
||||||
|
|
||||||
@@ -231,10 +242,73 @@ export function useCommandCompletion({
|
|||||||
? shellCompletionRange.query
|
? shellCompletionRange.query
|
||||||
: memoQuery;
|
: memoQuery;
|
||||||
|
|
||||||
const promptCompletion = usePromptCompletion({
|
const basePromptCompletion = usePromptCompletion({
|
||||||
buffer,
|
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(() => {
|
useEffect(() => {
|
||||||
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
|
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
|
||||||
setVisibleStartIndex(0);
|
setVisibleStartIndex(0);
|
||||||
@@ -271,6 +345,7 @@ export function useCommandCompletion({
|
|||||||
active &&
|
active &&
|
||||||
completionMode !== CompletionMode.IDLE &&
|
completionMode !== CompletionMode.IDLE &&
|
||||||
!reverseSearchActive &&
|
!reverseSearchActive &&
|
||||||
|
isShellSuggestionsVisible &&
|
||||||
(isLoadingSuggestions || suggestions.length > 0);
|
(isLoadingSuggestions || suggestions.length > 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -395,6 +470,9 @@ export function useCommandCompletion({
|
|||||||
showSuggestions,
|
showSuggestions,
|
||||||
isLoadingSuggestions,
|
isLoadingSuggestions,
|
||||||
isPerfectMatch,
|
isPerfectMatch,
|
||||||
|
forceShowShellSuggestions,
|
||||||
|
setForceShowShellSuggestions,
|
||||||
|
isShellSuggestionsVisible,
|
||||||
setActiveSuggestionIndex,
|
setActiveSuggestionIndex,
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
navigateUp,
|
navigateUp,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
@@ -435,6 +435,7 @@ export interface UseShellCompletionReturn {
|
|||||||
completionStart: number;
|
completionStart: number;
|
||||||
completionEnd: number;
|
completionEnd: number;
|
||||||
query: string;
|
query: string;
|
||||||
|
activeStart: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_TOKENS: string[] = [];
|
const EMPTY_TOKENS: string[] = [];
|
||||||
@@ -451,6 +452,7 @@ export function useShellCompletion({
|
|||||||
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
|
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [activeStart, setActiveStart] = useState<number>(-1);
|
||||||
|
|
||||||
const tokenInfo = useMemo(
|
const tokenInfo = useMemo(
|
||||||
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
|
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
|
||||||
@@ -467,6 +469,14 @@ export function useShellCompletion({
|
|||||||
commandToken = '',
|
commandToken = '',
|
||||||
} = tokenInfo || {};
|
} = 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
|
// Invalidate PATH cache when $PATH changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPath = process.env['PATH'] ?? '';
|
const currentPath = process.env['PATH'] ?? '';
|
||||||
@@ -558,6 +568,7 @@ export function useShellCompletion({
|
|||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
setSuggestions(results);
|
setSuggestions(results);
|
||||||
|
setActiveStart(completionStart);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
@@ -571,6 +582,7 @@ export function useShellCompletion({
|
|||||||
}
|
}
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
setActiveStart(completionStart);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
@@ -586,6 +598,7 @@ export function useShellCompletion({
|
|||||||
cursorIndex,
|
cursorIndex,
|
||||||
commandToken,
|
commandToken,
|
||||||
cwd,
|
cwd,
|
||||||
|
completionStart,
|
||||||
setSuggestions,
|
setSuggestions,
|
||||||
setIsLoadingSuggestions,
|
setIsLoadingSuggestions,
|
||||||
]);
|
]);
|
||||||
@@ -594,6 +607,7 @@ export function useShellCompletion({
|
|||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
setActiveStart(-1);
|
||||||
setIsLoadingSuggestions(false);
|
setIsLoadingSuggestions(false);
|
||||||
}
|
}
|
||||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||||
@@ -633,5 +647,6 @@ export function useShellCompletion({
|
|||||||
completionStart,
|
completionStart,
|
||||||
completionEnd,
|
completionEnd,
|
||||||
query,
|
query,
|
||||||
|
activeStart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user