fix(tui): readline parity on Linux — Ctrl+A = home, Alt+B/F word nav

textInput treated the platform action-mod (Cmd on macOS, Ctrl on Linux)
as the sole word-boundary modifier. On Linux that meant:

- Ctrl+A selected all instead of jumping to line start (contra standard
  readline and the hotkey doc in README.md which says `Ctrl+A` = Start
  of line).
- Alt+B / Alt+F / Alt+Backspace / Alt+Delete were dropped, because
  `key.meta` was never consulted — the README already documented
  `Meta+B` / `Meta+F` as word nav.

Gate select-all to macOS Cmd+A (`isMac && mod && inp === 'a'`), route
Linux Ctrl+A through `actionHome`, and broaden every word-boundary
predicate (b/f/Backspace/Delete and the modified arrow keys) from `mod`
to `wordMod = mod || k.meta` so Alt chords work on Linux and Mac while
existing Ctrl/Cmd chords keep working.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 10:49:35 -05:00
parent ce98e1ef11
commit d86c886b31
21 changed files with 215 additions and 84 deletions

View file

@ -277,8 +277,9 @@ function useFwdDelete(active: boolean) {
type PasteResult = { cursor: number; value: string } | null
const isPasteResultPromise = (value: PasteResult | Promise<PasteResult> | null | undefined): value is Promise<PasteResult> =>
!!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
const isPasteResultPromise = (
value: PasteResult | Promise<PasteResult> | null | undefined
): value is Promise<PasteResult> => !!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
export function TextInput({
columns = 80,
@ -522,9 +523,11 @@ export function TextInput({
}
const range = selRange()
const nextValue = range
? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end)
: vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current)
const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length
commit(nextValue, nextCursor)
@ -591,7 +594,8 @@ export function TextInput({
let c = curRef.current
let v = vRef.current
const mod = isActionMod(k)
const actionHome = k.home || isMacActionFallback(k, inp, 'a')
const wordMod = mod || k.meta
const actionHome = k.home || (!isMac && mod && inp === 'a') || isMacActionFallback(k, inp, 'a')
const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e')
const actionDeleteToStart = (mod && inp === 'u') || isMacActionFallback(k, inp, 'u')
const range = selRange()
@ -605,7 +609,7 @@ export function TextInput({
return swap(redo, undo)
}
if (mod && inp === 'a') {
if (isMac && mod && inp === 'a') {
return selectAll()
}
@ -616,32 +620,32 @@ export function TextInput({
clearSel()
c = v.length
} else if (k.leftArrow) {
if (range && !mod) {
if (range && !wordMod) {
clearSel()
c = range.start
} else {
clearSel()
c = mod ? wordLeft(v, c) : prevPos(v, c)
c = wordMod ? wordLeft(v, c) : prevPos(v, c)
}
} else if (k.rightArrow) {
if (range && !mod) {
if (range && !wordMod) {
clearSel()
c = range.end
} else {
clearSel()
c = mod ? wordRight(v, c) : nextPos(v, c)
c = wordMod ? wordRight(v, c) : nextPos(v, c)
}
} else if (mod && inp === 'b') {
} else if (wordMod && inp === 'b') {
clearSel()
c = wordLeft(v, c)
} else if (mod && inp === 'f') {
} else if (wordMod && inp === 'f') {
clearSel()
c = wordRight(v, c)
} else if (range && (k.backspace || delFwd)) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else if (k.backspace && c > 0) {
if (mod) {
if (wordMod) {
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
@ -651,7 +655,7 @@ export function TextInput({
c = t
}
} else if (delFwd && c < v.length) {
if (mod) {
if (wordMod) {
const t = wordRight(v, c)
v = v.slice(0, c) + v.slice(t)
} else {
@ -778,7 +782,9 @@ interface TextInputProps {
focus?: boolean
mask?: string
onChange: (v: string) => void
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onPaste?: (
e: PasteEvent
) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onSubmit?: (v: string) => void
placeholder?: string
value: string