ux(polish) autocomplete in the input prompt (#18181)

This commit is contained in:
Jacob Richman
2026-02-05 12:38:29 -08:00
committed by GitHub
parent 9ca7300c90
commit 8efae719ee
11 changed files with 927 additions and 210 deletions

View File

@@ -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 {