Download src/hooks/useHistorySearch.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 9.49 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/hooks/useHistorySearch.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/hooks/useHistorySearch.ts
-
curl -L -o useHistorySearch.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/hooks/useHistorySearch.ts
9.49 kB
| import { feature } from 'bun:bundle' | |
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react' | |
| import { | |
| getModeFromInput, | |
| getValueFromInput, | |
| } from '../components/PromptInput/inputModes.js' | |
| import { makeHistoryReader } from '../history.js' | |
| import { KeyboardEvent } from '../ink/events/keyboard-event.js' | |
| // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to <Box onKeyDown> | |
| import { useInput } from '../ink.js' | |
| import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js' | |
| import type { PromptInputMode } from '../types/textInputTypes.js' | |
| import type { HistoryEntry } from '../utils/config.js' | |
| export function useHistorySearch( | |
| onAcceptHistory: (entry: HistoryEntry) => void, | |
| currentInput: string, | |
| onInputChange: (input: string) => void, | |
| onCursorChange: (cursorOffset: number) => void, | |
| currentCursorOffset: number, | |
| onModeChange: (mode: PromptInputMode) => void, | |
| currentMode: PromptInputMode, | |
| isSearching: boolean, | |
| setIsSearching: (isSearching: boolean) => void, | |
| setPastedContents: (pastedContents: HistoryEntry['pastedContents']) => void, | |
| currentPastedContents: HistoryEntry['pastedContents'], | |
| ): { | |
| historyQuery: string | |
| setHistoryQuery: (query: string) => void | |
| historyMatch: HistoryEntry | undefined | |
| historyFailedMatch: boolean | |
| handleKeyDown: (e: KeyboardEvent) => void | |
| } { | |
| const [historyQuery, setHistoryQuery] = useState('') | |
| const [historyFailedMatch, setHistoryFailedMatch] = useState(false) | |
| const [originalInput, setOriginalInput] = useState('') | |
| const [originalCursorOffset, setOriginalCursorOffset] = useState(0) | |
| const [originalMode, setOriginalMode] = useState<PromptInputMode>('prompt') | |
| const [originalPastedContents, setOriginalPastedContents] = useState< | |
| HistoryEntry['pastedContents'] | |
| >({}) | |
| const [historyMatch, setHistoryMatch] = useState<HistoryEntry | undefined>( | |
| undefined, | |
| ) | |
| const historyReader = useRef<AsyncGenerator<HistoryEntry> | undefined>( | |
| undefined, | |
| ) | |
| const seenPrompts = useRef<Set<string>>(new Set()) | |
| const searchAbortController = useRef<AbortController | null>(null) | |
| const closeHistoryReader = useCallback((): void => { | |
| if (historyReader.current) { | |
| // Must explicitly call .return() to trigger the finally block in readLinesReverse, | |
| // which closes the file handle. Without this, file descriptors leak. | |
| void historyReader.current.return(undefined) | |
| historyReader.current = undefined | |
| } | |
| }, []) | |
| const reset = useCallback((): void => { | |
| setIsSearching(false) | |
| setHistoryQuery('') | |
| setHistoryFailedMatch(false) | |
| setOriginalInput('') | |
| setOriginalCursorOffset(0) | |
| setOriginalMode('prompt') | |
| setOriginalPastedContents({}) | |
| setHistoryMatch(undefined) | |
| closeHistoryReader() | |
| seenPrompts.current.clear() | |
| }, [setIsSearching, closeHistoryReader]) | |
| const searchHistory = useCallback( | |
| async (resume: boolean, signal?: AbortSignal): Promise<void> => { | |
| if (!isSearching) { | |
| return | |
| } | |
| if (historyQuery.length === 0) { | |
| closeHistoryReader() | |
| seenPrompts.current.clear() | |
| setHistoryMatch(undefined) | |
| setHistoryFailedMatch(false) | |
| onInputChange(originalInput) | |
| onCursorChange(originalCursorOffset) | |
| onModeChange(originalMode) | |
| setPastedContents(originalPastedContents) | |
| return | |
| } | |
| if (!resume) { | |
| closeHistoryReader() | |
| historyReader.current = makeHistoryReader() | |
| seenPrompts.current.clear() | |
| } | |
| if (!historyReader.current) { | |
| return | |
| } | |
| while (true) { | |
| if (signal?.aborted) { | |
| return | |
| } | |
| const item = await historyReader.current.next() | |
| if (item.done) { | |
| // No match found - keep last match but mark as failed | |
| setHistoryFailedMatch(true) | |
| return | |
| } | |
| const display = item.value.display | |
| const matchPosition = display.lastIndexOf(historyQuery) | |
| if (matchPosition !== -1 && !seenPrompts.current.has(display)) { | |
| seenPrompts.current.add(display) | |
| setHistoryMatch(item.value) | |
| setHistoryFailedMatch(false) | |
| const mode = getModeFromInput(display) | |
| onModeChange(mode) | |
| onInputChange(display) | |
| setPastedContents(item.value.pastedContents) | |
| // Position cursor relative to the clean value, not the display | |
| const value = getValueFromInput(display) | |
| const cleanMatchPosition = value.lastIndexOf(historyQuery) | |
| onCursorChange( | |
| cleanMatchPosition !== -1 ? cleanMatchPosition : matchPosition, | |
| ) | |
| return | |
| } | |
| } | |
| }, | |
| [ | |
| isSearching, | |
| historyQuery, | |
| closeHistoryReader, | |
| onInputChange, | |
| onCursorChange, | |
| onModeChange, | |
| setPastedContents, | |
| originalInput, | |
| originalCursorOffset, | |
| originalMode, | |
| originalPastedContents, | |
| ], | |
| ) | |
| // Handler: Start history search (when not searching) | |
| const handleStartSearch = useCallback(() => { | |
| setIsSearching(true) | |
| setOriginalInput(currentInput) | |
| setOriginalCursorOffset(currentCursorOffset) | |
| setOriginalMode(currentMode) | |
| setOriginalPastedContents(currentPastedContents) | |
| historyReader.current = makeHistoryReader() | |
| seenPrompts.current.clear() | |
| }, [ | |
| setIsSearching, | |
| currentInput, | |
| currentCursorOffset, | |
| currentMode, | |
| currentPastedContents, | |
| ]) | |
| // Handler: Find next match (when searching) | |
| const handleNextMatch = useCallback(() => { | |
| void searchHistory(true) | |
| }, [searchHistory]) | |
| // Handler: Accept current match and exit search | |
| const handleAccept = useCallback(() => { | |
| if (historyMatch) { | |
| const mode = getModeFromInput(historyMatch.display) | |
| const value = getValueFromInput(historyMatch.display) | |
| onInputChange(value) | |
| onModeChange(mode) | |
| setPastedContents(historyMatch.pastedContents) | |
| } else { | |
| // No match - restore original pasted contents | |
| setPastedContents(originalPastedContents) | |
| } | |
| reset() | |
| }, [ | |
| historyMatch, | |
| onInputChange, | |
| onModeChange, | |
| setPastedContents, | |
| originalPastedContents, | |
| reset, | |
| ]) | |
| // Handler: Cancel search and restore original input | |
| const handleCancel = useCallback(() => { | |
| onInputChange(originalInput) | |
| onCursorChange(originalCursorOffset) | |
| setPastedContents(originalPastedContents) | |
| reset() | |
| }, [ | |
| onInputChange, | |
| onCursorChange, | |
| setPastedContents, | |
| originalInput, | |
| originalCursorOffset, | |
| originalPastedContents, | |
| reset, | |
| ]) | |
| // Handler: Execute (accept and submit) | |
| const handleExecute = useCallback(() => { | |
| if (historyQuery.length === 0) { | |
| onAcceptHistory({ | |
| display: originalInput, | |
| pastedContents: originalPastedContents, | |
| }) | |
| } else if (historyMatch) { | |
| const mode = getModeFromInput(historyMatch.display) | |
| const value = getValueFromInput(historyMatch.display) | |
| onModeChange(mode) | |
| onAcceptHistory({ | |
| display: value, | |
| pastedContents: historyMatch.pastedContents, | |
| }) | |
| } | |
| reset() | |
| }, [ | |
| historyQuery, | |
| historyMatch, | |
| onAcceptHistory, | |
| onModeChange, | |
| originalInput, | |
| originalPastedContents, | |
| reset, | |
| ]) | |
| // Gated off under HISTORY_PICKER — the modal dialog owns ctrl+r there. | |
| useKeybinding('history:search', handleStartSearch, { | |
| context: 'Global', | |
| isActive: feature('HISTORY_PICKER') ? false : !isSearching, | |
| }) | |
| // History search context keybindings (only active when searching) | |
| const historySearchHandlers = useMemo( | |
| () => ({ | |
| 'historySearch:next': handleNextMatch, | |
| 'historySearch:accept': handleAccept, | |
| 'historySearch:cancel': handleCancel, | |
| 'historySearch:execute': handleExecute, | |
| }), | |
| [handleNextMatch, handleAccept, handleCancel, handleExecute], | |
| ) | |
| useKeybindings(historySearchHandlers, { | |
| context: 'HistorySearch', | |
| isActive: isSearching, | |
| }) | |
| // Handle backspace when query is empty (cancels search) | |
| // This is a conditional behavior that doesn't fit the keybinding model | |
| // well (backspace only cancels when query is empty) | |
| const handleKeyDown = (e: KeyboardEvent): void => { | |
| if (!isSearching) return | |
| if (e.key === 'backspace' && historyQuery === '') { | |
| e.preventDefault() | |
| handleCancel() | |
| } | |
| } | |
| // Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to | |
| // <Box onKeyDown>. Subscribe via useInput and adapt InputEvent → | |
| // KeyboardEvent until the consumer is migrated (separate PR). | |
| // TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown. | |
| useInput( | |
| (_input, _key, event) => { | |
| handleKeyDown(new KeyboardEvent(event.keypress)) | |
| }, | |
| { isActive: isSearching }, | |
| ) | |
| // Keep a ref to searchHistory to avoid it being a dependency of useEffect | |
| const searchHistoryRef = useRef(searchHistory) | |
| searchHistoryRef.current = searchHistory | |
| // Reset history search when query changes | |
| useEffect(() => { | |
| searchAbortController.current?.abort() | |
| const controller = new AbortController() | |
| searchAbortController.current = controller | |
| void searchHistoryRef.current(false, controller.signal) | |
| return () => { | |
| controller.abort() | |
| } | |
| }, [historyQuery]) | |
| return { | |
| historyQuery, | |
| setHistoryQuery, | |
| historyMatch, | |
| historyFailedMatch, | |
| handleKeyDown, | |
| } | |
| } | |