init claude-code
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user