init claude-code
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import type { Key } from '../ink.js'
|
||||
import type { VimInputState, VimMode } from '../types/textInputTypes.js'
|
||||
import { Cursor } from '../utils/Cursor.js'
|
||||
import { lastGrapheme } from '../utils/intl.js'
|
||||
import {
|
||||
executeIndent,
|
||||
executeJoin,
|
||||
executeOpenLine,
|
||||
executeOperatorFind,
|
||||
executeOperatorMotion,
|
||||
executeOperatorTextObj,
|
||||
executeReplace,
|
||||
executeToggleCase,
|
||||
executeX,
|
||||
type OperatorContext,
|
||||
} from '../vim/operators.js'
|
||||
import { type TransitionContext, transition } from '../vim/transitions.js'
|
||||
import {
|
||||
createInitialPersistentState,
|
||||
createInitialVimState,
|
||||
type PersistentState,
|
||||
type RecordedChange,
|
||||
type VimState,
|
||||
} from '../vim/types.js'
|
||||
import { type UseTextInputProps, useTextInput } from './useTextInput.js'
|
||||
|
||||
type UseVimInputProps = Omit<UseTextInputProps, 'inputFilter'> & {
|
||||
onModeChange?: (mode: VimMode) => void
|
||||
onUndo?: () => void
|
||||
inputFilter?: UseTextInputProps['inputFilter']
|
||||
}
|
||||
|
||||
export function useVimInput(props: UseVimInputProps): VimInputState {
|
||||
const vimStateRef = React.useRef<VimState>(createInitialVimState())
|
||||
const [mode, setMode] = useState<VimMode>('INSERT')
|
||||
|
||||
const persistentRef = React.useRef<PersistentState>(
|
||||
createInitialPersistentState(),
|
||||
)
|
||||
|
||||
// inputFilter is applied once at the top of handleVimInput (not here) so
|
||||
// vim-handled paths that return without calling textInput.onInput still
|
||||
// run the filter — otherwise a stateful filter (e.g. lazy-space-after-
|
||||
// pill) stays armed across an Escape → NORMAL → INSERT round-trip.
|
||||
const textInput = useTextInput({ ...props, inputFilter: undefined })
|
||||
const { onModeChange, inputFilter } = props
|
||||
|
||||
const switchToInsertMode = useCallback(
|
||||
(offset?: number): void => {
|
||||
if (offset !== undefined) {
|
||||
textInput.setOffset(offset)
|
||||
}
|
||||
vimStateRef.current = { mode: 'INSERT', insertedText: '' }
|
||||
setMode('INSERT')
|
||||
onModeChange?.('INSERT')
|
||||
},
|
||||
[textInput, onModeChange],
|
||||
)
|
||||
|
||||
const switchToNormalMode = useCallback((): void => {
|
||||
const current = vimStateRef.current
|
||||
if (current.mode === 'INSERT' && current.insertedText) {
|
||||
persistentRef.current.lastChange = {
|
||||
type: 'insert',
|
||||
text: current.insertedText,
|
||||
}
|
||||
}
|
||||
|
||||
// Vim behavior: move cursor left by 1 when exiting insert mode
|
||||
// (unless at beginning of line or at offset 0)
|
||||
const offset = textInput.offset
|
||||
if (offset > 0 && props.value[offset - 1] !== '\n') {
|
||||
textInput.setOffset(offset - 1)
|
||||
}
|
||||
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
setMode('NORMAL')
|
||||
onModeChange?.('NORMAL')
|
||||
}, [onModeChange, textInput, props.value])
|
||||
|
||||
function createOperatorContext(
|
||||
cursor: Cursor,
|
||||
isReplay: boolean = false,
|
||||
): OperatorContext {
|
||||
return {
|
||||
cursor,
|
||||
text: props.value,
|
||||
setText: (newText: string) => props.onChange(newText),
|
||||
setOffset: (offset: number) => textInput.setOffset(offset),
|
||||
enterInsert: (offset: number) => switchToInsertMode(offset),
|
||||
getRegister: () => persistentRef.current.register,
|
||||
setRegister: (content: string, linewise: boolean) => {
|
||||
persistentRef.current.register = content
|
||||
persistentRef.current.registerIsLinewise = linewise
|
||||
},
|
||||
getLastFind: () => persistentRef.current.lastFind,
|
||||
setLastFind: (type, char) => {
|
||||
persistentRef.current.lastFind = { type, char }
|
||||
},
|
||||
recordChange: isReplay
|
||||
? () => {}
|
||||
: (change: RecordedChange) => {
|
||||
persistentRef.current.lastChange = change
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function replayLastChange(): void {
|
||||
const change = persistentRef.current.lastChange
|
||||
if (!change) return
|
||||
|
||||
const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
|
||||
const ctx = createOperatorContext(cursor, true)
|
||||
|
||||
switch (change.type) {
|
||||
case 'insert':
|
||||
if (change.text) {
|
||||
const newCursor = cursor.insert(change.text)
|
||||
props.onChange(newCursor.text)
|
||||
textInput.setOffset(newCursor.offset)
|
||||
}
|
||||
break
|
||||
|
||||
case 'x':
|
||||
executeX(change.count, ctx)
|
||||
break
|
||||
|
||||
case 'replace':
|
||||
executeReplace(change.char, change.count, ctx)
|
||||
break
|
||||
|
||||
case 'toggleCase':
|
||||
executeToggleCase(change.count, ctx)
|
||||
break
|
||||
|
||||
case 'indent':
|
||||
executeIndent(change.dir, change.count, ctx)
|
||||
break
|
||||
|
||||
case 'join':
|
||||
executeJoin(change.count, ctx)
|
||||
break
|
||||
|
||||
case 'openLine':
|
||||
executeOpenLine(change.direction, ctx)
|
||||
break
|
||||
|
||||
case 'operator':
|
||||
executeOperatorMotion(change.op, change.motion, change.count, ctx)
|
||||
break
|
||||
|
||||
case 'operatorFind':
|
||||
executeOperatorFind(
|
||||
change.op,
|
||||
change.find,
|
||||
change.char,
|
||||
change.count,
|
||||
ctx,
|
||||
)
|
||||
break
|
||||
|
||||
case 'operatorTextObj':
|
||||
executeOperatorTextObj(
|
||||
change.op,
|
||||
change.scope,
|
||||
change.objType,
|
||||
change.count,
|
||||
ctx,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleVimInput(rawInput: string, key: Key): void {
|
||||
const state = vimStateRef.current
|
||||
// Run inputFilter in all modes so stateful filters disarm on any key,
|
||||
// but only apply the transformed input in INSERT — NORMAL-mode command
|
||||
// lookups expect single chars and a prepended space would break them.
|
||||
const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
|
||||
const input = state.mode === 'INSERT' ? filtered : rawInput
|
||||
const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
|
||||
|
||||
if (key.ctrl) {
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system.
|
||||
// It's vim's standard INSERT->NORMAL mode switch - a vim-specific behavior that should not be
|
||||
// configurable via keybindings. Vim users expect Esc to always exit INSERT mode.
|
||||
if (key.escape && state.mode === 'INSERT') {
|
||||
switchToNormalMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Escape in NORMAL mode cancels any pending command (replace, operator, etc.)
|
||||
if (key.escape && state.mode === 'NORMAL') {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
return
|
||||
}
|
||||
|
||||
// Pass Enter to base handler regardless of mode (allows submission from NORMAL)
|
||||
if (key.return) {
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.mode === 'INSERT') {
|
||||
// Track inserted text for dot-repeat
|
||||
if (key.backspace || key.delete) {
|
||||
if (state.insertedText.length > 0) {
|
||||
vimStateRef.current = {
|
||||
mode: 'INSERT',
|
||||
insertedText: state.insertedText.slice(
|
||||
0,
|
||||
-(lastGrapheme(state.insertedText).length || 1),
|
||||
),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
vimStateRef.current = {
|
||||
mode: 'INSERT',
|
||||
insertedText: state.insertedText + input,
|
||||
}
|
||||
}
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.mode !== 'NORMAL') {
|
||||
return
|
||||
}
|
||||
|
||||
// In idle state, delegate arrow keys to base handler for cursor movement
|
||||
// and history fallback (upOrHistoryUp / downOrHistoryDown)
|
||||
if (
|
||||
state.command.type === 'idle' &&
|
||||
(key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
|
||||
) {
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
const ctx: TransitionContext = {
|
||||
...createOperatorContext(cursor, false),
|
||||
onUndo: props.onUndo,
|
||||
onDotRepeat: replayLastChange,
|
||||
}
|
||||
|
||||
// Backspace/Delete are only mapped in motion-expecting states. In
|
||||
// literal-char states (replace, find, operatorFind), mapping would turn
|
||||
// r+Backspace into "replace with h" and df+Delete into "delete to next x".
|
||||
// Delete additionally skips count state: in vim, N<Del> removes a count
|
||||
// digit rather than executing Nx; we don't implement digit removal but
|
||||
// should at least not turn a cancel into a destructive Nx.
|
||||
const expectsMotion =
|
||||
state.command.type === 'idle' ||
|
||||
state.command.type === 'count' ||
|
||||
state.command.type === 'operator' ||
|
||||
state.command.type === 'operatorCount'
|
||||
|
||||
// Map arrow keys to vim motions in NORMAL mode
|
||||
let vimInput = input
|
||||
if (key.leftArrow) vimInput = 'h'
|
||||
else if (key.rightArrow) vimInput = 'l'
|
||||
else if (key.upArrow) vimInput = 'k'
|
||||
else if (key.downArrow) vimInput = 'j'
|
||||
else if (expectsMotion && key.backspace) vimInput = 'h'
|
||||
else if (expectsMotion && state.command.type !== 'count' && key.delete)
|
||||
vimInput = 'x'
|
||||
|
||||
const result = transition(state.command, vimInput, ctx)
|
||||
|
||||
if (result.execute) {
|
||||
result.execute()
|
||||
}
|
||||
|
||||
// Update command state (only if execute didn't switch to INSERT)
|
||||
if (vimStateRef.current.mode === 'NORMAL') {
|
||||
if (result.next) {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: result.next }
|
||||
} else if (result.execute) {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input === '?' &&
|
||||
state.mode === 'NORMAL' &&
|
||||
state.command.type === 'idle'
|
||||
) {
|
||||
props.onChange('?')
|
||||
}
|
||||
}
|
||||
|
||||
const setModeExternal = useCallback(
|
||||
(newMode: VimMode) => {
|
||||
if (newMode === 'INSERT') {
|
||||
vimStateRef.current = { mode: 'INSERT', insertedText: '' }
|
||||
} else {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
}
|
||||
setMode(newMode)
|
||||
onModeChange?.(newMode)
|
||||
},
|
||||
[onModeChange],
|
||||
)
|
||||
|
||||
return {
|
||||
...textInput,
|
||||
onInput: handleVimInput,
|
||||
mode,
|
||||
setMode: setModeExternal,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user