2026-04-07 20:10:33 -05:00
|
|
|
import { Text, useInput } from 'ink'
|
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
|
|
|
|
|
|
function wl(s: string, p: number) {
|
|
|
|
|
let i = p - 1
|
2026-04-07 20:30:22 -05:00
|
|
|
|
|
|
|
|
while (i > 0 && /\s/.test(s[i]!)) {
|
|
|
|
|
i--
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (i > 0 && !/\s/.test(s[i - 1]!)) {
|
|
|
|
|
i--
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
return Math.max(0, i)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wr(s: string, p: number) {
|
|
|
|
|
let i = p
|
2026-04-07 20:30:22 -05:00
|
|
|
|
|
|
|
|
while (i < s.length && !/\s/.test(s[i]!)) {
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (i < s.length && /\s/.test(s[i]!)) {
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ESC = String.fromCharCode(0x1b)
|
|
|
|
|
const INV = ESC + '[7m'
|
|
|
|
|
const INV_OFF = ESC + '[27m'
|
|
|
|
|
const DIM = ESC + '[2m'
|
|
|
|
|
const DIM_OFF = ESC + '[22m'
|
|
|
|
|
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
|
2026-04-07 20:30:22 -05:00
|
|
|
const BRACKET_PASTE = new RegExp(`${ESC}\\[20[01]~`, 'g')
|
2026-04-07 20:10:33 -05:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
value: string
|
|
|
|
|
onChange: (v: string) => void
|
|
|
|
|
onSubmit?: (v: string) => void
|
2026-04-08 13:45:34 -05:00
|
|
|
onPaste?: (data: { cursor: number; text: string; value: string }) => { 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)
|
|
|
|
|
const vRef = useRef(value)
|
|
|
|
|
const selfChange = useRef(false)
|
|
|
|
|
const pasteBuf = useRef('')
|
|
|
|
|
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
|
const pastePos = useRef(0)
|
|
|
|
|
vRef.current = value
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-07 20:30:22 -05:00
|
|
|
if (selfChange.current) {
|
|
|
|
|
selfChange.current = false
|
|
|
|
|
} else {
|
|
|
|
|
setCur(value.length)
|
|
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
}, [value])
|
|
|
|
|
|
|
|
|
|
const flushPaste = () => {
|
|
|
|
|
const pasted = pasteBuf.current
|
|
|
|
|
const at = pastePos.current
|
|
|
|
|
pasteBuf.current = ''
|
|
|
|
|
pasteTimer.current = null
|
2026-04-07 20:30:22 -05:00
|
|
|
|
|
|
|
|
if (!pasted) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
|
|
|
|
const v = vRef.current
|
2026-04-08 13:45:34 -05:00
|
|
|
const handled = onPaste?.({ cursor: at, text: pasted, value: v })
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-08 13:45:34 -05:00
|
|
|
if (handled) {
|
2026-04-07 20:10:33 -05:00
|
|
|
selfChange.current = true
|
2026-04-08 13:45:34 -05:00
|
|
|
onChange(handled.value)
|
|
|
|
|
setCur(handled.cursor)
|
2026-04-07 20:30:22 -05:00
|
|
|
|
2026-04-08 13:45:34 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pasted.length && PRINTABLE.test(pasted)) {
|
|
|
|
|
const nv = v.slice(0, at) + pasted + v.slice(at)
|
|
|
|
|
selfChange.current = true
|
|
|
|
|
onChange(nv)
|
|
|
|
|
setCur(at + pasted.length)
|
2026-04-07 20:10:33 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useInput(
|
|
|
|
|
(inp, k) => {
|
2026-04-07 20:30:22 -05:00
|
|
|
if (
|
|
|
|
|
k.upArrow ||
|
|
|
|
|
k.downArrow ||
|
|
|
|
|
(k.ctrl && inp === 'c') ||
|
|
|
|
|
k.tab ||
|
|
|
|
|
(k.shift && k.tab) ||
|
|
|
|
|
k.pageUp ||
|
|
|
|
|
k.pageDown ||
|
|
|
|
|
k.escape
|
|
|
|
|
) {
|
2026-04-07 20:10:33 -05:00
|
|
|
return
|
2026-04-07 20:30:22 -05:00
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
2026-04-07 20:30:22 -05:00
|
|
|
if (k.return) {
|
|
|
|
|
onSubmit?.(value)
|
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
|
|
|
|
|
|
|
|
let c = cur,
|
|
|
|
|
v = value
|
2026-04-07 20:44:18 -05:00
|
|
|
|
2026-04-07 20:30:22 -05:00
|
|
|
const mod = k.ctrl || k.meta
|
|
|
|
|
|
|
|
|
|
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 ? wl(v, c) : Math.max(0, c - 1)
|
|
|
|
|
} else if (k.rightArrow) {
|
|
|
|
|
c = mod ? wr(v, c) : Math.min(v.length, c + 1)
|
|
|
|
|
} else if ((k.backspace || k.delete) && c > 0) {
|
|
|
|
|
if (mod) {
|
|
|
|
|
const t = wl(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 = wl(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 = wl(v, c)
|
|
|
|
|
} else if (k.meta && inp === 'f') {
|
|
|
|
|
c = wr(v, c)
|
|
|
|
|
} else if (inp.length > 0) {
|
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
|
|
|
|
|
|
|
|
if (!raw) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
|
|
|
|
|
const isMultiChar = raw.length > 1 || raw.includes('\n')
|
|
|
|
|
|
|
|
|
|
if (isMultiChar) {
|
2026-04-07 20:30:22 -05:00
|
|
|
if (!pasteBuf.current) {
|
|
|
|
|
pastePos.current = c
|
|
|
|
|
}
|
2026-04-07 20:44:18 -05:00
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
pasteBuf.current += raw
|
2026-04-07 20:30:22 -05:00
|
|
|
|
|
|
|
|
if (pasteTimer.current) {
|
|
|
|
|
clearTimeout(pasteTimer.current)
|
|
|
|
|
}
|
2026-04-07 20:44:18 -05:00
|
|
|
|
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-07 20:30:22 -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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c = Math.max(0, Math.min(c, v.length))
|
|
|
|
|
setCur(c)
|
2026-04-07 20:30:22 -05:00
|
|
|
|
|
|
|
|
if (v !== value) {
|
|
|
|
|
selfChange.current = true
|
|
|
|
|
onChange(v)
|
|
|
|
|
}
|
2026-04-07 20:10:33 -05:00
|
|
|
},
|
|
|
|
|
{ isActive: focus }
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-07 20:30:22 -05:00
|
|
|
if (!focus) {
|
|
|
|
|
return <Text>{value || (placeholder ? DIM + placeholder + DIM_OFF : '')}</Text>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
let r = ''
|
2026-04-07 20:30:22 -05:00
|
|
|
|
|
|
|
|
for (let i = 0; i < value.length; i++) {
|
|
|
|
|
r += i === cur ? INV + value[i] + INV_OFF : value[i]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cur === value.length) {
|
|
|
|
|
r += INV + ' ' + INV_OFF
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:10:33 -05:00
|
|
|
return <Text>{r}</Text>
|
|
|
|
|
}
|