fix(cli): Polish shell autocomplete rendering to be a little more shell native feeling. (#20931)

This commit is contained in:
Jacob Richman
2026-03-03 22:52:56 -08:00
committed by GitHub
parent 1017b78157
commit 12957ea16a
5 changed files with 327 additions and 49 deletions

View File

@@ -279,6 +279,9 @@ describe('InputPrompt', () => {
},
getCompletedText: vi.fn().mockReturnValue(null),
completionMode: CompletionMode.IDLE,
forceShowShellSuggestions: false,
setForceShowShellSuggestions: vi.fn(),
isShellSuggestionsVisible: true,
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);

View File

@@ -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();

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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,
};
}