feat (cli): Add command search using Ctrl+r (#5539)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Ayesha Shafique
2025-09-15 12:49:23 -05:00
committed by GitHub
parent 8c283127d5
commit 0d9c1fba1d
10 changed files with 635 additions and 80 deletions

View File

@@ -4,11 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useCompletion } from './useCompletion.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
function useDebouncedValue<T>(value: T, delay = 200): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}
export interface UseReverseSearchCompletionReturn {
suggestions: Suggestion[];
activeSuggestionIndex: number;
@@ -23,7 +32,7 @@ export interface UseReverseSearchCompletionReturn {
export function useReverseSearchCompletion(
buffer: TextBuffer,
shellHistory: readonly string[],
history: readonly string[],
reverseSearchActive: boolean,
): UseReverseSearchCompletionReturn {
const {
@@ -32,45 +41,95 @@ export function useReverseSearchCompletion(
visibleStartIndex,
showSuggestions,
isLoadingSuggestions,
setSuggestions,
setShowSuggestions,
setActiveSuggestionIndex,
resetCompletionState,
navigateUp,
navigateDown,
setVisibleStartIndex,
} = useCompletion();
const debouncedQuery = useDebouncedValue(buffer.text, 100);
// incremental search
const prevQueryRef = useRef<string>('');
const prevMatchesRef = useRef<Suggestion[]>([]);
// Clear incremental cache when activating reverse search
useEffect(() => {
if (reverseSearchActive) {
prevQueryRef.current = '';
prevMatchesRef.current = [];
}
}, [reverseSearchActive]);
// Also clear cache when history changes so new items are considered
useEffect(() => {
prevQueryRef.current = '';
prevMatchesRef.current = [];
}, [history]);
const searchHistory = useCallback(
(query: string, items: readonly string[]) => {
const out: Suggestion[] = [];
for (let i = 0; i < items.length; i++) {
const cmd = items[i];
const idx = cmd.toLowerCase().indexOf(query);
if (idx !== -1) {
out.push({ label: cmd, value: cmd, matchedIndex: idx });
}
}
return out;
},
[],
);
const matches = useMemo<Suggestion[]>(() => {
if (!reverseSearchActive) return [];
if (debouncedQuery.length === 0)
return history.map((cmd) => ({
label: cmd,
value: cmd,
matchedIndex: -1,
}));
const query = debouncedQuery.toLowerCase();
const canUseCache =
prevQueryRef.current &&
query.startsWith(prevQueryRef.current) &&
prevMatchesRef.current.length > 0;
const source = canUseCache
? prevMatchesRef.current.map((m) => m.value)
: history;
return searchHistory(query, source);
}, [debouncedQuery, history, reverseSearchActive, searchHistory]);
useEffect(() => {
if (!reverseSearchActive) {
resetCompletionState();
}
}, [reverseSearchActive, resetCompletionState]);
useEffect(() => {
if (!reverseSearchActive) {
return;
}
const q = buffer.text.toLowerCase();
const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => {
const idx = cmd.toLowerCase().indexOf(q);
if (idx !== -1) {
acc.push({ label: cmd, value: cmd, matchedIndex: idx });
}
return acc;
}, []);
setSuggestions(matches);
setShowSuggestions(matches.length > 0);
setActiveSuggestionIndex(matches.length > 0 ? 0 : -1);
const hasAny = matches.length > 0;
setShowSuggestions(hasAny);
setActiveSuggestionIndex(hasAny ? 0 : -1);
setVisibleStartIndex(0);
prevQueryRef.current = debouncedQuery.toLowerCase();
prevMatchesRef.current = matches;
}, [
buffer.text,
shellHistory,
debouncedQuery,
matches,
reverseSearchActive,
setActiveSuggestionIndex,
setShowSuggestions,
setSuggestions,
setShowSuggestions,
setActiveSuggestionIndex,
setVisibleStartIndex,
resetCompletionState,
]);
const handleAutocomplete = useCallback(