fix: little box typey thing

This commit is contained in:
Brooklyn Nicholson 2026-04-12 16:31:30 -05:00
parent 8efd3db1b4
commit 4b026d6761
6 changed files with 142 additions and 62 deletions

View file

@ -1,15 +1,18 @@
import { Box, Text, TextInput } from '@hermes/ink'
import { Box, Text } from '@hermes/ink'
import { useState } from 'react'
import type { Theme } from '../theme.js'
import { TextInput } from './textInput.js'
export function MaskedPrompt({
cols = 80,
icon,
label,
onSubmit,
sub,
t
}: {
cols?: number
icon: string
label: string
onSubmit: (v: string) => void
@ -27,7 +30,7 @@ export function MaskedPrompt({
<Box>
<Text color={t.color.label}>{'> '}</Text>
<TextInput mask="*" onChange={setValue} onSubmit={onSubmit} value={value} />
<TextInput columns={Math.max(20, cols - 6)} mask="*" onChange={setValue} onSubmit={onSubmit} value={value} />
</Box>
</Box>
)

View file

@ -20,6 +20,14 @@ export const MessageLine = memo(function MessageLine({
msg: Msg
t: Theme
}) {
if (msg.kind === 'trail' && msg.tools?.length) {
return (
<Box flexDirection="column" marginTop={1}>
<ToolTrail t={t} trail={msg.tools} />
</Box>
)
}
if (msg.role === 'tool') {
const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14))

View file

@ -1,8 +1,9 @@
import { Box, Text, TextInput, useInput } from '@hermes/ink'
import { Box, Text, useInput } from '@hermes/ink'
import { useState } from 'react'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq } from '../types.js'
import { TextInput } from './textInput.js'
export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) {
const [sel, setSel] = useState(3)
@ -59,68 +60,77 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
)
}
export function ClarifyPrompt({ onAnswer, req, t }: { onAnswer: (s: string) => void; req: ClarifyReq; t: Theme }) {
export function ClarifyPrompt({
cols = 80,
onAnswer,
onCancel,
req,
t
}: {
cols?: number
onAnswer: (s: string) => void
onCancel: () => void
req: ClarifyReq
t: Theme
}) {
const [sel, setSel] = useState(0)
const [custom, setCustom] = useState('')
const [typing, setTyping] = useState(false)
const choices = req.choices ?? []
const heading = (
<Text bold>
<Text color={t.color.amber}>ask</Text>
<Text color={t.color.cornsilk}> {req.question}</Text>
</Text>
)
useInput((ch, key) => {
if (typing) {
if (key.escape) {
typing && choices.length ? setTyping(false) : onCancel()
return
}
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
if (typing) return
if (key.downArrow && sel < choices.length) {
setSel(s => s + 1)
}
if (key.upArrow && sel > 0) setSel(s => s - 1)
if (key.downArrow && sel < choices.length) setSel(s => s + 1)
if (key.return) {
if (sel === choices.length) {
setTyping(true)
} else if (choices[sel]) {
onAnswer(choices[sel]!)
}
sel === choices.length ? setTyping(true) : choices[sel] && onAnswer(choices[sel]!)
}
const n = parseInt(ch)
if (n >= 1 && n <= choices.length) {
onAnswer(choices[n - 1]!)
}
if (n >= 1 && n <= choices.length) onAnswer(choices[n - 1]!)
})
if (typing || !choices.length) {
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
{req.question}
</Text>
{heading}
<Box>
<Text color={t.color.label}>{'> '}</Text>
<TextInput onChange={setCustom} onSubmit={onAnswer} value={custom} />
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
</Box>
<Text color={t.color.dim}>Enter send · Esc back · Ctrl+C cancel</Text>
</Box>
)
}
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
{req.question}
</Text>
{heading}
{[...choices, 'Other (type your answer)'].map((c, i) => (
<Text key={i}>
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
{i + 1}. {c}
</Text>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>{i + 1}. {c}</Text>
</Text>
))}
<Text color={t.color.dim}>/ select · Enter confirm · 1-{choices.length} quick pick</Text>
<Text color={t.color.dim}>/ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel</Text>
</Box>
)
}

View file

@ -9,7 +9,7 @@ type InkExt = typeof Ink & {
}
const ink = Ink as unknown as InkExt
const { Box, Text, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
// ── ANSI escapes ─────────────────────────────────────────────────────
@ -18,6 +18,7 @@ const INV = `${ESC}[7m`
const INV_OFF = `${ESC}[27m`
const DIM = `${ESC}[2m`
const DIM_OFF = `${ESC}[22m`
const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`)
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
@ -121,6 +122,31 @@ function renderWithCursor(value: string, cursor: number) {
return done ? out : out + invert(' ')
}
// ── Forward-delete detection hook ────────────────────────────────────
function useFwdDelete(active: boolean) {
const ref = useRef(false)
const { inputEmitter: ee } = useStdin()
useEffect(() => {
if (!active) {
return
}
const h = (d: string) => {
ref.current = FWD_DEL_RE.test(d)
}
ee.prependListener('input', h)
return () => {
ee.removeListener('input', h)
}
}, [active, ee])
return ref
}
// ── Types ────────────────────────────────────────────────────────────
export interface PasteEvent {
@ -137,14 +163,25 @@ interface Props {
onChange: (v: string) => void
onSubmit?: (v: string) => void
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
mask?: string
placeholder?: string
focus?: boolean
}
// ── Component ────────────────────────────────────────────────────────
export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) {
export function TextInput({
columns = 80,
value,
onChange,
onPaste,
onSubmit,
mask,
placeholder = '',
focus = true
}: Props) {
const [cur, setCur] = useState(value.length)
const fwdDel = useFwdDelete(focus)
const termFocus = useTerminalFocus()
const curRef = useRef(cur)
@ -163,7 +200,8 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl
cbSubmit.current = onSubmit
cbPaste.current = onPaste
const display = self.current ? vRef.current : value
const raw = self.current ? vRef.current : value
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
// ── Cursor declaration ───────────────────────────────────────────
@ -337,7 +375,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl
}
// Deletion
else if (k.backspace && c > 0) {
else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
if (mod) {
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
@ -346,7 +384,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl
v = v.slice(0, c - 1) + v.slice(c)
c--
}
} else if (k.delete && c < v.length) {
} else if (k.delete && fwdDel.current && c < v.length) {
if (mod) {
const t = wordRight(v, c)
v = v.slice(0, c) + v.slice(t)