Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
10 kB · 364 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365import { 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 }}