source dump of claude code
at main 316 lines 9.7 kB view raw
1import React, { useCallback, useState } from 'react' 2import type { Key } from '../ink.js' 3import type { VimInputState, VimMode } from '../types/textInputTypes.js' 4import { Cursor } from '../utils/Cursor.js' 5import { lastGrapheme } from '../utils/intl.js' 6import { 7 executeIndent, 8 executeJoin, 9 executeOpenLine, 10 executeOperatorFind, 11 executeOperatorMotion, 12 executeOperatorTextObj, 13 executeReplace, 14 executeToggleCase, 15 executeX, 16 type OperatorContext, 17} from '../vim/operators.js' 18import { type TransitionContext, transition } from '../vim/transitions.js' 19import { 20 createInitialPersistentState, 21 createInitialVimState, 22 type PersistentState, 23 type RecordedChange, 24 type VimState, 25} from '../vim/types.js' 26import { type UseTextInputProps, useTextInput } from './useTextInput.js' 27 28type UseVimInputProps = Omit<UseTextInputProps, 'inputFilter'> & { 29 onModeChange?: (mode: VimMode) => void 30 onUndo?: () => void 31 inputFilter?: UseTextInputProps['inputFilter'] 32} 33 34export function useVimInput(props: UseVimInputProps): VimInputState { 35 const vimStateRef = React.useRef<VimState>(createInitialVimState()) 36 const [mode, setMode] = useState<VimMode>('INSERT') 37 38 const persistentRef = React.useRef<PersistentState>( 39 createInitialPersistentState(), 40 ) 41 42 // inputFilter is applied once at the top of handleVimInput (not here) so 43 // vim-handled paths that return without calling textInput.onInput still 44 // run the filter — otherwise a stateful filter (e.g. lazy-space-after- 45 // pill) stays armed across an Escape → NORMAL → INSERT round-trip. 46 const textInput = useTextInput({ ...props, inputFilter: undefined }) 47 const { onModeChange, inputFilter } = props 48 49 const switchToInsertMode = useCallback( 50 (offset?: number): void => { 51 if (offset !== undefined) { 52 textInput.setOffset(offset) 53 } 54 vimStateRef.current = { mode: 'INSERT', insertedText: '' } 55 setMode('INSERT') 56 onModeChange?.('INSERT') 57 }, 58 [textInput, onModeChange], 59 ) 60 61 const switchToNormalMode = useCallback((): void => { 62 const current = vimStateRef.current 63 if (current.mode === 'INSERT' && current.insertedText) { 64 persistentRef.current.lastChange = { 65 type: 'insert', 66 text: current.insertedText, 67 } 68 } 69 70 // Vim behavior: move cursor left by 1 when exiting insert mode 71 // (unless at beginning of line or at offset 0) 72 const offset = textInput.offset 73 if (offset > 0 && props.value[offset - 1] !== '\n') { 74 textInput.setOffset(offset - 1) 75 } 76 77 vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } } 78 setMode('NORMAL') 79 onModeChange?.('NORMAL') 80 }, [onModeChange, textInput, props.value]) 81 82 function createOperatorContext( 83 cursor: Cursor, 84 isReplay: boolean = false, 85 ): OperatorContext { 86 return { 87 cursor, 88 text: props.value, 89 setText: (newText: string) => props.onChange(newText), 90 setOffset: (offset: number) => textInput.setOffset(offset), 91 enterInsert: (offset: number) => switchToInsertMode(offset), 92 getRegister: () => persistentRef.current.register, 93 setRegister: (content: string, linewise: boolean) => { 94 persistentRef.current.register = content 95 persistentRef.current.registerIsLinewise = linewise 96 }, 97 getLastFind: () => persistentRef.current.lastFind, 98 setLastFind: (type, char) => { 99 persistentRef.current.lastFind = { type, char } 100 }, 101 recordChange: isReplay 102 ? () => {} 103 : (change: RecordedChange) => { 104 persistentRef.current.lastChange = change 105 }, 106 } 107 } 108 109 function replayLastChange(): void { 110 const change = persistentRef.current.lastChange 111 if (!change) return 112 113 const cursor = Cursor.fromText(props.value, props.columns, textInput.offset) 114 const ctx = createOperatorContext(cursor, true) 115 116 switch (change.type) { 117 case 'insert': 118 if (change.text) { 119 const newCursor = cursor.insert(change.text) 120 props.onChange(newCursor.text) 121 textInput.setOffset(newCursor.offset) 122 } 123 break 124 125 case 'x': 126 executeX(change.count, ctx) 127 break 128 129 case 'replace': 130 executeReplace(change.char, change.count, ctx) 131 break 132 133 case 'toggleCase': 134 executeToggleCase(change.count, ctx) 135 break 136 137 case 'indent': 138 executeIndent(change.dir, change.count, ctx) 139 break 140 141 case 'join': 142 executeJoin(change.count, ctx) 143 break 144 145 case 'openLine': 146 executeOpenLine(change.direction, ctx) 147 break 148 149 case 'operator': 150 executeOperatorMotion(change.op, change.motion, change.count, ctx) 151 break 152 153 case 'operatorFind': 154 executeOperatorFind( 155 change.op, 156 change.find, 157 change.char, 158 change.count, 159 ctx, 160 ) 161 break 162 163 case 'operatorTextObj': 164 executeOperatorTextObj( 165 change.op, 166 change.scope, 167 change.objType, 168 change.count, 169 ctx, 170 ) 171 break 172 } 173 } 174 175 function handleVimInput(rawInput: string, key: Key): void { 176 const state = vimStateRef.current 177 // Run inputFilter in all modes so stateful filters disarm on any key, 178 // but only apply the transformed input in INSERT — NORMAL-mode command 179 // lookups expect single chars and a prepended space would break them. 180 const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput 181 const input = state.mode === 'INSERT' ? filtered : rawInput 182 const cursor = Cursor.fromText(props.value, props.columns, textInput.offset) 183 184 if (key.ctrl) { 185 textInput.onInput(input, key) 186 return 187 } 188 189 // NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system. 190 // It's vim's standard INSERT->NORMAL mode switch - a vim-specific behavior that should not be 191 // configurable via keybindings. Vim users expect Esc to always exit INSERT mode. 192 if (key.escape && state.mode === 'INSERT') { 193 switchToNormalMode() 194 return 195 } 196 197 // Escape in NORMAL mode cancels any pending command (replace, operator, etc.) 198 if (key.escape && state.mode === 'NORMAL') { 199 vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } } 200 return 201 } 202 203 // Pass Enter to base handler regardless of mode (allows submission from NORMAL) 204 if (key.return) { 205 textInput.onInput(input, key) 206 return 207 } 208 209 if (state.mode === 'INSERT') { 210 // Track inserted text for dot-repeat 211 if (key.backspace || key.delete) { 212 if (state.insertedText.length > 0) { 213 vimStateRef.current = { 214 mode: 'INSERT', 215 insertedText: state.insertedText.slice( 216 0, 217 -(lastGrapheme(state.insertedText).length || 1), 218 ), 219 } 220 } 221 } else { 222 vimStateRef.current = { 223 mode: 'INSERT', 224 insertedText: state.insertedText + input, 225 } 226 } 227 textInput.onInput(input, key) 228 return 229 } 230 231 if (state.mode !== 'NORMAL') { 232 return 233 } 234 235 // In idle state, delegate arrow keys to base handler for cursor movement 236 // and history fallback (upOrHistoryUp / downOrHistoryDown) 237 if ( 238 state.command.type === 'idle' && 239 (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) 240 ) { 241 textInput.onInput(input, key) 242 return 243 } 244 245 const ctx: TransitionContext = { 246 ...createOperatorContext(cursor, false), 247 onUndo: props.onUndo, 248 onDotRepeat: replayLastChange, 249 } 250 251 // Backspace/Delete are only mapped in motion-expecting states. In 252 // literal-char states (replace, find, operatorFind), mapping would turn 253 // r+Backspace into "replace with h" and df+Delete into "delete to next x". 254 // Delete additionally skips count state: in vim, N<Del> removes a count 255 // digit rather than executing Nx; we don't implement digit removal but 256 // should at least not turn a cancel into a destructive Nx. 257 const expectsMotion = 258 state.command.type === 'idle' || 259 state.command.type === 'count' || 260 state.command.type === 'operator' || 261 state.command.type === 'operatorCount' 262 263 // Map arrow keys to vim motions in NORMAL mode 264 let vimInput = input 265 if (key.leftArrow) vimInput = 'h' 266 else if (key.rightArrow) vimInput = 'l' 267 else if (key.upArrow) vimInput = 'k' 268 else if (key.downArrow) vimInput = 'j' 269 else if (expectsMotion && key.backspace) vimInput = 'h' 270 else if (expectsMotion && state.command.type !== 'count' && key.delete) 271 vimInput = 'x' 272 273 const result = transition(state.command, vimInput, ctx) 274 275 if (result.execute) { 276 result.execute() 277 } 278 279 // Update command state (only if execute didn't switch to INSERT) 280 if (vimStateRef.current.mode === 'NORMAL') { 281 if (result.next) { 282 vimStateRef.current = { mode: 'NORMAL', command: result.next } 283 } else if (result.execute) { 284 vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } } 285 } 286 } 287 288 if ( 289 input === '?' && 290 state.mode === 'NORMAL' && 291 state.command.type === 'idle' 292 ) { 293 props.onChange('?') 294 } 295 } 296 297 const setModeExternal = useCallback( 298 (newMode: VimMode) => { 299 if (newMode === 'INSERT') { 300 vimStateRef.current = { mode: 'INSERT', insertedText: '' } 301 } else { 302 vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } } 303 } 304 setMode(newMode) 305 onModeChange?.(newMode) 306 }, 307 [onModeChange], 308 ) 309 310 return { 311 ...textInput, 312 onInput: handleVimInput, 313 mode, 314 setMode: setModeExternal, 315 } 316}