diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 65a4440d77..4a9658f47c 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -279,6 +279,9 @@ describe('InputPrompt', () => { }, getCompletedText: vi.fn().mockReturnValue(null), completionMode: CompletionMode.IDLE, + forceShowShellSuggestions: false, + setForceShowShellSuggestions: vi.fn(), + isShellSuggestionsVisible: true, }; mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e8a01fa716..ad057ca8c2 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -301,6 +301,27 @@ export const InputPrompt: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ focus, buffer, completion, + setForceShowShellSuggestions, shellModeActive, setShellModeActive, onClearScreen, @@ -1221,6 +1256,9 @@ export const InputPrompt: React.FC = ({ registerPlainTabPress, resetPlainTabPress, toggleCleanUiDetailsVisible, + shouldShowSuggestions, + isShellSuggestionsVisible, + forceShowShellSuggestions, ], ); @@ -1346,14 +1384,6 @@ export const InputPrompt: React.FC = ({ ]); 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(); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 8f91013070..bbcddb7d9d 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -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 & { + textBuffer: ReturnType; + }; + + 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 & { - textBuffer: ReturnType; - }; - - 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(); + const renderResult = renderWithProviders( + , + ); 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', () => { diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index f9b772bc93..480ca2c28e 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -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>; 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, diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts index cc73128344..ec50c98ac9 100644 --- a/packages/cli/src/ui/hooks/useShellCompletion.ts +++ b/packages/cli/src/ui/hooks/useShellCompletion.ts @@ -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(process.env['PATH'] ?? ''); const abortRef = useRef(null); const debounceRef = useRef(null); + const [activeStart, setActiveStart] = useState(-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, }; }