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
+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;