2026-04-07 20:10:33 -05:00
|
|
|
import { Text, useInput } from 'ink'
|
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
|
|
2026-04-08 14:18:37 -05:00
|
|
|
function wordLeft(s: string, p: number) {
|
2026-04-07 20:10:33 -05:00
|
|
|
let i = p - 1
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
while (i > 0 && /\s/.test(s[i]!)) {i--}
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
while (i > 0 && !/\s/.test(s[i - 1]!)) {i--}
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
return Math.max(0, i)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 14:18:37 -05:00
|
|
|
function wordRight(s: string, p: number) {
|
2026-04-07 20:10:33 -05:00
|
|
|
let i = p
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
while (i < s.length && !/\s/.test(s[i]!)) {i++}
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
while (i < s.length && /\s/.test(s[i]!)) {i++}
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 14:18:37 -05:00
|
|
|
const ESC = '\x1b'
|
2026-04-07 20:10:33 -05:00
|
|
|
const INV = ESC + '[7m'
|
|
|
|
|
const INV_OFF = ESC + '[27m'
|
|
|
|
|
const DIM = ESC + '[2m'
|
|
|
|
|
const DIM_OFF = ESC + '[22m'
|
|
|
|
|
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
|
2026-04-09 13:45:23 -05:00
|
|
|
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
|
|
|
|
|
|
|
|
|
|
export interface PasteEvent {
|
|
|
|
|
bracketed?: boolean
|
|
|
|
|
cursor: number
|
|
|
|
|
hotkey?: boolean
|
|
|
|
|
text: string
|
|
|
|
|
value: string
|
|
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
value: string
|
|
|
|
|
onChange: (v: string) => void
|
|
|
|
|
onSubmit?: (v: string) => void
|
2026-04-09 13:45:23 -05:00
|
|
|
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
2026-04-07 20:10:33 -05:00
|
|
|
placeholder?: string
|
|
|
|
|
focus?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 13:45:34 -05:00
|
|
|
export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) {
|
2026-04-07 20:10:33 -05:00
|
|
|
const [cur, setCur] = useState(value.length)
|
2026-04-09 12:21:24 -05:00
|
|
|
|
2026-04-09 00:36:53 -05:00
|
|
|
const curRef = useRef(cur)
|
2026-04-07 20:10:33 -05:00
|
|
|
const vRef = useRef(value)
|
|
|
|
|
const selfChange = useRef(false)
|
|
|
|
|
const pasteBuf = useRef('')
|
|
|
|
|
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
|
const pastePos = useRef(0)
|
2026-04-09 12:21:24 -05:00
|
|
|
const undoStack = useRef<Array<{ cursor: number; value: string }>>([])
|
|
|
|
|
const redoStack = useRef<Array<{ cursor: number; value: string }>>([])
|
|
|
|
|
|
|
|
|
|
const onChangeRef = useRef(onChange)
|
|
|
|
|
const onSubmitRef = useRef(onSubmit)
|
|
|
|
|
const onPasteRef = useRef(onPaste)
|
|
|
|
|
onChangeRef.current = onChange
|
|
|
|
|
onSubmitRef.current = onSubmit
|
|
|
|
|
onPasteRef.current = onPaste
|
2026-04-07 20:10:33 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-07 20:30:22 -05:00
|
|
|
if (selfChange.current) {
|
|
|
|
|
selfChange.current = false
|
|
|
|
|
} else {
|
|
|
|
|
setCur(value.length)
|
2026-04-09 00:36:53 -05:00
|
|
|
curRef.current = value.length
|
2026-04-09 12:21:24 -05:00
|
|
|
vRef.current = value
|
|
|
|
|
undoStack.current = []
|
|
|
|
|
redoStack.current = []
|
2026-04-07 20:30:22 -05:00
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
}, [value])
|
|
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
useEffect(() => () => { if (pasteTimer.current) {clearTimeout(pasteTimer.current)} }, [])
|
2026-04-09 12:21:24 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
// ── Buffer ops (synchronous, ref-based) ─────────────────────────
|
2026-04-09 12:21:24 -05:00
|
|
|
|
|
|
|
|
const commit = (next: string, nextCur: number, track = true) => {
|
|
|
|
|
const prev = vRef.current
|
|
|
|
|
const c = Math.max(0, Math.min(nextCur, next.length))
|
2026-04-09 00:36:53 -05:00
|
|
|
|
2026-04-09 12:21:24 -05:00
|
|
|
if (track && next !== prev) {
|
|
|
|
|
undoStack.current.push({ cursor: curRef.current, value: prev })
|
2026-04-09 00:36:53 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (undoStack.current.length > 200) {undoStack.current.shift()}
|
2026-04-09 12:21:24 -05:00
|
|
|
redoStack.current = []
|
2026-04-09 00:36:53 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-08 14:18:37 -05:00
|
|
|
setCur(c)
|
2026-04-09 00:36:53 -05:00
|
|
|
curRef.current = c
|
2026-04-09 12:21:24 -05:00
|
|
|
vRef.current = next
|
2026-04-08 14:18:37 -05:00
|
|
|
|
2026-04-09 12:21:24 -05:00
|
|
|
if (next !== prev) {
|
2026-04-08 14:18:37 -05:00
|
|
|
selfChange.current = true
|
2026-04-09 12:21:24 -05:00
|
|
|
onChangeRef.current(next)
|
2026-04-08 14:18:37 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 12:21:24 -05:00
|
|
|
const swap = (from: typeof undoStack, to: typeof redoStack) => {
|
|
|
|
|
const entry = from.current.pop()
|
|
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (!entry) {return}
|
2026-04-09 12:21:24 -05:00
|
|
|
to.current.push({ cursor: curRef.current, value: vRef.current })
|
|
|
|
|
commit(entry.value, entry.cursor, false)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
const emitPaste = (e: PasteEvent) => {
|
|
|
|
|
const handled = onPasteRef.current?.(e)
|
|
|
|
|
|
|
|
|
|
if (handled) {commit(handled.value, handled.cursor)}
|
|
|
|
|
|
|
|
|
|
return !!handled
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
const flushPaste = () => {
|
2026-04-09 13:45:23 -05:00
|
|
|
const text = pasteBuf.current
|
2026-04-07 20:10:33 -05:00
|
|
|
const at = pastePos.current
|
|
|
|
|
pasteBuf.current = ''
|
|
|
|
|
pasteTimer.current = null
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (!text) {return}
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) {
|
|
|
|
|
commit(vRef.current.slice(0, at) + text + vRef.current.slice(at), at + text.length)
|
2026-04-07 20:10:33 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
const insert = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
|
|
|
|
|
|
|
|
|
// ── Input handler ───────────────────────────────────────────────
|
2026-04-09 12:21:24 -05:00
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
useInput(
|
|
|
|
|
(inp, k) => {
|
2026-04-09 13:45:23 -05:00
|
|
|
// Paste hotkeys — single owner, no competing listeners in App
|
|
|
|
|
if ((k.ctrl || k.meta) && inp.toLowerCase() === 'v') {
|
|
|
|
|
emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keys handled by App.useInput
|
2026-04-07 20:30:22 -05:00
|
|
|
if (
|
2026-04-09 13:45:23 -05:00
|
|
|
k.upArrow || k.downArrow ||
|
2026-04-07 20:30:22 -05:00
|
|
|
(k.ctrl && inp === 'c') ||
|
2026-04-09 13:45:23 -05:00
|
|
|
k.tab || (k.shift && k.tab) ||
|
|
|
|
|
k.pageUp || k.pageDown ||
|
2026-04-07 20:30:22 -05:00
|
|
|
k.escape
|
2026-04-09 13:45:23 -05:00
|
|
|
) {return}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
2026-04-07 20:30:22 -05:00
|
|
|
if (k.return) {
|
2026-04-09 13:45:23 -05:00
|
|
|
;(k.shift || k.meta)
|
|
|
|
|
? commit(insert(vRef.current, curRef.current, '\n'), curRef.current + 1)
|
|
|
|
|
: onSubmitRef.current?.(vRef.current)
|
2026-04-07 20:10:33 -05:00
|
|
|
|
2026-04-07 20:30:22 -05:00
|
|
|
return
|
2026-04-07 20:10:33 -05:00
|
|
|
}
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 12:21:24 -05:00
|
|
|
let c = curRef.current
|
|
|
|
|
let v = vRef.current
|
2026-04-07 20:30:22 -05:00
|
|
|
const mod = k.ctrl || k.meta
|
|
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (k.ctrl && inp === 'z') {return swap(undoStack, redoStack)}
|
2026-04-09 00:36:53 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) {return swap(redoStack, undoStack)}
|
2026-04-09 00:36:53 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (k.home || (k.ctrl && inp === 'a')) {c = 0}
|
|
|
|
|
else if (k.end || (k.ctrl && inp === 'e')) {c = v.length}
|
|
|
|
|
else if (k.leftArrow) {c = mod ? wordLeft(v, c) : Math.max(0, c - 1)}
|
|
|
|
|
else if (k.rightArrow) {c = mod ? wordRight(v, c) : Math.min(v.length, c + 1)}
|
|
|
|
|
else if ((k.backspace || k.delete) && c > 0) {
|
|
|
|
|
if (mod) { const t = wordLeft(v, c); v = v.slice(0, t) + v.slice(c); c = t }
|
|
|
|
|
else { v = v.slice(0, c - 1) + v.slice(c); c-- }
|
|
|
|
|
}
|
|
|
|
|
else if (k.ctrl && inp === 'w' && c > 0) { const t = wordLeft(v, c); v = v.slice(0, t) + v.slice(c); c = t }
|
|
|
|
|
else if (k.ctrl && inp === 'u') { v = v.slice(c); c = 0 }
|
|
|
|
|
else if (k.ctrl && inp === 'k') {v = v.slice(0, c)}
|
|
|
|
|
else if (k.meta && inp === 'b') {c = wordLeft(v, c)}
|
|
|
|
|
else if (k.meta && inp === 'f') {c = wordRight(v, c)}
|
|
|
|
|
else if (inp.length > 0) {
|
|
|
|
|
const bracketed = inp.includes('[200~')
|
2026-04-07 20:10:33 -05:00
|
|
|
const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {return}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (!raw) {return}
|
|
|
|
|
|
|
|
|
|
if (raw === '\n') {return commit(insert(v, c, '\n'), c + 1)}
|
2026-04-08 23:59:56 -05:00
|
|
|
|
2026-04-08 14:18:37 -05:00
|
|
|
if (raw.length > 1 || raw.includes('\n')) {
|
2026-04-09 13:45:23 -05:00
|
|
|
if (!pasteBuf.current) {pastePos.current = c}
|
2026-04-07 20:10:33 -05:00
|
|
|
pasteBuf.current += raw
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (pasteTimer.current) {clearTimeout(pasteTimer.current)}
|
2026-04-07 20:10:33 -05:00
|
|
|
pasteTimer.current = setTimeout(flushPaste, 50)
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (PRINTABLE.test(raw)) { v = v.slice(0, c) + raw + v.slice(c); c += raw.length }
|
|
|
|
|
else {return}
|
|
|
|
|
} else {return}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
2026-04-08 14:18:37 -05:00
|
|
|
commit(v, c)
|
2026-04-07 20:10:33 -05:00
|
|
|
},
|
|
|
|
|
{ isActive: focus }
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-09 12:21:24 -05:00
|
|
|
// ── Render ──────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
if (!focus) {return <Text>{value || (placeholder ? DIM + placeholder + DIM_OFF : '')}</Text>}
|
2026-04-07 20:30:22 -05:00
|
|
|
|
|
|
|
|
if (!value && placeholder) {
|
|
|
|
|
return <Text>{INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF}</Text>
|
|
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
2026-04-09 13:45:23 -05:00
|
|
|
return (
|
|
|
|
|
<Text>
|
|
|
|
|
{[...value].map((ch, i) => (i === cur ? INV + ch + INV_OFF : ch)).join('') +
|
|
|
|
|
(cur === value.length ? INV + ' ' + INV_OFF : '')}
|
|
|
|
|
</Text>
|
|
|
|
|
)
|
2026-04-07 20:10:33 -05:00
|
|
|
}
|