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;