Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 151 additions & 11 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,14 @@ describe('InputPrompt', () => {
text: '',
cursor: [0, 0],
lines: [''],
setText: vi.fn((newText: string) => {
setText: vi.fn((newText: string, cursorPosition?: 'start' | 'end') => {
mockBuffer.text = newText;
mockBuffer.lines = [newText];
mockBuffer.cursor = [0, newText.length];
if (cursorPosition === 'start') {
mockBuffer.cursor = [0, 0];
} else {
mockBuffer.cursor = [0, newText.length];
}
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
mockBuffer.visualToLogicalMap = [[0, 0]];
Expand Down Expand Up @@ -1397,15 +1401,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();
Expand Down Expand Up @@ -2832,6 +2837,141 @@ 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);
});

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 re-enable 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: true,
});
});
});
});
});

function clean(str: string | undefined): string {
Expand Down
61 changes: 30 additions & 31 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,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: !justNavigatedHistory,
});

const reverseSearchCompletion = useReverseSearchCompletion(
buffer,
Expand Down Expand Up @@ -264,8 +265,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
);

const customSetTextAndResetCompletionSignal = useCallback(
(newText: string) => {
buffer.setText(newText);
(newText: string, cursorPosition?: 'start' | 'end') => {
buffer.setText(newText, cursorPosition);
setJustNavigatedHistory(true);
},
[buffer, setJustNavigatedHistory],
Expand All @@ -288,7 +289,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetReverseSearchCompletionState();
resetCommandSearchCompletionState();
setExpandedSuggestionIndex(-1);
setJustNavigatedHistory(false);
}
}, [
justNavigatedHistory,
Expand All @@ -297,6 +297,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setJustNavigatedHistory,
resetReverseSearchCompletionState,
resetCommandSearchCompletionState,
setExpandedSuggestionIndex,
]);

// Helper function to handle loading queued messages into input
Expand Down Expand Up @@ -368,6 +369,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useMouseClick(
innerBoxRef,
(_event, relX, relY) => {
setJustNavigatedHistory(false);
if (isEmbeddedShellFocused) {
setEmbeddedShellFocused(false);
}
Expand All @@ -380,6 +382,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useMouse(
(event: MouseEvent) => {
if (event.name === 'right-release') {
setJustNavigatedHistory(false);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleClipboardPaste();
}
Expand All @@ -389,6 +392,25 @@ 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)));

// Reset completion suppression if the user performs any action other than history navigation
setJustNavigatedHistory((prev) =>
prev && !isHistoryUp && !isHistoryDown ? false : prev,
);

// TODO(jacobr): this special case is likely not needed anymore.
// We should probably stop supporting paste if the InputPrompt is not
// focused.
Expand Down Expand Up @@ -698,7 +720,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}

if (keyMatchers[Command.HISTORY_UP](key)) {
if (isHistoryUp) {
// Check for queued messages first when input is empty
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
if (tryLoadQueuedMessages()) {
Expand All @@ -708,30 +730,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory.navigateUp();
return;
}
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
}
// 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()) {
return;
}
// Only navigate history if popAllMessages doesn't exist
inputHistory.navigateUp();
return;
}
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
if (isHistoryDown) {
inputHistory.navigateDown();
return;
}
Expand Down
36 changes: 28 additions & 8 deletions packages/cli/src/ui/components/shared/text-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,12 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
};

export type TextBufferAction =
| { type: 'set_text'; payload: string; pushToUndo?: boolean }
| {
type: 'set_text';
payload: string;
pushToUndo?: boolean;
cursorPosition?: 'start' | 'end';
}
| { type: 'insert'; payload: string }
| { type: 'backspace' }
| {
Expand Down Expand Up @@ -1229,12 +1234,24 @@ 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 (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,
};
}
Expand Down Expand Up @@ -1998,9 +2015,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'): void => {
dispatch({ type: 'set_text', payload: newText, cursorPosition });
},
[],
);

const deleteWordLeft = useCallback((): void => {
dispatch({ type: 'delete_word_left' });
Expand Down Expand Up @@ -2563,7 +2583,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') => void;
/**
* Insert a single character or string without newlines.
*/
Expand Down
Loading
Loading