/ hooks / useHistorySearch.ts
useHistorySearch.ts
  1  import { feature } from 'bun:bundle'
  2  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  3  import {
  4    getModeFromInput,
  5    getValueFromInput,
  6  } from '../components/PromptInput/inputModes.js'
  7  import { makeHistoryReader } from '../history.js'
  8  import { KeyboardEvent } from '../ink/events/keyboard-event.js'
  9  // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to <Box onKeyDown>
 10  import { useInput } from '../ink.js'
 11  import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js'
 12  import type { PromptInputMode } from '../types/textInputTypes.js'
 13  import type { HistoryEntry } from '../utils/config.js'
 14  
 15  export function useHistorySearch(
 16    onAcceptHistory: (entry: HistoryEntry) => void,
 17    currentInput: string,
 18    onInputChange: (input: string) => void,
 19    onCursorChange: (cursorOffset: number) => void,
 20    currentCursorOffset: number,
 21    onModeChange: (mode: PromptInputMode) => void,
 22    currentMode: PromptInputMode,
 23    isSearching: boolean,
 24    setIsSearching: (isSearching: boolean) => void,
 25    setPastedContents: (pastedContents: HistoryEntry['pastedContents']) => void,
 26    currentPastedContents: HistoryEntry['pastedContents'],
 27  ): {
 28    historyQuery: string
 29    setHistoryQuery: (query: string) => void
 30    historyMatch: HistoryEntry | undefined
 31    historyFailedMatch: boolean
 32    handleKeyDown: (e: KeyboardEvent) => void
 33  } {
 34    const [historyQuery, setHistoryQuery] = useState('')
 35    const [historyFailedMatch, setHistoryFailedMatch] = useState(false)
 36    const [originalInput, setOriginalInput] = useState('')
 37    const [originalCursorOffset, setOriginalCursorOffset] = useState(0)
 38    const [originalMode, setOriginalMode] = useState<PromptInputMode>('prompt')
 39    const [originalPastedContents, setOriginalPastedContents] = useState<
 40      HistoryEntry['pastedContents']
 41    >({})
 42    const [historyMatch, setHistoryMatch] = useState<HistoryEntry | undefined>(
 43      undefined,
 44    )
 45    const historyReader = useRef<AsyncGenerator<HistoryEntry> | undefined>(
 46      undefined,
 47    )
 48    const seenPrompts = useRef<Set<string>>(new Set())
 49    const searchAbortController = useRef<AbortController | null>(null)
 50  
 51    const closeHistoryReader = useCallback((): void => {
 52      if (historyReader.current) {
 53        // Must explicitly call .return() to trigger the finally block in readLinesReverse,
 54        // which closes the file handle. Without this, file descriptors leak.
 55        void historyReader.current.return(undefined)
 56        historyReader.current = undefined
 57      }
 58    }, [])
 59  
 60    const reset = useCallback((): void => {
 61      setIsSearching(false)
 62      setHistoryQuery('')
 63      setHistoryFailedMatch(false)
 64      setOriginalInput('')
 65      setOriginalCursorOffset(0)
 66      setOriginalMode('prompt')
 67      setOriginalPastedContents({})
 68      setHistoryMatch(undefined)
 69      closeHistoryReader()
 70      seenPrompts.current.clear()
 71    }, [setIsSearching, closeHistoryReader])
 72  
 73    const searchHistory = useCallback(
 74      async (resume: boolean, signal?: AbortSignal): Promise<void> => {
 75        if (!isSearching) {
 76          return
 77        }
 78  
 79        if (historyQuery.length === 0) {
 80          closeHistoryReader()
 81          seenPrompts.current.clear()
 82          setHistoryMatch(undefined)
 83          setHistoryFailedMatch(false)
 84          onInputChange(originalInput)
 85          onCursorChange(originalCursorOffset)
 86          onModeChange(originalMode)
 87          setPastedContents(originalPastedContents)
 88          return
 89        }
 90  
 91        if (!resume) {
 92          closeHistoryReader()
 93          historyReader.current = makeHistoryReader()
 94          seenPrompts.current.clear()
 95        }
 96  
 97        if (!historyReader.current) {
 98          return
 99        }
100  
101        while (true) {
102          if (signal?.aborted) {
103            return
104          }
105  
106          const item = await historyReader.current.next()
107          if (item.done) {
108            // No match found - keep last match but mark as failed
109            setHistoryFailedMatch(true)
110            return
111          }
112  
113          const display = item.value.display
114  
115          const matchPosition = display.lastIndexOf(historyQuery)
116          if (matchPosition !== -1 && !seenPrompts.current.has(display)) {
117            seenPrompts.current.add(display)
118            setHistoryMatch(item.value)
119            setHistoryFailedMatch(false)
120            const mode = getModeFromInput(display)
121            onModeChange(mode)
122            onInputChange(display)
123            setPastedContents(item.value.pastedContents)
124  
125            // Position cursor relative to the clean value, not the display
126            const value = getValueFromInput(display)
127            const cleanMatchPosition = value.lastIndexOf(historyQuery)
128            onCursorChange(
129              cleanMatchPosition !== -1 ? cleanMatchPosition : matchPosition,
130            )
131            return
132          }
133        }
134      },
135      [
136        isSearching,
137        historyQuery,
138        closeHistoryReader,
139        onInputChange,
140        onCursorChange,
141        onModeChange,
142        setPastedContents,
143        originalInput,
144        originalCursorOffset,
145        originalMode,
146        originalPastedContents,
147      ],
148    )
149  
150    // Handler: Start history search (when not searching)
151    const handleStartSearch = useCallback(() => {
152      setIsSearching(true)
153      setOriginalInput(currentInput)
154      setOriginalCursorOffset(currentCursorOffset)
155      setOriginalMode(currentMode)
156      setOriginalPastedContents(currentPastedContents)
157      historyReader.current = makeHistoryReader()
158      seenPrompts.current.clear()
159    }, [
160      setIsSearching,
161      currentInput,
162      currentCursorOffset,
163      currentMode,
164      currentPastedContents,
165    ])
166  
167    // Handler: Find next match (when searching)
168    const handleNextMatch = useCallback(() => {
169      void searchHistory(true)
170    }, [searchHistory])
171  
172    // Handler: Accept current match and exit search
173    const handleAccept = useCallback(() => {
174      if (historyMatch) {
175        const mode = getModeFromInput(historyMatch.display)
176        const value = getValueFromInput(historyMatch.display)
177        onInputChange(value)
178        onModeChange(mode)
179        setPastedContents(historyMatch.pastedContents)
180      } else {
181        // No match - restore original pasted contents
182        setPastedContents(originalPastedContents)
183      }
184      reset()
185    }, [
186      historyMatch,
187      onInputChange,
188      onModeChange,
189      setPastedContents,
190      originalPastedContents,
191      reset,
192    ])
193  
194    // Handler: Cancel search and restore original input
195    const handleCancel = useCallback(() => {
196      onInputChange(originalInput)
197      onCursorChange(originalCursorOffset)
198      setPastedContents(originalPastedContents)
199      reset()
200    }, [
201      onInputChange,
202      onCursorChange,
203      setPastedContents,
204      originalInput,
205      originalCursorOffset,
206      originalPastedContents,
207      reset,
208    ])
209  
210    // Handler: Execute (accept and submit)
211    const handleExecute = useCallback(() => {
212      if (historyQuery.length === 0) {
213        onAcceptHistory({
214          display: originalInput,
215          pastedContents: originalPastedContents,
216        })
217      } else if (historyMatch) {
218        const mode = getModeFromInput(historyMatch.display)
219        const value = getValueFromInput(historyMatch.display)
220        onModeChange(mode)
221        onAcceptHistory({
222          display: value,
223          pastedContents: historyMatch.pastedContents,
224        })
225      }
226      reset()
227    }, [
228      historyQuery,
229      historyMatch,
230      onAcceptHistory,
231      onModeChange,
232      originalInput,
233      originalPastedContents,
234      reset,
235    ])
236  
237    // Gated off under HISTORY_PICKER — the modal dialog owns ctrl+r there.
238    useKeybinding('history:search', handleStartSearch, {
239      context: 'Global',
240      isActive: feature('HISTORY_PICKER') ? false : !isSearching,
241    })
242  
243    // History search context keybindings (only active when searching)
244    const historySearchHandlers = useMemo(
245      () => ({
246        'historySearch:next': handleNextMatch,
247        'historySearch:accept': handleAccept,
248        'historySearch:cancel': handleCancel,
249        'historySearch:execute': handleExecute,
250      }),
251      [handleNextMatch, handleAccept, handleCancel, handleExecute],
252    )
253  
254    useKeybindings(historySearchHandlers, {
255      context: 'HistorySearch',
256      isActive: isSearching,
257    })
258  
259    // Handle backspace when query is empty (cancels search)
260    // This is a conditional behavior that doesn't fit the keybinding model
261    // well (backspace only cancels when query is empty)
262    const handleKeyDown = (e: KeyboardEvent): void => {
263      if (!isSearching) return
264      if (e.key === 'backspace' && historyQuery === '') {
265        e.preventDefault()
266        handleCancel()
267      }
268    }
269  
270    // Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to
271    // <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
272    // KeyboardEvent until the consumer is migrated (separate PR).
273    // TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown.
274    useInput(
275      (_input, _key, event) => {
276        handleKeyDown(new KeyboardEvent(event.keypress))
277      },
278      { isActive: isSearching },
279    )
280  
281    // Keep a ref to searchHistory to avoid it being a dependency of useEffect
282    const searchHistoryRef = useRef(searchHistory)
283    searchHistoryRef.current = searchHistory
284  
285    // Reset history search when query changes
286    useEffect(() => {
287      searchAbortController.current?.abort()
288      const controller = new AbortController()
289      searchAbortController.current = controller
290      void searchHistoryRef.current(false, controller.signal)
291      return () => {
292        controller.abort()
293      }
294    }, [historyQuery])
295  
296    return {
297      historyQuery,
298      setHistoryQuery,
299      historyMatch,
300      historyFailedMatch,
301      handleKeyDown,
302    }
303  }