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