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
@@ -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 {
+93 -35
View File
@@ -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.
*/