Skip to content
Merged
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
389 changes: 366 additions & 23 deletions packages/cli/src/ui/components/InputPrompt.test.tsx

Large diffs are not rendered by default.

128 changes: 93 additions & 35 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -405,6 +407,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useMouseClick(
innerBoxRef,
(_event, relX, relY) => {
setSuppressCompletion(true);
if (isEmbeddedShellFocused) {
setEmbeddedShellFocused(false);
}
Expand Down Expand Up @@ -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();
}
Expand All @@ -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.
Expand Down Expand Up @@ -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)
) {
Expand Down Expand Up @@ -801,26 +850,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}

if (keyMatchers[Command.HISTORY_UP](key)) {
// Check for queued messages first when input is empty
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
if (tryLoadQueuedMessages()) {
if (isHistoryUp) {
if (
keyMatchers[Command.NAVIGATION_UP](key) &&
buffer.visualCursor[1] > 0
) {
buffer.move('home');
return true;
}
// Only navigate history if popAllMessages doesn't exist
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()) {
Expand All @@ -830,22 +867,43 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory.navigateUp();
return true;
}
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
if (isHistoryDown) {
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
buffer.visualCursor[1] <
cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '')
) {
buffer.move('end');
return true;
}
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 16 additions & 4 deletions packages/cli/src/ui/components/shared/TextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}),
};

Expand Down Expand Up @@ -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;
Expand Down
41 changes: 33 additions & 8 deletions packages/cli/src/ui/components/shared/text-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1413,8 +1413,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' }
| {
Expand Down Expand Up @@ -1517,12 +1522,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,
};
Expand Down Expand Up @@ -2637,9 +2659,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' });
Expand Down Expand Up @@ -3383,7 +3408,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.
*/
Expand Down
Loading
Loading