feat: Add reverse search capability for shell commands (#4793)

This commit is contained in:
Ayesha Shafique
2025-08-04 00:53:24 +05:00
committed by GitHub
parent c0b4fc9506
commit 0335ce5ecc
10 changed files with 1505 additions and 737 deletions

View File

@@ -9,12 +9,13 @@ import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer } from './shared/text-buffer.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useSlashCompletion } from '../hooks/useSlashCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core';
@@ -69,18 +70,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setDirs(dirsChanged);
}
}, [dirs.length, dirsChanged]);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
const shellHistory = useShellHistory(config.getProjectRoot());
const historyData = shellHistory.history;
const completion = useCompletion(
const completion = useSlashCompletion(
buffer,
dirs,
config.getTargetDir(),
slashCommands,
commandContext,
reverseSearchActive,
config,
);
const reverseSearchCompletion = useReverseSearchCompletion(
buffer,
historyData,
reverseSearchActive,
);
const resetCompletionState = completion.resetCompletionState;
const shellHistory = useShellHistory(config.getProjectRoot());
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
@@ -92,8 +107,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.setText('');
onSubmit(submittedValue);
resetCompletionState();
resetReverseSearchCompletionState();
},
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
[
onSubmit,
buffer,
resetCompletionState,
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
],
);
const customSetTextAndResetCompletionSignal = useCallback(
@@ -118,6 +141,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useEffect(() => {
if (justNavigatedHistory) {
resetCompletionState();
resetReverseSearchCompletionState();
setJustNavigatedHistory(false);
}
}, [
@@ -125,6 +149,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.text,
resetCompletionState,
setJustNavigatedHistory,
resetReverseSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -197,6 +222,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (key.name === 'escape') {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
buffer.setText(textBeforeReverseSearch);
const offset = logicalPosToOffset(
buffer.lines,
cursorPosition[0],
cursorPosition[1],
);
buffer.moveToOffset(offset);
return;
}
if (shellModeActive) {
setShellModeActive(false);
return;
@@ -208,11 +246,61 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
if (shellModeActive && key.ctrl && key.name === 'r') {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (key.ctrl && key.name === 'l') {
onClearScreen();
return;
}
if (reverseSearchActive) {
const {
activeSuggestionIndex,
navigateUp,
navigateDown,
showSuggestions,
suggestions,
} = reverseSearchCompletion;
if (showSuggestions) {
if (key.name === 'up') {
navigateUp();
return;
}
if (key.name === 'down') {
navigateDown();
return;
}
if (key.name === 'tab') {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
}
if (key.name === 'return' && !key.ctrl) {
const textToSubmit =
showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
// Prevent up/down from falling through to regular history navigation
if (key.name === 'up' || key.name === 'down') {
return;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') {
handleSubmitAndClear(buffer.text);
@@ -272,7 +360,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
} else {
// Shell History Navigation
if (key.name === 'up') {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
@@ -284,7 +371,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
@@ -362,9 +448,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory,
handleSubmitAndClear,
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
resetCompletionState,
vimHandleInput,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
],
);
@@ -385,7 +475,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
>
{shellModeActive ? '! ' : '> '}
{shellModeActive ? (
reverseSearchActive ? (
<Text color={Colors.AccentCyan}>(r:) </Text>
) : (
'! '
)
) : (
'> '
)}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
@@ -449,6 +547,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
</Box>
)}
{reverseSearchActive && (
<Box>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
isLoading={reverseSearchCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={reverseSearchCompletion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
</>
);
};