/ hooks / useSearchInput.ts
useSearchInput.ts
  1  import { useCallback, useState } from 'react'
  2  import { KeyboardEvent } from '../ink/events/keyboard-event.js'
  3  // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to <Box onKeyDown>
  4  import { useInput } from '../ink.js'
  5  import {
  6    Cursor,
  7    getLastKill,
  8    pushToKillRing,
  9    recordYank,
 10    resetKillAccumulation,
 11    resetYankState,
 12    updateYankLength,
 13    yankPop,
 14  } from '../utils/Cursor.js'
 15  import { useTerminalSize } from './useTerminalSize.js'
 16  
 17  type UseSearchInputOptions = {
 18    isActive: boolean
 19    onExit: () => void
 20    /** Esc + Ctrl+C abandon (distinct from onExit = Enter commit). When
 21     *  provided: single-Esc calls this directly (no clear-first-then-exit
 22     *  two-press). When absent: current behavior — Esc clears non-empty
 23     *  query, exits on empty; Ctrl+C silently swallowed (no switch case). */
 24    onCancel?: () => void
 25    onExitUp?: () => void
 26    columns?: number
 27    passthroughCtrlKeys?: string[]
 28    initialQuery?: string
 29    /** Backspace (and ctrl+h) on empty query calls onCancel ?? onExit — the
 30     *  less/vim "delete past the /" convention. Dialogs that want Esc-only
 31     *  cancel set this false so a held backspace doesn't eject the user. */
 32    backspaceExitsOnEmpty?: boolean
 33  }
 34  
 35  type UseSearchInputReturn = {
 36    query: string
 37    setQuery: (q: string) => void
 38    cursorOffset: number
 39    handleKeyDown: (e: KeyboardEvent) => void
 40  }
 41  
 42  function isKillKey(e: KeyboardEvent): boolean {
 43    if (e.ctrl && (e.key === 'k' || e.key === 'u' || e.key === 'w')) {
 44      return true
 45    }
 46    if (e.meta && e.key === 'backspace') {
 47      return true
 48    }
 49    return false
 50  }
 51  
 52  function isYankKey(e: KeyboardEvent): boolean {
 53    return (e.ctrl || e.meta) && e.key === 'y'
 54  }
 55  
 56  // Special key names that fall through the explicit handlers above the
 57  // text-input branch (return/escape/arrows/home/end/tab/backspace/delete
 58  // all early-return). Reject these so e.g. PageUp doesn't leak 'pageup'
 59  // as literal text. The length>=1 check below is intentionally loose —
 60  // batched input like stdin.write('abc') arrives as one multi-char e.key,
 61  // matching the old useInput(input) behavior where cursor.insert(input)
 62  // inserted the full chunk.
 63  const UNHANDLED_SPECIAL_KEYS = new Set([
 64    'pageup',
 65    'pagedown',
 66    'insert',
 67    'wheelup',
 68    'wheeldown',
 69    'mouse',
 70    'f1',
 71    'f2',
 72    'f3',
 73    'f4',
 74    'f5',
 75    'f6',
 76    'f7',
 77    'f8',
 78    'f9',
 79    'f10',
 80    'f11',
 81    'f12',
 82  ])
 83  
 84  export function useSearchInput({
 85    isActive,
 86    onExit,
 87    onCancel,
 88    onExitUp,
 89    columns,
 90    passthroughCtrlKeys = [],
 91    initialQuery = '',
 92    backspaceExitsOnEmpty = true,
 93  }: UseSearchInputOptions): UseSearchInputReturn {
 94    const { columns: terminalColumns } = useTerminalSize()
 95    const effectiveColumns = columns ?? terminalColumns
 96    const [query, setQueryState] = useState(initialQuery)
 97    const [cursorOffset, setCursorOffset] = useState(initialQuery.length)
 98  
 99    const setQuery = useCallback((q: string) => {
100      setQueryState(q)
101      setCursorOffset(q.length)
102    }, [])
103  
104    const handleKeyDown = (e: KeyboardEvent): void => {
105      if (!isActive) return
106  
107      const cursor = Cursor.fromText(query, effectiveColumns, cursorOffset)
108  
109      // Check passthrough ctrl keys
110      if (e.ctrl && passthroughCtrlKeys.includes(e.key.toLowerCase())) {
111        return
112      }
113  
114      // Reset kill accumulation for non-kill keys
115      if (!isKillKey(e)) {
116        resetKillAccumulation()
117      }
118  
119      // Reset yank state for non-yank keys
120      if (!isYankKey(e)) {
121        resetYankState()
122      }
123  
124      // Exit conditions
125      if (e.key === 'return' || e.key === 'down') {
126        e.preventDefault()
127        onExit()
128        return
129      }
130      if (e.key === 'up') {
131        e.preventDefault()
132        if (onExitUp) {
133          onExitUp()
134        }
135        return
136      }
137      if (e.key === 'escape') {
138        e.preventDefault()
139        if (onCancel) {
140          onCancel()
141        } else if (query.length > 0) {
142          setQueryState('')
143          setCursorOffset(0)
144        } else {
145          onExit()
146        }
147        return
148      }
149  
150      // Backspace/Delete
151      if (e.key === 'backspace') {
152        e.preventDefault()
153        if (e.meta) {
154          // Meta+Backspace: kill word before
155          const { cursor: newCursor, killed } = cursor.deleteWordBefore()
156          pushToKillRing(killed, 'prepend')
157          setQueryState(newCursor.text)
158          setCursorOffset(newCursor.offset)
159          return
160        }
161        if (query.length === 0) {
162          // Backspace past the / — cancel (clear + snap back), not commit.
163          // less: same. vim: deletes the / and exits command mode.
164          if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
165          return
166        }
167        const newCursor = cursor.backspace()
168        setQueryState(newCursor.text)
169        setCursorOffset(newCursor.offset)
170        return
171      }
172  
173      if (e.key === 'delete') {
174        e.preventDefault()
175        const newCursor = cursor.del()
176        setQueryState(newCursor.text)
177        setCursorOffset(newCursor.offset)
178        return
179      }
180  
181      // Arrow keys with modifiers (word jump)
182      if (e.key === 'left' && (e.ctrl || e.meta || e.fn)) {
183        e.preventDefault()
184        const newCursor = cursor.prevWord()
185        setCursorOffset(newCursor.offset)
186        return
187      }
188      if (e.key === 'right' && (e.ctrl || e.meta || e.fn)) {
189        e.preventDefault()
190        const newCursor = cursor.nextWord()
191        setCursorOffset(newCursor.offset)
192        return
193      }
194  
195      // Plain arrow keys
196      if (e.key === 'left') {
197        e.preventDefault()
198        const newCursor = cursor.left()
199        setCursorOffset(newCursor.offset)
200        return
201      }
202      if (e.key === 'right') {
203        e.preventDefault()
204        const newCursor = cursor.right()
205        setCursorOffset(newCursor.offset)
206        return
207      }
208  
209      // Home/End
210      if (e.key === 'home') {
211        e.preventDefault()
212        setCursorOffset(0)
213        return
214      }
215      if (e.key === 'end') {
216        e.preventDefault()
217        setCursorOffset(query.length)
218        return
219      }
220  
221      // Ctrl key bindings
222      if (e.ctrl) {
223        e.preventDefault()
224        switch (e.key.toLowerCase()) {
225          case 'a':
226            setCursorOffset(0)
227            return
228          case 'e':
229            setCursorOffset(query.length)
230            return
231          case 'b':
232            setCursorOffset(cursor.left().offset)
233            return
234          case 'f':
235            setCursorOffset(cursor.right().offset)
236            return
237          case 'd': {
238            if (query.length === 0) {
239              ;(onCancel ?? onExit)()
240              return
241            }
242            const newCursor = cursor.del()
243            setQueryState(newCursor.text)
244            setCursorOffset(newCursor.offset)
245            return
246          }
247          case 'h': {
248            if (query.length === 0) {
249              if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
250              return
251            }
252            const newCursor = cursor.backspace()
253            setQueryState(newCursor.text)
254            setCursorOffset(newCursor.offset)
255            return
256          }
257          case 'k': {
258            const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
259            pushToKillRing(killed, 'append')
260            setQueryState(newCursor.text)
261            setCursorOffset(newCursor.offset)
262            return
263          }
264          case 'u': {
265            const { cursor: newCursor, killed } = cursor.deleteToLineStart()
266            pushToKillRing(killed, 'prepend')
267            setQueryState(newCursor.text)
268            setCursorOffset(newCursor.offset)
269            return
270          }
271          case 'w': {
272            const { cursor: newCursor, killed } = cursor.deleteWordBefore()
273            pushToKillRing(killed, 'prepend')
274            setQueryState(newCursor.text)
275            setCursorOffset(newCursor.offset)
276            return
277          }
278          case 'y': {
279            const text = getLastKill()
280            if (text.length > 0) {
281              const startOffset = cursor.offset
282              const newCursor = cursor.insert(text)
283              recordYank(startOffset, text.length)
284              setQueryState(newCursor.text)
285              setCursorOffset(newCursor.offset)
286            }
287            return
288          }
289          case 'g':
290          case 'c':
291            // Cancel (abandon search). ctrl+g is less's cancel key. Only
292            // fires if onCancel provided — otherwise falls through and
293            // returns silently (11 call sites, most expect ctrl+c to no-op).
294            if (onCancel) {
295              onCancel()
296              return
297            }
298        }
299        return
300      }
301  
302      // Meta key bindings
303      if (e.meta) {
304        e.preventDefault()
305        switch (e.key.toLowerCase()) {
306          case 'b':
307            setCursorOffset(cursor.prevWord().offset)
308            return
309          case 'f':
310            setCursorOffset(cursor.nextWord().offset)
311            return
312          case 'd': {
313            const newCursor = cursor.deleteWordAfter()
314            setQueryState(newCursor.text)
315            setCursorOffset(newCursor.offset)
316            return
317          }
318          case 'y': {
319            const popResult = yankPop()
320            if (popResult) {
321              const { text, start, length } = popResult
322              const before = query.slice(0, start)
323              const after = query.slice(start + length)
324              const newText = before + text + after
325              const newOffset = start + text.length
326              updateYankLength(text.length)
327              setQueryState(newText)
328              setCursorOffset(newOffset)
329            }
330            return
331          }
332        }
333        return
334      }
335  
336      // Tab: ignore
337      if (e.key === 'tab') {
338        return
339      }
340  
341      // Regular character input. Accepts multi-char e.key so batched writes
342      // (stdin.write('abc') in tests, or paste outside bracketed-paste mode)
343      // insert the full chunk — matching the old useInput behavior.
344      if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
345        e.preventDefault()
346        const newCursor = cursor.insert(e.key)
347        setQueryState(newCursor.text)
348        setCursorOffset(newCursor.offset)
349      }
350    }
351  
352    // Backward-compat bridge: existing consumers don't yet wire handleKeyDown
353    // to <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
354    // KeyboardEvent until all 11 call sites are migrated (separate PRs).
355    // TODO(onKeyDown-migration): remove once all consumers pass handleKeyDown.
356    useInput(
357      (_input, _key, event) => {
358        handleKeyDown(new KeyboardEvent(event.keypress))
359      },
360      { isActive },
361    )
362  
363    return { query, setQuery, cursorOffset, handleKeyDown }
364  }