Kode-cli/src/hooks/useTextInput.ts
CrazyBoyM 926df2cfaf feat: Ultra-redesign completion system with @mention integration
- Complete architectural overhaul of useUnifiedCompletion hook
- Unified state management: 8 separate states → single CompletionState interface
- Simplified core logic: getWordAtCursor 194 lines → 42 lines (78% reduction)
- Fixed infinite React update loops with ref-based input tracking
- Smart triggering mechanism replacing aggressive auto-completion
- Integrated @agent and @file mention system with system reminders
- Added comprehensive agent loading and mention processing
- Enhanced Tab/Arrow/Enter key handling with clean event management
- Maintained 100% functional compatibility across all completion types

Key improvements:
• File path completion (relative, absolute, ~expansion, @references)
• Slash command completion (/help, /model, etc.)
• Agent completion (@agent-xxx with intelligent descriptions)
• System command completion (PATH scanning with fallback)
• Terminal-style Tab cycling, Enter confirmation, Escape cancellation
• Preview mode with boundary calculation
• History navigation compatibility
• Empty directory handling with user feedback

Architecture: Event-driven @mention detection → system reminder injection → LLM tool usage
Performance: Eliminated 7-layer nested conditionals, reduced state synchronization issues
Reliability: Fixed maximum update depth exceeded warnings, stable state management
2025-08-21 01:21:12 +08:00

319 lines
8.1 KiB
TypeScript

import { useState } from 'react'
import { type Key } from 'ink'
import { useDoublePress } from './useDoublePress'
import { Cursor } from '../utils/Cursor'
import {
getImageFromClipboard,
CLIPBOARD_ERROR_MESSAGE,
} from '../utils/imagePaste.js'
const IMAGE_PLACEHOLDER = '[Image pasted]'
type MaybeCursor = void | Cursor
type InputHandler = (input: string) => MaybeCursor
type InputMapper = (input: string) => MaybeCursor
function mapInput(input_map: Array<[string, InputHandler]>): InputMapper {
return function (input: string): MaybeCursor {
const handler = new Map(input_map).get(input) ?? (() => {})
return handler(input)
}
}
type UseTextInputProps = {
value: string
onChange: (value: string) => void
onSubmit?: (value: string) => void
onExit?: () => void
onExitMessage?: (show: boolean, key?: string) => void
onMessage?: (show: boolean, message?: string) => void
onHistoryUp?: () => void
onHistoryDown?: () => void
onHistoryReset?: () => void
focus?: boolean
mask?: string
multiline?: boolean
cursorChar: string
highlightPastedText?: boolean
invert: (text: string) => string
themeText: (text: string) => string
columns: number
onImagePaste?: (base64Image: string) => void
disableCursorMovementForUpDownKeys?: boolean
externalOffset: number
onOffsetChange: (offset: number) => void
}
type UseTextInputResult = {
renderedValue: string
onInput: (input: string, key: Key) => void
offset: number
setOffset: (offset: number) => void
}
export function useTextInput({
value: originalValue,
onChange,
onSubmit,
onExit,
onExitMessage,
onMessage,
onHistoryUp,
onHistoryDown,
onHistoryReset,
mask = '',
multiline = false,
cursorChar,
invert,
columns,
onImagePaste,
disableCursorMovementForUpDownKeys = false,
externalOffset,
onOffsetChange,
}: UseTextInputProps): UseTextInputResult {
const offset = externalOffset
const setOffset = onOffsetChange
const cursor = Cursor.fromText(originalValue, columns, offset)
const [imagePasteErrorTimeout, setImagePasteErrorTimeout] =
useState<NodeJS.Timeout | null>(null)
function maybeClearImagePasteErrorTimeout() {
if (!imagePasteErrorTimeout) {
return
}
clearTimeout(imagePasteErrorTimeout)
setImagePasteErrorTimeout(null)
onMessage?.(false)
}
const handleCtrlC = useDoublePress(
show => {
maybeClearImagePasteErrorTimeout()
onExitMessage?.(show, 'Ctrl-C')
},
() => onExit?.(),
() => {
if (originalValue) {
onChange('')
onHistoryReset?.()
}
},
)
// Keep Escape for clearing input
const handleEscape = useDoublePress(
show => {
maybeClearImagePasteErrorTimeout()
onMessage?.(!!originalValue && show, `Press Escape again to clear`)
},
() => {
if (originalValue) {
onChange('')
}
},
)
function clear() {
return Cursor.fromText('', columns, 0)
}
const handleEmptyCtrlD = useDoublePress(
show => onExitMessage?.(show, 'Ctrl-D'),
() => onExit?.(),
)
function handleCtrlD(): MaybeCursor {
maybeClearImagePasteErrorTimeout()
if (cursor.text === '') {
// When input is empty, handle double-press
handleEmptyCtrlD()
return cursor
}
// When input is not empty, delete forward like iPython
return cursor.del()
}
function tryImagePaste() {
const base64Image = getImageFromClipboard()
if (base64Image === null) {
if (process.platform !== 'darwin') {
return cursor
}
onMessage?.(true, CLIPBOARD_ERROR_MESSAGE)
maybeClearImagePasteErrorTimeout()
setImagePasteErrorTimeout(
// @ts-expect-error: Bun is overloading types here, but we're using the NodeJS runtime
setTimeout(() => {
onMessage?.(false)
}, 4000),
)
return cursor
}
onImagePaste?.(base64Image)
return cursor.insert(IMAGE_PLACEHOLDER)
}
const handleCtrl = mapInput([
['a', () => cursor.startOfLine()],
['b', () => cursor.left()],
['c', handleCtrlC],
['d', handleCtrlD],
['e', () => cursor.endOfLine()],
['f', () => cursor.right()],
[
'h',
() => {
maybeClearImagePasteErrorTimeout()
return cursor.backspace()
},
],
['k', () => cursor.deleteToLineEnd()],
['l', () => clear()],
['n', () => downOrHistoryDown()],
['p', () => upOrHistoryUp()],
['u', () => cursor.deleteToLineStart()],
['v', tryImagePaste],
['w', () => cursor.deleteWordBefore()],
])
const handleMeta = mapInput([
['b', () => cursor.prevWord()],
['f', () => cursor.nextWord()],
['d', () => cursor.deleteWordAfter()],
])
function handleEnter(key: Key) {
if (
multiline &&
cursor.offset > 0 &&
cursor.text[cursor.offset - 1] === '\\'
) {
return cursor.backspace().insert('\n')
}
if (key.meta) {
return cursor.insert('\n')
}
onSubmit?.(originalValue)
}
function upOrHistoryUp() {
if (disableCursorMovementForUpDownKeys) {
onHistoryUp?.()
return cursor
}
const cursorUp = cursor.up()
if (cursorUp.equals(cursor)) {
// already at beginning
onHistoryUp?.()
}
return cursorUp
}
function downOrHistoryDown() {
if (disableCursorMovementForUpDownKeys) {
onHistoryDown?.()
return cursor
}
const cursorDown = cursor.down()
if (cursorDown.equals(cursor)) {
onHistoryDown?.()
}
return cursorDown
}
function onInput(input: string, key: Key): void {
if (key.tab) {
return // Skip Tab key processing - let completion system handle it
}
// Direct handling for backspace or delete (which is being detected as delete)
if (
key.backspace ||
key.delete ||
input === '\b' ||
input === '\x7f' ||
input === '\x08'
) {
const nextCursor = cursor.backspace()
if (!cursor.equals(nextCursor)) {
setOffset(nextCursor.offset)
if (cursor.text !== nextCursor.text) {
onChange(nextCursor.text)
}
}
return
}
const nextCursor = mapKey(key)(input)
if (nextCursor) {
if (!cursor.equals(nextCursor)) {
setOffset(nextCursor.offset)
if (cursor.text !== nextCursor.text) {
onChange(nextCursor.text)
}
}
}
}
function mapKey(key: Key): InputMapper {
// Direct handling for backspace or delete
if (key.backspace || key.delete) {
maybeClearImagePasteErrorTimeout()
return () => cursor.backspace()
}
switch (true) {
case key.escape:
return handleEscape
case key.leftArrow && (key.ctrl || key.meta || key.fn):
return () => cursor.prevWord()
case key.rightArrow && (key.ctrl || key.meta || key.fn):
return () => cursor.nextWord()
case key.ctrl:
return handleCtrl
case key.home:
return () => cursor.startOfLine()
case key.end:
return () => cursor.endOfLine()
case key.pageDown:
return () => cursor.endOfLine()
case key.pageUp:
return () => cursor.startOfLine()
case key.meta:
return handleMeta
case key.return:
return () => handleEnter(key)
// Remove Tab handling - let completion system handle it
case key.upArrow:
return upOrHistoryUp
case key.downArrow:
return downOrHistoryDown
case key.leftArrow:
return () => cursor.left()
case key.rightArrow:
return () => cursor.right()
}
return function (input: string) {
switch (true) {
// Home key
case input == '\x1b[H' || input == '\x1b[1~':
return cursor.startOfLine()
// End key
case input == '\x1b[F' || input == '\x1b[4~':
return cursor.endOfLine()
// Handle backspace character explicitly - this is the key fix
case input === '\b' || input === '\x7f' || input === '\x08':
maybeClearImagePasteErrorTimeout()
return cursor.backspace()
default:
return cursor.insert(input.replace(/\r/g, '\n'))
}
}
}
return {
onInput,
renderedValue: cursor.render(cursorChar, mask, invert),
offset,
setOffset,
}
}