useSearchInput.ts
hooks/useSearchInput.ts
No strong subsystem tag
365
Lines
10327
Bytes
1
Exports
5
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 365 lines, 5 detected imports, and 1 detected exports.
Important relationships
Detected exports
useSearchInput
Keywords
newcursorcursorsetcursoroffsettextoffsetcaselengthsetquerystatequerypreventdefault
Detected imports
react../ink/events/keyboard-event.js../ink.js../utils/Cursor.js./useTerminalSize.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
import { useCallback, useState } from 'react'
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 {
Cursor,
getLastKill,
pushToKillRing,
recordYank,
resetKillAccumulation,
resetYankState,
updateYankLength,
yankPop,
} from '../utils/Cursor.js'
import { useTerminalSize } from './useTerminalSize.js'
type UseSearchInputOptions = {
isActive: boolean
onExit: () => void
/** Esc + Ctrl+C abandon (distinct from onExit = Enter commit). When
* provided: single-Esc calls this directly (no clear-first-then-exit
* two-press). When absent: current behavior — Esc clears non-empty
* query, exits on empty; Ctrl+C silently swallowed (no switch case). */
onCancel?: () => void
onExitUp?: () => void
columns?: number
passthroughCtrlKeys?: string[]
initialQuery?: string
/** Backspace (and ctrl+h) on empty query calls onCancel ?? onExit — the
* less/vim "delete past the /" convention. Dialogs that want Esc-only
* cancel set this false so a held backspace doesn't eject the user. */
backspaceExitsOnEmpty?: boolean
}
type UseSearchInputReturn = {
query: string
setQuery: (q: string) => void
cursorOffset: number
handleKeyDown: (e: KeyboardEvent) => void
}
function isKillKey(e: KeyboardEvent): boolean {
if (e.ctrl && (e.key === 'k' || e.key === 'u' || e.key === 'w')) {
return true
}
if (e.meta && e.key === 'backspace') {
return true
}
return false
}
function isYankKey(e: KeyboardEvent): boolean {
return (e.ctrl || e.meta) && e.key === 'y'
}
// Special key names that fall through the explicit handlers above the
// text-input branch (return/escape/arrows/home/end/tab/backspace/delete
// all early-return). Reject these so e.g. PageUp doesn't leak 'pageup'
// as literal text. The length>=1 check below is intentionally loose —
// batched input like stdin.write('abc') arrives as one multi-char e.key,
// matching the old useInput(input) behavior where cursor.insert(input)
// inserted the full chunk.
const UNHANDLED_SPECIAL_KEYS = new Set([
'pageup',
'pagedown',
'insert',
'wheelup',
'wheeldown',
'mouse',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12',
])
export function useSearchInput({
isActive,
onExit,
onCancel,
onExitUp,
columns,
passthroughCtrlKeys = [],
initialQuery = '',
backspaceExitsOnEmpty = true,
}: UseSearchInputOptions): UseSearchInputReturn {
const { columns: terminalColumns } = useTerminalSize()
const effectiveColumns = columns ?? terminalColumns
const [query, setQueryState] = useState(initialQuery)
const [cursorOffset, setCursorOffset] = useState(initialQuery.length)
const setQuery = useCallback((q: string) => {
setQueryState(q)
setCursorOffset(q.length)
}, [])
const handleKeyDown = (e: KeyboardEvent): void => {
if (!isActive) return
const cursor = Cursor.fromText(query, effectiveColumns, cursorOffset)
// Check passthrough ctrl keys
if (e.ctrl && passthroughCtrlKeys.includes(e.key.toLowerCase())) {
return
}
// Reset kill accumulation for non-kill keys
if (!isKillKey(e)) {
resetKillAccumulation()
}
// Reset yank state for non-yank keys
if (!isYankKey(e)) {
resetYankState()
}
// Exit conditions
if (e.key === 'return' || e.key === 'down') {
e.preventDefault()
onExit()
return
}
if (e.key === 'up') {
e.preventDefault()
if (onExitUp) {
onExitUp()
}
return
}
if (e.key === 'escape') {
e.preventDefault()
if (onCancel) {
onCancel()
} else if (query.length > 0) {
setQueryState('')
setCursorOffset(0)
} else {
onExit()
}
return
}
// Backspace/Delete
if (e.key === 'backspace') {
e.preventDefault()
if (e.meta) {
// Meta+Backspace: kill word before
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
pushToKillRing(killed, 'prepend')
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
if (query.length === 0) {
// Backspace past the / — cancel (clear + snap back), not commit.
// less: same. vim: deletes the / and exits command mode.
if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
return
}
const newCursor = cursor.backspace()
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
if (e.key === 'delete') {
e.preventDefault()
const newCursor = cursor.del()
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
// Arrow keys with modifiers (word jump)
if (e.key === 'left' && (e.ctrl || e.meta || e.fn)) {
e.preventDefault()
const newCursor = cursor.prevWord()
setCursorOffset(newCursor.offset)
return
}
if (e.key === 'right' && (e.ctrl || e.meta || e.fn)) {
e.preventDefault()
const newCursor = cursor.nextWord()
setCursorOffset(newCursor.offset)
return
}
// Plain arrow keys
if (e.key === 'left') {
e.preventDefault()
const newCursor = cursor.left()
setCursorOffset(newCursor.offset)
return
}
if (e.key === 'right') {
e.preventDefault()
const newCursor = cursor.right()
setCursorOffset(newCursor.offset)
return
}
// Home/End
if (e.key === 'home') {
e.preventDefault()
setCursorOffset(0)
return
}
if (e.key === 'end') {
e.preventDefault()
setCursorOffset(query.length)
return
}
// Ctrl key bindings
if (e.ctrl) {
e.preventDefault()
switch (e.key.toLowerCase()) {
case 'a':
setCursorOffset(0)
return
case 'e':
setCursorOffset(query.length)
return
case 'b':
setCursorOffset(cursor.left().offset)
return
case 'f':
setCursorOffset(cursor.right().offset)
return
case 'd': {
if (query.length === 0) {
;(onCancel ?? onExit)()
return
}
const newCursor = cursor.del()
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
case 'h': {
if (query.length === 0) {
if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
return
}
const newCursor = cursor.backspace()
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
case 'k': {
const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
pushToKillRing(killed, 'append')
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
case 'u': {
const { cursor: newCursor, killed } = cursor.deleteToLineStart()
pushToKillRing(killed, 'prepend')
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
case 'w': {
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
pushToKillRing(killed, 'prepend')
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
case 'y': {
const text = getLastKill()
if (text.length > 0) {
const startOffset = cursor.offset
const newCursor = cursor.insert(text)
recordYank(startOffset, text.length)
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
}
return
}
case 'g':
case 'c':
// Cancel (abandon search). ctrl+g is less's cancel key. Only
// fires if onCancel provided — otherwise falls through and
// returns silently (11 call sites, most expect ctrl+c to no-op).
if (onCancel) {
onCancel()
return
}
}
return
}
// Meta key bindings
if (e.meta) {
e.preventDefault()
switch (e.key.toLowerCase()) {
case 'b':
setCursorOffset(cursor.prevWord().offset)
return
case 'f':
setCursorOffset(cursor.nextWord().offset)
return
case 'd': {
const newCursor = cursor.deleteWordAfter()
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
case 'y': {
const popResult = yankPop()
if (popResult) {
const { text, start, length } = popResult
const before = query.slice(0, start)
const after = query.slice(start + length)
const newText = before + text + after
const newOffset = start + text.length
updateYankLength(text.length)
setQueryState(newText)
setCursorOffset(newOffset)
}
return
}
}
return
}
// Tab: ignore
if (e.key === 'tab') {
return
}
// Regular character input. Accepts multi-char e.key so batched writes
// (stdin.write('abc') in tests, or paste outside bracketed-paste mode)
// insert the full chunk — matching the old useInput behavior.
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
e.preventDefault()
const newCursor = cursor.insert(e.key)
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
}
}
// Backward-compat bridge: existing consumers don't yet wire handleKeyDown
// to <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
// KeyboardEvent until all 11 call sites are migrated (separate PRs).
// TODO(onKeyDown-migration): remove once all consumers pass handleKeyDown.
useInput(
(_input, _key, event) => {
handleKeyDown(new KeyboardEvent(event.keypress))
},
{ isActive },
)
return { query, setQuery, cursorOffset, handleKeyDown }
}