Something went wrong. Try again.
small gleam coding and (not yet) persistent agent daemon with a detachable cli
Something went wrong. Try again.
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100import { useState } from "react"import { Text, useInput, type Key } from "ink"
export type TextInputProps = { value: string onChange: (value: string) => void onSubmit?: (value: string) => void onKey?: (input: string, key: Key, replace: (value: string) => void) => boolean mask?: string multiline?: boolean width?: number /** The left arrow is an edit inside a buffer and a navigation gesture when the buffer is empty. */ onLeftWhenEmpty?: () => void isActive?: boolean}
/** Control characters never reach the buffer; pasted text arrives as one chunk. */const typed = (input: string): string => [...input].filter((char) => char >= " " && char !== "\u007f").join("")
const space = (char: string | undefined): boolean => char === undefined || /\s/.test(char)
/** Word motion skips the gap first, then the word, like readline and every editor. */const wordLeft = (text: string, at: number): number => { let index = at while (index > 0 && space(text[index - 1])) index-- while (index > 0 && !space(text[index - 1])) index-- return index}
const wordRight = (text: string, at: number): number => { let index = at while (index < text.length && space(text[index])) index++ while (index < text.length && !space(text[index])) index++ return index}
/** * Minimal controlled line editor. The buffer belongs to the caller, so the * prompt beside it lives in the render tree and no keystroke can erase it. */export function TextInput({ value, onChange, onSubmit, onKey, onLeftWhenEmpty, isActive = true, mask, multiline, width }: TextInputProps) { const [offset, setOffset] = useState(value.length) const cursor = Math.min(offset, value.length) const edit = (next: string, at: number): void => { onChange(next); setOffset(at) }
useInput((input, key) => { if (onKey?.(input, key, (next) => edit(next, next.length))) { return } // Terminals disagree about shift+return, so option/alt+return opens a line too. if (multiline && ((key.return && (key.shift || key.meta)) || (input.length > 1 && /[\r\n]/.test(input)))) { const pasted = key.return ? "\n" : input.replace(/\r\n?/g, "\n") const insert = pasted.split("\n").map(typed).join("\n") edit(value.slice(0, cursor) + insert + value.slice(cursor), cursor + insert.length) return } if (key.return) { setOffset(0); onSubmit?.(value); return } // option/alt on macOS and ctrl elsewhere both mean "by word" here. const byWord = key.meta || key.ctrl if (key.leftArrow) { if (!value) onLeftWhenEmpty?.(); else setOffset(byWord ? wordLeft(value, cursor) : Math.max(0, cursor - 1)); return } if (key.rightArrow) return setOffset(byWord ? wordRight(value, cursor) : Math.min(value.length, cursor + 1)) // Some terminals send option+arrow as the readline word motions instead. if (key.meta && (input === "b" || input === "f")) return setOffset(input === "b" ? wordLeft(value, cursor) : wordRight(value, cursor)) if (key.home) return setOffset(0) if (key.end) return setOffset(value.length) if (key.backspace) return cursor === 0 ? undefined : edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1) if (key.delete) return cursor >= value.length ? undefined : edit(value.slice(0, cursor) + value.slice(cursor + 1), cursor) if (key.ctrl) return input === "u" ? edit("", 0) : undefined if (key.meta || key.escape || key.tab || key.upArrow || key.downArrow) return // A terminal can deliver several keystrokes in one chunk (paste, fast // input, pipe-fed tests). Ink classifies the whole chunk as one keypress, // so an embedded \r or \n is never "the return key" — split it ourselves // and submit each terminated line, like readline did. const segments = input.split(/[\r\n]/) if (segments.length === 1) { const insert = typed(input) if (insert) edit(value.slice(0, cursor) + insert + value.slice(cursor), cursor + insert.length) return } let buffer = value let at = cursor for (const [index, segment] of segments.entries()) { const insert = typed(segment) if (insert) { buffer = buffer.slice(0, at) + insert + buffer.slice(at); at += insert.length } if (index < segments.length - 1) { onChange(buffer); onSubmit?.(buffer); buffer = ""; at = 0 } else { onChange(buffer); setOffset(at) } } }, { isActive })
const shown = mask ? mask.repeat(value.length) : value.replace(/\n/g, " ") const first = Math.max(0, cursor - (width ?? shown.length + 1) + 1) return ( <Text> {shown.slice(first, cursor)} <Text inverse>{shown.slice(cursor, cursor + 1) || " "}</Text> {shown.slice(cursor + 1, first + (width ?? shown.length + 1))} </Text> )}