diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 56abf21927..92d21a4d29 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -43,6 +43,7 @@ import { StreamingState } from '../types.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; import type { UIState } from '../contexts/UIStateContext.js'; import { isLowColorDepth } from '../utils/terminalUtils.js'; +import { cpLen } from '../utils/textUtils.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -156,14 +157,25 @@ describe('InputPrompt', () => { text: '', cursor: [0, 0], lines: [''], - setText: vi.fn((newText: string) => { - mockBuffer.text = newText; - mockBuffer.lines = [newText]; - mockBuffer.cursor = [0, newText.length]; - mockBuffer.viewportVisualLines = [newText]; - mockBuffer.allVisualLines = [newText]; - mockBuffer.visualToLogicalMap = [[0, 0]]; - }), + setText: vi.fn( + (newText: string, cursorPosition?: 'start' | 'end' | number) => { + mockBuffer.text = newText; + mockBuffer.lines = [newText]; + let col = 0; + if (typeof cursorPosition === 'number') { + col = cursorPosition; + } else if (cursorPosition === 'start') { + col = 0; + } else { + col = newText.length; + } + mockBuffer.cursor = [0, col]; + mockBuffer.viewportVisualLines = [newText]; + mockBuffer.allVisualLines = [newText]; + mockBuffer.visualToLogicalMap = [[0, 0]]; + mockBuffer.visualCursor = [0, col]; + }, + ), replaceRangeByOffset: vi.fn(), viewportVisualLines: [''], allVisualLines: [''], @@ -179,7 +191,15 @@ describe('InputPrompt', () => { } return false; }), - move: vi.fn(), + move: vi.fn((dir: string) => { + if (dir === 'home') { + mockBuffer.visualCursor = [mockBuffer.visualCursor[0], 0]; + } else if (dir === 'end') { + const line = + mockBuffer.allVisualLines[mockBuffer.visualCursor[0]] || ''; + mockBuffer.visualCursor = [mockBuffer.visualCursor[0], cpLen(line)]; + } + }), moveToOffset: vi.fn((offset: number) => { mockBuffer.cursor = [0, offset]; }), @@ -225,7 +245,6 @@ describe('InputPrompt', () => { navigateDown: vi.fn(), resetCompletionState: vi.fn(), setActiveSuggestionIndex: vi.fn(), - setShowSuggestions: vi.fn(), handleAutocomplete: vi.fn(), promptCompletion: { text: '', @@ -381,12 +400,12 @@ describe('InputPrompt', () => { }); await act(async () => { - stdin.write('\u001B[A'); // Up arrow + stdin.write('\u0010'); // Ctrl+P }); await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled()); await act(async () => { - stdin.write('\u001B[B'); // Down arrow + stdin.write('\u000E'); // Ctrl+N }); await waitFor(() => expect(mockInputHistory.navigateDown).toHaveBeenCalled(), @@ -405,6 +424,100 @@ describe('InputPrompt', () => { unmount(); }); + describe('arrow key navigation', () => { + it('should move to start of line on Up arrow if on first line but not at start', async () => { + mockBuffer.allVisualLines = ['line 1', 'line 2']; + mockBuffer.visualCursor = [0, 5]; // First line, not at start + mockBuffer.visualScrollRow = 0; + + const { stdin, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\u001B[A'); // Up arrow + }); + + await waitFor(() => { + expect(mockBuffer.move).toHaveBeenCalledWith('home'); + expect(mockInputHistory.navigateUp).not.toHaveBeenCalled(); + }); + unmount(); + }); + + it('should navigate history on Up arrow if on first line and at start', async () => { + mockBuffer.allVisualLines = ['line 1', 'line 2']; + mockBuffer.visualCursor = [0, 0]; // First line, at start + mockBuffer.visualScrollRow = 0; + + const { stdin, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\u001B[A'); // Up arrow + }); + + await waitFor(() => { + expect(mockBuffer.move).not.toHaveBeenCalledWith('home'); + expect(mockInputHistory.navigateUp).toHaveBeenCalled(); + }); + unmount(); + }); + + it('should move to end of line on Down arrow if on last line but not at end', async () => { + mockBuffer.allVisualLines = ['line 1', 'line 2']; + mockBuffer.visualCursor = [1, 0]; // Last line, not at end + mockBuffer.visualScrollRow = 0; + + const { stdin, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + + await waitFor(() => { + expect(mockBuffer.move).toHaveBeenCalledWith('end'); + expect(mockInputHistory.navigateDown).not.toHaveBeenCalled(); + }); + unmount(); + }); + + it('should navigate history on Down arrow if on last line and at end', async () => { + mockBuffer.allVisualLines = ['line 1', 'line 2']; + mockBuffer.visualCursor = [1, 6]; // Last line, at end ("line 2" is length 6) + mockBuffer.visualScrollRow = 0; + + const { stdin, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + + await waitFor(() => { + expect(mockBuffer.move).not.toHaveBeenCalledWith('end'); + expect(mockInputHistory.navigateDown).toHaveBeenCalled(); + }); + unmount(); + }); + }); + it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, @@ -485,11 +598,11 @@ describe('InputPrompt', () => { }); await act(async () => { - stdin.write('\u001B[A'); // Up arrow + stdin.write('\u0010'); // Ctrl+P }); await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled()); await act(async () => { - stdin.write('\u001B[B'); // Down arrow + stdin.write('\u000E'); // Ctrl+N }); await waitFor(() => expect(mockInputHistory.navigateDown).toHaveBeenCalled(), @@ -934,6 +1047,33 @@ describe('InputPrompt', () => { unmount(); }); + it('should NOT submit on Enter when an @-path is a perfect match', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [{ label: 'file.txt', value: 'file.txt' }], + activeSuggestionIndex: 0, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + // Should handle autocomplete but NOT submit + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(props.onSubmit).not.toHaveBeenCalled(); + }); + unmount(); + }); + it('should auto-execute commands with autoExecute: true on Enter', async () => { const aboutCommand: SlashCommand = { name: 'about', @@ -1625,15 +1765,16 @@ describe('InputPrompt', () => { }); await waitFor(() => { - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); + expect(mockedUseCommandCompletion).toHaveBeenCalledWith({ + buffer: mockBuffer, + cwd: path.join('test', 'project', 'src'), + slashCommands: mockSlashCommands, + commandContext: mockCommandContext, + reverseSearchActive: false, + shellModeActive: false, + config: expect.any(Object), + active: expect.anything(), + }); }); unmount(); @@ -3685,6 +3826,208 @@ describe('InputPrompt', () => { unmount(); }); }); + describe('History Navigation and Completion Suppression', () => { + beforeEach(() => { + props.userMessages = ['first message', 'second message']; + // Mock useInputHistory to actually call onChange + mockedUseInputHistory.mockImplementation(({ onChange }) => ({ + navigateUp: () => { + onChange('second message', 'start'); + return true; + }, + navigateDown: () => { + onChange('first message', 'end'); + return true; + }, + handleSubmit: vi.fn(), + })); + }); + + it.each([ + { name: 'Up arrow', key: '\u001B[A', position: 'start' }, + { name: 'Ctrl+P', key: '\u0010', position: 'start' }, + ])( + 'should move cursor to $position on $name (older history)', + async ({ key, position }) => { + const { stdin } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + stdin.write(key); + }); + + await waitFor(() => { + expect(mockBuffer.setText).toHaveBeenCalledWith( + 'second message', + position as 'start' | 'end', + ); + }); + }, + ); + + it.each([ + { name: 'Down arrow', key: '\u001B[B', position: 'end' }, + { name: 'Ctrl+N', key: '\u000E', position: 'end' }, + ])( + 'should move cursor to $position on $name (newer history)', + async ({ key, position }) => { + const { stdin } = renderWithProviders(, { + uiActions, + }); + + // First go up + await act(async () => { + stdin.write('\u001B[A'); + }); + + // Then go down + await act(async () => { + stdin.write(key); + if (key === '\u001B[B') { + // Second press to actually navigate history + stdin.write(key); + } + }); + + await waitFor(() => { + expect(mockBuffer.setText).toHaveBeenCalledWith( + 'first message', + position as 'start' | 'end', + ); + }); + }, + ); + + it('should suppress completion after history navigation', async () => { + const { stdin } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + stdin.write('\u001B[A'); // Up arrow + }); + + await waitFor(() => { + expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({ + buffer: mockBuffer, + cwd: expect.anything(), + slashCommands: expect.anything(), + commandContext: expect.anything(), + reverseSearchActive: expect.anything(), + shellModeActive: expect.anything(), + config: expect.anything(), + active: false, + }); + }); + }); + + it('should not render suggestions during history navigation', async () => { + // 1. Set up a dynamic mock implementation BEFORE rendering + mockedUseCommandCompletion.mockImplementation(({ active }) => ({ + ...mockCommandCompletion, + showSuggestions: active, + suggestions: active + ? [{ value: 'suggestion', label: 'suggestion' }] + : [], + })); + + const { stdout, stdin, unmount } = renderWithProviders( + , + { uiActions }, + ); + + // 2. Verify suggestions ARE showing initially because active is true by default + await waitFor(() => { + expect(stdout.lastFrame()).toContain('suggestion'); + }); + + // 3. Trigger history navigation which should set suppressCompletion to true + await act(async () => { + stdin.write('\u001B[A'); + }); + + // 4. Verify that suggestions are NOT in the output frame after navigation + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('suggestion'); + }); + + expect(stdout.lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should continue to suppress completion after manual cursor movement', async () => { + const { stdin } = renderWithProviders(, { + uiActions, + }); + + // Navigate history (suppresses) + await act(async () => { + stdin.write('\u001B[A'); + }); + + // Wait for it to be suppressed + await waitFor(() => { + expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({ + buffer: mockBuffer, + cwd: expect.anything(), + slashCommands: expect.anything(), + commandContext: expect.anything(), + reverseSearchActive: expect.anything(), + shellModeActive: expect.anything(), + config: expect.anything(), + active: false, + }); + }); + + // Move cursor manually + await act(async () => { + stdin.write('\u001B[D'); // Left arrow + }); + + await waitFor(() => { + expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({ + buffer: mockBuffer, + cwd: expect.anything(), + slashCommands: expect.anything(), + commandContext: expect.anything(), + reverseSearchActive: expect.anything(), + shellModeActive: expect.anything(), + config: expect.anything(), + active: false, + }); + }); + }); + + it('should re-enable completion after typing', async () => { + const { stdin } = renderWithProviders(, { + uiActions, + }); + + // Navigate history (suppresses) + await act(async () => { + stdin.write('\u001B[A'); + }); + + // Wait for it to be suppressed + await waitFor(() => { + expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ active: false }), + ); + }); + + // Type a character + await act(async () => { + stdin.write('a'); + }); + + await waitFor(() => { + expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ active: true }), + ); + }); + }); + }); }); function clean(str: string | undefined): string { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 151c5e14b8..a93cd5287e 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -160,7 +160,7 @@ export const InputPrompt: React.FC = ({ backgroundShells, backgroundShellHeight, } = useUIState(); - const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); + const [suppressCompletion, setSuppressCompletion] = useState(false); const escPressCount = useRef(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const escapeTimerRef = useRef(null); @@ -181,15 +181,16 @@ export const InputPrompt: React.FC = ({ const shellHistory = useShellHistory(config.getProjectRoot()); const shellHistoryData = shellHistory.history; - const completion = useCommandCompletion( + const completion = useCommandCompletion({ buffer, - config.getTargetDir(), + cwd: config.getTargetDir(), slashCommands, commandContext, reverseSearchActive, shellModeActive, config, - ); + active: !suppressCompletion, + }); const reverseSearchCompletion = useReverseSearchCompletion( buffer, @@ -302,11 +303,11 @@ export const InputPrompt: React.FC = ({ ); const customSetTextAndResetCompletionSignal = useCallback( - (newText: string) => { - buffer.setText(newText); - setJustNavigatedHistory(true); + (newText: string, cursorPosition?: 'start' | 'end' | number) => { + buffer.setText(newText, cursorPosition); + setSuppressCompletion(true); }, - [buffer, setJustNavigatedHistory], + [buffer, setSuppressCompletion], ); const inputHistory = useInputHistory({ @@ -316,25 +317,26 @@ export const InputPrompt: React.FC = ({ (!completion.showSuggestions || completion.suggestions.length === 1) && !shellModeActive, currentQuery: buffer.text, + currentCursorOffset: buffer.getOffset(), onChange: customSetTextAndResetCompletionSignal, }); // Effect to reset completion if history navigation just occurred and set the text useEffect(() => { - if (justNavigatedHistory) { + if (suppressCompletion) { resetCompletionState(); resetReverseSearchCompletionState(); resetCommandSearchCompletionState(); setExpandedSuggestionIndex(-1); - setJustNavigatedHistory(false); } }, [ - justNavigatedHistory, + suppressCompletion, buffer.text, resetCompletionState, - setJustNavigatedHistory, + setSuppressCompletion, resetReverseSearchCompletionState, resetCommandSearchCompletionState, + setExpandedSuggestionIndex, ]); // Helper function to handle loading queued messages into input @@ -405,6 +407,7 @@ export const InputPrompt: React.FC = ({ useMouseClick( innerBoxRef, (_event, relX, relY) => { + setSuppressCompletion(true); if (isEmbeddedShellFocused) { setEmbeddedShellFocused(false); } @@ -470,6 +473,7 @@ export const InputPrompt: React.FC = ({ useMouse( (event: MouseEvent) => { if (event.name === 'right-release') { + setSuppressCompletion(false); // eslint-disable-next-line @typescript-eslint/no-floating-promises handleClipboardPaste(); } @@ -479,6 +483,50 @@ export const InputPrompt: React.FC = ({ const handleInput = useCallback( (key: Key) => { + // Determine if this keypress is a history navigation command + const isHistoryUp = + !shellModeActive && + (keyMatchers[Command.HISTORY_UP](key) || + (keyMatchers[Command.NAVIGATION_UP](key) && + (buffer.allVisualLines.length === 1 || + (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)))); + const isHistoryDown = + !shellModeActive && + (keyMatchers[Command.HISTORY_DOWN](key) || + (keyMatchers[Command.NAVIGATION_DOWN](key) && + (buffer.allVisualLines.length === 1 || + buffer.visualCursor[0] === buffer.allVisualLines.length - 1))); + + const isHistoryNav = isHistoryUp || isHistoryDown; + const isCursorMovement = + keyMatchers[Command.MOVE_LEFT](key) || + keyMatchers[Command.MOVE_RIGHT](key) || + keyMatchers[Command.MOVE_UP](key) || + keyMatchers[Command.MOVE_DOWN](key) || + keyMatchers[Command.MOVE_WORD_LEFT](key) || + keyMatchers[Command.MOVE_WORD_RIGHT](key) || + keyMatchers[Command.HOME](key) || + keyMatchers[Command.END](key); + + const isSuggestionsNav = + (completion.showSuggestions || + reverseSearchCompletion.showSuggestions || + commandSearchCompletion.showSuggestions) && + (keyMatchers[Command.COMPLETION_UP](key) || + keyMatchers[Command.COMPLETION_DOWN](key) || + keyMatchers[Command.EXPAND_SUGGESTION](key) || + keyMatchers[Command.COLLAPSE_SUGGESTION](key) || + keyMatchers[Command.ACCEPT_SUGGESTION](key)); + + // Reset completion suppression if the user performs any action other than + // history navigation or cursor movement. + // We explicitly skip this if we are currently navigating suggestions. + if (!isSuggestionsNav) { + setSuppressCompletion( + isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key), + ); + } + // TODO(jacobr): this special case is likely not needed anymore. // We should probably stop supporting paste if the InputPrompt is not // focused. @@ -702,6 +750,7 @@ export const InputPrompt: React.FC = ({ // We prioritize execution unless the user is explicitly selecting a different suggestion. if ( completion.isPerfectMatch && + completion.completionMode !== CompletionMode.AT && keyMatchers[Command.RETURN](key) && (!completion.showSuggestions || completion.activeSuggestionIndex <= 0) ) { @@ -801,7 +850,14 @@ export const InputPrompt: React.FC = ({ return true; } - if (keyMatchers[Command.HISTORY_UP](key)) { + if (isHistoryUp) { + if ( + keyMatchers[Command.NAVIGATION_UP](key) && + buffer.visualCursor[1] > 0 + ) { + buffer.move('home'); + return true; + } // Check for queued messages first when input is empty // If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages if (tryLoadQueuedMessages()) { @@ -811,41 +867,43 @@ export const InputPrompt: React.FC = ({ inputHistory.navigateUp(); return true; } - if (keyMatchers[Command.HISTORY_DOWN](key)) { - inputHistory.navigateDown(); - return true; - } - // Handle arrow-up/down for history on single-line or at edges - if ( - keyMatchers[Command.NAVIGATION_UP](key) && - (buffer.allVisualLines.length === 1 || - (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) - ) { - // Check for queued messages first when input is empty - // If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages - if (tryLoadQueuedMessages()) { + if (isHistoryDown) { + if ( + keyMatchers[Command.NAVIGATION_DOWN](key) && + buffer.visualCursor[1] < + cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '') + ) { + buffer.move('end'); return true; } - // Only navigate history if popAllMessages doesn't exist - inputHistory.navigateUp(); - return true; - } - if ( - keyMatchers[Command.NAVIGATION_DOWN](key) && - (buffer.allVisualLines.length === 1 || - buffer.visualCursor[0] === buffer.allVisualLines.length - 1) - ) { inputHistory.navigateDown(); return true; } } else { // Shell History Navigation if (keyMatchers[Command.NAVIGATION_UP](key)) { + if ( + (buffer.allVisualLines.length === 1 || + (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) && + buffer.visualCursor[1] > 0 + ) { + buffer.move('home'); + return true; + } const prevCommand = shellHistory.getPreviousCommand(); if (prevCommand !== null) buffer.setText(prevCommand); return true; } if (keyMatchers[Command.NAVIGATION_DOWN](key)) { + if ( + (buffer.allVisualLines.length === 1 || + buffer.visualCursor[0] === buffer.allVisualLines.length - 1) && + buffer.visualCursor[1] < + cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '') + ) { + buffer.move('end'); + return true; + } const nextCommand = shellHistory.getNextCommand(); if (nextCommand !== null) buffer.setText(nextCommand); return true; diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 60c8889f36..ff3818d6f8 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -1,5 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > second message +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" +`; + exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ (r:) Type your message or @path/to/file diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index d32480fc5b..d217cce759 100644 --- a/packages/cli/src/ui/components/shared/TextInput.test.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -44,10 +44,16 @@ vi.mock('./text-buffer.js', () => { ); } }), - setText: vi.fn((newText) => { + setText: vi.fn((newText, cursorPosition) => { mockTextBuffer.text = newText; mockTextBuffer.viewportVisualLines = [newText]; - mockTextBuffer.visualCursor[1] = newText.length; + if (typeof cursorPosition === 'number') { + mockTextBuffer.visualCursor[1] = cursorPosition; + } else if (cursorPosition === 'start') { + mockTextBuffer.visualCursor[1] = 0; + } else { + mockTextBuffer.visualCursor[1] = newText.length; + } }), }; @@ -92,10 +98,16 @@ describe('TextInput', () => { ); } }), - setText: vi.fn((newText) => { + setText: vi.fn((newText, cursorPosition) => { buffer.text = newText; buffer.viewportVisualLines = [newText]; - buffer.visualCursor[1] = newText.length; + if (typeof cursorPosition === 'number') { + buffer.visualCursor[1] = cursorPosition; + } else if (cursorPosition === 'start') { + buffer.visualCursor[1] = 0; + } else { + buffer.visualCursor[1] = newText.length; + } }), }; mockBuffer = buffer as unknown as TextBuffer; diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 1264f7eae9..ecc7e473e3 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1596,8 +1596,13 @@ function generatePastedTextId( } export type TextBufferAction = - | { type: 'set_text'; payload: string; pushToUndo?: boolean } | { type: 'insert'; payload: string; isPaste?: boolean } + | { + type: 'set_text'; + payload: string; + pushToUndo?: boolean; + cursorPosition?: 'start' | 'end' | number; + } | { type: 'add_pasted_content'; payload: { id: string; text: string } } | { type: 'backspace' } | { @@ -1709,12 +1714,29 @@ function textBufferReducerLogic( .replace(/\r\n?/g, '\n') .split('\n'); const lines = newContentLines.length === 0 ? [''] : newContentLines; - const lastNewLineIndex = lines.length - 1; + + let newCursorRow: number; + let newCursorCol: number; + + if (typeof action.cursorPosition === 'number') { + [newCursorRow, newCursorCol] = offsetToLogicalPos( + action.payload, + action.cursorPosition, + ); + } else if (action.cursorPosition === 'start') { + newCursorRow = 0; + newCursorCol = 0; + } else { + // Default to 'end' + newCursorRow = lines.length - 1; + newCursorCol = cpLen(lines[newCursorRow] ?? ''); + } + return { ...nextState, lines, - cursorRow: lastNewLineIndex, - cursorCol: cpLen(lines[lastNewLineIndex] ?? ''), + cursorRow: newCursorRow, + cursorCol: newCursorCol, preferredCol: null, pastedContent: action.payload === '' ? {} : nextState.pastedContent, }; @@ -2838,9 +2860,12 @@ export function useTextBuffer({ dispatch({ type: 'redo' }); }, []); - const setText = useCallback((newText: string): void => { - dispatch({ type: 'set_text', payload: newText }); - }, []); + const setText = useCallback( + (newText: string, cursorPosition?: 'start' | 'end' | number): void => { + dispatch({ type: 'set_text', payload: newText, cursorPosition }); + }, + [], + ); const deleteWordLeft = useCallback((): void => { dispatch({ type: 'delete_word_left' }); @@ -3638,7 +3663,7 @@ export interface TextBuffer { * Replaces the entire buffer content with the provided text. * The operation is undoable. */ - setText: (text: string) => void; + setText: (text: string, cursorPosition?: 'start' | 'end' | number) => void; /** * Insert a single character or string without newlines. */ diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index e023de786f..204d9d108f 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -114,6 +114,7 @@ describe('useCommandCompletion', () => { initialText: string, cursorOffset?: number, shellModeActive = false, + active = true, ) => { let hookResult: ReturnType & { textBuffer: ReturnType; @@ -121,15 +122,16 @@ describe('useCommandCompletion', () => { function TestComponent() { const textBuffer = useTextBufferForTest(initialText, cursorOffset); - const completion = useCommandCompletion( - textBuffer, - testRootDir, - [], - mockCommandContext, - false, + const completion = useCommandCompletion({ + buffer: textBuffer, + cwd: testRootDir, + slashCommands: [], + commandContext: mockCommandContext, + reverseSearchActive: false, shellModeActive, - mockConfig, - ); + config: mockConfig, + active, + }); hookResult = { ...completion, textBuffer }; return null; } @@ -197,7 +199,6 @@ describe('useCommandCompletion', () => { act(() => { result.current.setActiveSuggestionIndex(5); - result.current.setShowSuggestions(true); }); act(() => { @@ -509,22 +510,25 @@ describe('useCommandCompletion', () => { function TestComponent() { const textBuffer = useTextBufferForTest('// This is a line comment'); - const completion = useCommandCompletion( - textBuffer, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); + const completion = useCommandCompletion({ + buffer: textBuffer, + cwd: testRootDir, + slashCommands: [], + commandContext: mockCommandContext, + reverseSearchActive: false, + shellModeActive: false, + config: mockConfig, + active: true, + }); hookResult = { ...completion, textBuffer }; return null; } renderWithProviders(); // Should not trigger prompt completion for comments - expect(hookResult!.suggestions.length).toBe(0); + await waitFor(() => { + expect(hookResult!.suggestions.length).toBe(0); + }); }); it('should not trigger prompt completion for block comments', async () => { @@ -541,22 +545,25 @@ describe('useCommandCompletion', () => { const textBuffer = useTextBufferForTest( '/* This is a block comment */', ); - const completion = useCommandCompletion( - textBuffer, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); + const completion = useCommandCompletion({ + buffer: textBuffer, + cwd: testRootDir, + slashCommands: [], + commandContext: mockCommandContext, + reverseSearchActive: false, + shellModeActive: false, + config: mockConfig, + active: true, + }); hookResult = { ...completion, textBuffer }; return null; } renderWithProviders(); // Should not trigger prompt completion for comments - expect(hookResult!.suggestions.length).toBe(0); + await waitFor(() => { + expect(hookResult!.suggestions.length).toBe(0); + }); }); it('should trigger prompt completion for regular text when enabled', async () => { @@ -573,24 +580,27 @@ describe('useCommandCompletion', () => { const textBuffer = useTextBufferForTest( 'This is regular text that should trigger completion', ); - const completion = useCommandCompletion( - textBuffer, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); + const completion = useCommandCompletion({ + buffer: textBuffer, + cwd: testRootDir, + slashCommands: [], + commandContext: mockCommandContext, + reverseSearchActive: false, + shellModeActive: false, + config: mockConfig, + active: true, + }); hookResult = { ...completion, textBuffer }; return null; } renderWithProviders(); // This test verifies that comments are filtered out while regular text is not - expect(hookResult!.textBuffer.text).toBe( - 'This is regular text that should trigger completion', - ); + await waitFor(() => { + expect(hookResult!.textBuffer.text).toBe( + 'This is regular text that should trigger completion', + ); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index b5f3264ee7..5ae009d5a2 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -36,7 +36,6 @@ export interface UseCommandCompletionReturn { isLoadingSuggestions: boolean; isPerfectMatch: boolean; setActiveSuggestionIndex: React.Dispatch>; - setShowSuggestions: React.Dispatch>; resetCompletionState: () => void; navigateUp: () => void; navigateDown: () => void; @@ -58,25 +57,35 @@ export interface UseCommandCompletionReturn { completionMode: CompletionMode; } -export function useCommandCompletion( - buffer: TextBuffer, - cwd: string, - slashCommands: readonly SlashCommand[], - commandContext: CommandContext, - reverseSearchActive: boolean = false, - shellModeActive: boolean, - config?: Config, -): UseCommandCompletionReturn { +export interface UseCommandCompletionOptions { + buffer: TextBuffer; + cwd: string; + slashCommands: readonly SlashCommand[]; + commandContext: CommandContext; + reverseSearchActive?: boolean; + shellModeActive: boolean; + config?: Config; + active: boolean; +} + +export function useCommandCompletion({ + buffer, + cwd, + slashCommands, + commandContext, + reverseSearchActive = false, + shellModeActive, + config, + active, +}: UseCommandCompletionOptions): UseCommandCompletionReturn { const { suggestions, activeSuggestionIndex, visibleStartIndex, - showSuggestions, isLoadingSuggestions, isPerfectMatch, setSuggestions, - setShowSuggestions, setActiveSuggestionIndex, setIsLoadingSuggestions, setIsPerfectMatch, @@ -173,7 +182,7 @@ export function useCommandCompletion( }, [cursorRow, cursorCol, buffer.lines, buffer.text, config]); useAtCompletion({ - enabled: completionMode === CompletionMode.AT, + enabled: active && completionMode === CompletionMode.AT, pattern: query || '', config, cwd, @@ -182,7 +191,8 @@ export function useCommandCompletion( }); const slashCompletionRange = useSlashCompletion({ - enabled: completionMode === CompletionMode.SLASH && !shellModeActive, + enabled: + active && completionMode === CompletionMode.SLASH && !shellModeActive, query, slashCommands, commandContext, @@ -194,29 +204,46 @@ export function useCommandCompletion( const promptCompletion = usePromptCompletion({ buffer, config, - enabled: completionMode === CompletionMode.PROMPT, + enabled: active && completionMode === CompletionMode.PROMPT, }); useEffect(() => { setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); setVisibleStartIndex(0); - }, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]); + + // Generic perfect match detection for non-slash modes or as a fallback + if (completionMode !== CompletionMode.SLASH) { + if (suggestions.length > 0) { + const firstSuggestion = suggestions[0]; + setIsPerfectMatch(firstSuggestion.value === query); + } else { + setIsPerfectMatch(false); + } + } + }, [ + suggestions, + setActiveSuggestionIndex, + setVisibleStartIndex, + completionMode, + query, + setIsPerfectMatch, + ]); useEffect(() => { - if (completionMode === CompletionMode.IDLE || reverseSearchActive) { + if ( + !active || + completionMode === CompletionMode.IDLE || + reverseSearchActive + ) { resetCompletionState(); - return; } - // Show suggestions if we are loading OR if there are results to display. - setShowSuggestions(isLoadingSuggestions || suggestions.length > 0); - }, [ - completionMode, - suggestions.length, - isLoadingSuggestions, - reverseSearchActive, - resetCompletionState, - setShowSuggestions, - ]); + }, [active, completionMode, reverseSearchActive, resetCompletionState]); + + const showSuggestions = + active && + completionMode !== CompletionMode.IDLE && + !reverseSearchActive && + (isLoadingSuggestions || suggestions.length > 0); /** * Gets the completed text by replacing the completion range with the suggestion value. @@ -333,7 +360,6 @@ export function useCommandCompletion( isLoadingSuggestions, isPerfectMatch, setActiveSuggestionIndex, - setShowSuggestions, resetCompletionState, navigateUp, navigateDown, diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 8d3d4c2f37..1483564691 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -13,7 +13,6 @@ export interface UseCompletionReturn { suggestions: Suggestion[]; activeSuggestionIndex: number; visibleStartIndex: number; - showSuggestions: boolean; isLoadingSuggestions: boolean; isPerfectMatch: boolean; setSuggestions: React.Dispatch>; @@ -21,7 +20,6 @@ export interface UseCompletionReturn { setVisibleStartIndex: React.Dispatch>; setIsLoadingSuggestions: React.Dispatch>; setIsPerfectMatch: React.Dispatch>; - setShowSuggestions: React.Dispatch>; resetCompletionState: () => void; navigateUp: () => void; navigateDown: () => void; @@ -32,7 +30,6 @@ export function useCompletion(): UseCompletionReturn { const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); const [visibleStartIndex, setVisibleStartIndex] = useState(0); - const [showSuggestions, setShowSuggestions] = useState(false); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const [isPerfectMatch, setIsPerfectMatch] = useState(false); @@ -41,7 +38,6 @@ export function useCompletion(): UseCompletionReturn { setSuggestions([]); setActiveSuggestionIndex(-1); setVisibleStartIndex(0); - setShowSuggestions(false); setIsLoadingSuggestions(false); setIsPerfectMatch(false); }, []); @@ -108,12 +104,10 @@ export function useCompletion(): UseCompletionReturn { suggestions, activeSuggestionIndex, visibleStartIndex, - showSuggestions, isLoadingSuggestions, isPerfectMatch, setSuggestions, - setShowSuggestions, setActiveSuggestionIndex, setVisibleStartIndex, setIsLoadingSuggestions, diff --git a/packages/cli/src/ui/hooks/useInputHistory.test.ts b/packages/cli/src/ui/hooks/useInputHistory.test.ts index 6d0d7fad2f..e9a985484a 100644 --- a/packages/cli/src/ui/hooks/useInputHistory.test.ts +++ b/packages/cli/src/ui/hooks/useInputHistory.test.ts @@ -25,6 +25,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, currentQuery: '', + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -45,6 +46,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, currentQuery: ' test query ', + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -68,6 +70,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, currentQuery: '', + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -88,6 +91,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: false, currentQuery: 'current', + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -105,6 +109,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, currentQuery: 'current', + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -123,6 +128,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, currentQuery, + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -131,17 +137,19 @@ describe('useInputHistory', () => { result.current.navigateUp(); }); - expect(mockOnChange).toHaveBeenCalledWith(userMessages[2]); // Last message + expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start'); // Last message }); - it('should store currentQuery as originalQueryBeforeNav on first navigateUp', () => { + it('should store currentQuery and currentCursorOffset as original state on first navigateUp', () => { const currentQuery = 'original user input'; + const currentCursorOffset = 5; const { result } = renderHook(() => useInputHistory({ userMessages, onSubmit: mockOnSubmit, isActive: true, currentQuery, + currentCursorOffset, onChange: mockOnChange, }), ); @@ -149,13 +157,16 @@ describe('useInputHistory', () => { act(() => { result.current.navigateUp(); // historyIndex becomes 0 }); - expect(mockOnChange).toHaveBeenCalledWith(userMessages[2]); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start'); - // Navigate down to restore original query + // Navigate down to restore original query and cursor position act(() => { result.current.navigateDown(); // historyIndex becomes -1 }); - expect(mockOnChange).toHaveBeenCalledWith(currentQuery); + expect(mockOnChange).toHaveBeenCalledWith( + currentQuery, + currentCursorOffset, + ); }); it('should navigate through history messages on subsequent navigateUp calls', () => { @@ -165,6 +176,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, currentQuery: '', + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -172,17 +184,17 @@ describe('useInputHistory', () => { act(() => { result.current.navigateUp(); // Navigates to 'message 3' }); - expect(mockOnChange).toHaveBeenCalledWith(userMessages[2]); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start'); act(() => { result.current.navigateUp(); // Navigates to 'message 2' }); - expect(mockOnChange).toHaveBeenCalledWith(userMessages[1]); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'start'); act(() => { result.current.navigateUp(); // Navigates to 'message 1' }); - expect(mockOnChange).toHaveBeenCalledWith(userMessages[0]); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[0], 'start'); }); }); @@ -193,6 +205,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, // Start active to allow setup navigation currentQuery: 'current', + currentCursorOffset: 0, onChange: mockOnChange, }; const { result, rerender } = renderHook( @@ -225,6 +238,7 @@ describe('useInputHistory', () => { onSubmit: mockOnSubmit, isActive: true, currentQuery: 'current', + currentCursorOffset: 0, onChange: mockOnChange, }), ); @@ -235,28 +249,235 @@ describe('useInputHistory', () => { expect(mockOnChange).not.toHaveBeenCalled(); }); - it('should restore originalQueryBeforeNav when navigating down to initial state', () => { + it('should restore cursor offset only when in middle of compose prompt', () => { const originalQuery = 'my original input'; + const originalCursorOffset = 5; // Middle const { result } = renderHook(() => useInputHistory({ userMessages, onSubmit: mockOnSubmit, isActive: true, currentQuery: originalQuery, + currentCursorOffset: originalCursorOffset, onChange: mockOnChange, }), ); act(() => { - result.current.navigateUp(); // Navigates to 'message 3', stores 'originalQuery' + result.current.navigateUp(); }); - expect(mockOnChange).toHaveBeenCalledWith(userMessages[2]); mockOnChange.mockClear(); act(() => { - result.current.navigateDown(); // Navigates back to original query + result.current.navigateDown(); }); - expect(mockOnChange).toHaveBeenCalledWith(originalQuery); + // Should restore middle offset + expect(mockOnChange).toHaveBeenCalledWith( + originalQuery, + originalCursorOffset, + ); + }); + + it('should NOT restore cursor offset if it was at start or end of compose prompt', () => { + const originalQuery = 'my original input'; + const { result, rerender } = renderHook( + (props) => useInputHistory(props), + { + initialProps: { + userMessages, + onSubmit: mockOnSubmit, + isActive: true, + currentQuery: originalQuery, + currentCursorOffset: 0, // Start + onChange: mockOnChange, + }, + }, + ); + + // Case 1: Start + act(() => { + result.current.navigateUp(); + }); + mockOnChange.mockClear(); + act(() => { + result.current.navigateDown(); + }); + // Should use 'end' default instead of 0 + expect(mockOnChange).toHaveBeenCalledWith(originalQuery, 'end'); + + // Case 2: End + rerender({ + userMessages, + onSubmit: mockOnSubmit, + isActive: true, + currentQuery: originalQuery, + currentCursorOffset: originalQuery.length, // End + onChange: mockOnChange, + }); + act(() => { + result.current.navigateUp(); + }); + mockOnChange.mockClear(); + act(() => { + result.current.navigateDown(); + }); + // Should use 'end' default + expect(mockOnChange).toHaveBeenCalledWith(originalQuery, 'end'); + }); + + it('should remember text edits but use default cursor when navigating between history items', () => { + const originalQuery = 'my original input'; + const originalCursorOffset = 5; + const { result, rerender } = renderHook( + (props) => useInputHistory(props), + { + initialProps: { + userMessages, + onSubmit: mockOnSubmit, + isActive: true, + currentQuery: originalQuery, + currentCursorOffset: originalCursorOffset, + onChange: mockOnChange, + }, + }, + ); + + // 1. Navigate UP from compose prompt (-1 -> 0) + act(() => { + result.current.navigateUp(); + }); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start'); + mockOnChange.mockClear(); + + // Simulate being at History[0] ('message 3') and editing it + const editedHistoryText = 'message 3 edited'; + const editedHistoryOffset = 5; + rerender({ + userMessages, + onSubmit: mockOnSubmit, + isActive: true, + currentQuery: editedHistoryText, + currentCursorOffset: editedHistoryOffset, + onChange: mockOnChange, + }); + + // 2. Navigate UP to next history item (0 -> 1) + act(() => { + result.current.navigateUp(); + }); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'start'); + mockOnChange.mockClear(); + + // 3. Navigate DOWN back to History[0] (1 -> 0) + act(() => { + result.current.navigateDown(); + }); + // Should restore edited text AND the offset because we just came from History[0] + expect(mockOnChange).toHaveBeenCalledWith( + editedHistoryText, + editedHistoryOffset, + ); + mockOnChange.mockClear(); + + // Simulate being at History[0] (restored) and navigating DOWN to compose prompt (0 -> -1) + rerender({ + userMessages, + onSubmit: mockOnSubmit, + isActive: true, + currentQuery: editedHistoryText, + currentCursorOffset: editedHistoryOffset, + onChange: mockOnChange, + }); + + // 4. Navigate DOWN to compose prompt + act(() => { + result.current.navigateDown(); + }); + // Level -1 should ALWAYS restore its offset if it was in the middle + expect(mockOnChange).toHaveBeenCalledWith( + originalQuery, + originalCursorOffset, + ); + }); + + it('should restore offset for history items ONLY if returning from them immediately', () => { + const originalQuery = 'my original input'; + const initialProps = { + userMessages, + onSubmit: mockOnSubmit, + isActive: true, + currentQuery: originalQuery, + currentCursorOffset: 5, + onChange: mockOnChange, + }; + + const { result, rerender } = renderHook( + (props) => useInputHistory(props), + { + initialProps, + }, + ); + + // -1 -> 0 ('message 3') + act(() => { + result.current.navigateUp(); + }); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start'); + const historyOffset = 4; + // Manually update props to reflect current level + rerender({ + ...initialProps, + currentQuery: userMessages[2], + currentCursorOffset: historyOffset, + }); + + // 0 -> 1 ('message 2') + act(() => { + result.current.navigateUp(); + }); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'start'); + rerender({ + ...initialProps, + currentQuery: userMessages[1], + currentCursorOffset: 0, + }); + + // 1 -> 2 ('message 1') + act(() => { + result.current.navigateUp(); + }); + expect(mockOnChange).toHaveBeenCalledWith(userMessages[0], 'start'); + rerender({ + ...initialProps, + currentQuery: userMessages[0], + currentCursorOffset: 0, + }); + + mockOnChange.mockClear(); + + // 2 -> 1 ('message 2') + act(() => { + result.current.navigateDown(); + }); + // 2 -> 1 is immediate back-and-forth. + // But Level 1 offset was 0 (not in middle), so use 'end' default. + expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'end'); + mockOnChange.mockClear(); + + // Rerender to reflect Level 1 state + rerender({ + ...initialProps, + currentQuery: userMessages[1], + currentCursorOffset: userMessages[1].length, + }); + + // 1 -> 0 ('message 3') + act(() => { + result.current.navigateDown(); + }); + // 1 -> 0 is NOT immediate (Level 2 was the last jump point). + // So Level 0 SHOULD use default 'end' even though it has a middle offset saved. + expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'end'); }); }); }); diff --git a/packages/cli/src/ui/hooks/useInputHistory.ts b/packages/cli/src/ui/hooks/useInputHistory.ts index 58fc9d4a6c..c9c7f7edb4 100644 --- a/packages/cli/src/ui/hooks/useInputHistory.ts +++ b/packages/cli/src/ui/hooks/useInputHistory.ts @@ -4,14 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; +import { cpLen } from '../utils/textUtils.js'; interface UseInputHistoryProps { userMessages: readonly string[]; onSubmit: (value: string) => void; isActive: boolean; currentQuery: string; // Renamed from query to avoid confusion - onChange: (value: string) => void; + currentCursorOffset: number; + onChange: (value: string, cursorPosition?: 'start' | 'end' | number) => void; } export interface UseInputHistoryReturn { @@ -25,15 +27,25 @@ export function useInputHistory({ onSubmit, isActive, currentQuery, + currentCursorOffset, onChange, }: UseInputHistoryProps): UseInputHistoryReturn { const [historyIndex, setHistoryIndex] = useState(-1); - const [originalQueryBeforeNav, setOriginalQueryBeforeNav] = - useState(''); + + // previousHistoryIndexRef tracks the index we occupied *immediately before* the current historyIndex. + // This allows us to detect when we are "returning" to a level we just left. + const previousHistoryIndexRef = useRef(undefined); + + // Cache stores text and cursor offset for each history index level. + // Level -1 is the current unsubmitted prompt. + const historyCacheRef = useRef< + Record + >({}); const resetHistoryNav = useCallback(() => { setHistoryIndex(-1); - setOriginalQueryBeforeNav(''); + previousHistoryIndexRef.current = undefined; + historyCacheRef.current = {}; }, []); const handleSubmit = useCallback( @@ -47,61 +59,72 @@ export function useInputHistory({ [onSubmit, resetHistoryNav], ); + const navigateTo = useCallback( + (nextIndex: number, defaultCursor: 'start' | 'end') => { + const prevIndexBeforeMove = historyIndex; + + // 1. Save current state to cache before moving + historyCacheRef.current[prevIndexBeforeMove] = { + text: currentQuery, + offset: currentCursorOffset, + }; + + // 2. Update index + setHistoryIndex(nextIndex); + + // 3. Restore next state + const saved = historyCacheRef.current[nextIndex]; + + // We robustly restore the cursor position IF: + // 1. We are returning to the compose prompt (-1) + // 2. OR we are returning to the level we occupied *just before* the current one. + // AND in both cases, the cursor was not at the very first or last character. + const isReturningToPrevious = + nextIndex === -1 || nextIndex === previousHistoryIndexRef.current; + + if ( + isReturningToPrevious && + saved && + saved.offset > 0 && + saved.offset < cpLen(saved.text) + ) { + onChange(saved.text, saved.offset); + } else if (nextIndex === -1) { + onChange(saved ? saved.text : '', defaultCursor); + } else { + // For regular history browsing, use default cursor position. + if (saved) { + onChange(saved.text, defaultCursor); + } else { + const newValue = userMessages[userMessages.length - 1 - nextIndex]; + onChange(newValue, defaultCursor); + } + } + + // Record the level we just came from for the next navigation + previousHistoryIndexRef.current = prevIndexBeforeMove; + }, + [historyIndex, currentQuery, currentCursorOffset, userMessages, onChange], + ); + const navigateUp = useCallback(() => { if (!isActive) return false; if (userMessages.length === 0) return false; - let nextIndex = historyIndex; - if (historyIndex === -1) { - // Store the current query from the parent before navigating - setOriginalQueryBeforeNav(currentQuery); - nextIndex = 0; - } else if (historyIndex < userMessages.length - 1) { - nextIndex = historyIndex + 1; - } else { - return false; // Already at the oldest message - } - - if (nextIndex !== historyIndex) { - setHistoryIndex(nextIndex); - const newValue = userMessages[userMessages.length - 1 - nextIndex]; - onChange(newValue); + if (historyIndex < userMessages.length - 1) { + navigateTo(historyIndex + 1, 'start'); return true; } return false; - }, [ - historyIndex, - setHistoryIndex, - onChange, - userMessages, - isActive, - currentQuery, // Use currentQuery from props - setOriginalQueryBeforeNav, - ]); + }, [historyIndex, userMessages, isActive, navigateTo]); const navigateDown = useCallback(() => { if (!isActive) return false; if (historyIndex === -1) return false; // Not currently navigating history - const nextIndex = historyIndex - 1; - setHistoryIndex(nextIndex); - - if (nextIndex === -1) { - // Reached the end of history navigation, restore original query - onChange(originalQueryBeforeNav); - } else { - const newValue = userMessages[userMessages.length - 1 - nextIndex]; - onChange(newValue); - } + navigateTo(historyIndex - 1, 'end'); return true; - }, [ - historyIndex, - setHistoryIndex, - originalQueryBeforeNav, - onChange, - userMessages, - isActive, - ]); + }, [historyIndex, isActive, navigateTo]); return { handleSubmit, diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx index d90875c10c..289e51588c 100644 --- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx @@ -39,10 +39,8 @@ export function useReverseSearchCompletion( suggestions, activeSuggestionIndex, visibleStartIndex, - showSuggestions, isLoadingSuggestions, setSuggestions, - setShowSuggestions, setActiveSuggestionIndex, resetCompletionState, navigateUp, @@ -115,7 +113,6 @@ export function useReverseSearchCompletion( setSuggestions(matches); const hasAny = matches.length > 0; - setShowSuggestions(hasAny); setActiveSuggestionIndex(hasAny ? 0 : -1); setVisibleStartIndex(0); @@ -126,12 +123,14 @@ export function useReverseSearchCompletion( matches, reverseSearchActive, setSuggestions, - setShowSuggestions, setActiveSuggestionIndex, setVisibleStartIndex, resetCompletionState, ]); + const showSuggestions = + reverseSearchActive && (isLoadingSuggestions || suggestions.length > 0); + const handleAutocomplete = useCallback( (i: number) => { if (i < 0 || i >= suggestions.length) return;