mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
ux(polish) autocomplete in the input prompt (#18181)
This commit is contained in:
@@ -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(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
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(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
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(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
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(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
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(<InputPrompt {...props} />, {
|
||||
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(<InputPrompt {...props} />, {
|
||||
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(<InputPrompt {...props} />, {
|
||||
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(<InputPrompt {...props} />, {
|
||||
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(
|
||||
<InputPrompt {...props} />,
|
||||
{ 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(<InputPrompt {...props} />, {
|
||||
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(<InputPrompt {...props} />, {
|
||||
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 {
|
||||
|
||||
@@ -160,7 +160,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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<NodeJS.Timeout | null>(null);
|
||||
@@ -181,15 +181,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
);
|
||||
|
||||
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<InputPromptProps> = ({
|
||||
(!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<InputPromptProps> = ({
|
||||
useMouseClick(
|
||||
innerBoxRef,
|
||||
(_event, relX, relY) => {
|
||||
setSuppressCompletion(true);
|
||||
if (isEmbeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
@@ -470,6 +473,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
|
||||
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<InputPromptProps> = ({
|
||||
// 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<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -114,6 +114,7 @@ describe('useCommandCompletion', () => {
|
||||
initialText: string,
|
||||
cursorOffset?: number,
|
||||
shellModeActive = false,
|
||||
active = true,
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useCommandCompletion> & {
|
||||
textBuffer: ReturnType<typeof useTextBuffer>;
|
||||
@@ -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(<TestComponent />);
|
||||
|
||||
// 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(<TestComponent />);
|
||||
|
||||
// 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(<TestComponent />);
|
||||
|
||||
// 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface UseCommandCompletionReturn {
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
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,
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface UseCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
activeSuggestionIndex: number;
|
||||
visibleStartIndex: number;
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
setSuggestions: React.Dispatch<React.SetStateAction<Suggestion[]>>;
|
||||
@@ -21,7 +20,6 @@ export interface UseCompletionReturn {
|
||||
setVisibleStartIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setIsLoadingSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsPerfectMatch: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
@@ -32,7 +30,6 @@ export function useCompletion(): UseCompletionReturn {
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] =
|
||||
useState<number>(-1);
|
||||
const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0);
|
||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||
useState<boolean>(false);
|
||||
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number>(-1);
|
||||
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
|
||||
useState<string>('');
|
||||
|
||||
// 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<number | undefined>(undefined);
|
||||
|
||||
// Cache stores text and cursor offset for each history index level.
|
||||
// Level -1 is the current unsubmitted prompt.
|
||||
const historyCacheRef = useRef<
|
||||
Record<number, { text: string; offset: number }>
|
||||
>({});
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user