fix: little box typey thing
This commit is contained in:
@ -368,6 +368,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const pasteCounterRef = useRef(0)
|
||||
const colsRef = useRef(cols)
|
||||
const turnToolsRef = useRef<string[]>([])
|
||||
const persistedToolLabelsRef = useRef<Set<string>>(new Set())
|
||||
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const busyRef = useRef(busy)
|
||||
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
|
||||
@ -454,31 +455,19 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setTrail = (next: string[]) => { turnToolsRef.current = next; return next }
|
||||
|
||||
const pruneTransient = useCallback(() => {
|
||||
setTurnTrail(prev => {
|
||||
const next = prev.filter(l => !isTransientTrailLine(l))
|
||||
|
||||
if (next.length === prev.length) {
|
||||
return prev
|
||||
}
|
||||
|
||||
turnToolsRef.current = next
|
||||
|
||||
return next
|
||||
return next.length === prev.length ? prev : setTrail(next)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const pushTrail = useCallback((line: string) => {
|
||||
setTurnTrail(prev => {
|
||||
if (prev.at(-1) === line) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const next = [...prev.filter(l => !isTransientTrailLine(l)), line].slice(-8)
|
||||
turnToolsRef.current = next
|
||||
|
||||
return next
|
||||
})
|
||||
setTurnTrail(prev =>
|
||||
prev.at(-1) === line ? prev : setTrail([...prev.filter(l => !isTransientTrailLine(l)), line].slice(-8))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const rpc = useCallback(
|
||||
@ -489,6 +478,31 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
[gw, sys]
|
||||
)
|
||||
|
||||
const answerClarify = useCallback(
|
||||
(answer: string) => {
|
||||
if (!clarify) return
|
||||
|
||||
const label = TOOL_VERBS.clarify ?? 'clarify'
|
||||
|
||||
setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l)))
|
||||
setTurnTrail(turnToolsRef.current)
|
||||
|
||||
gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {})
|
||||
|
||||
if (answer) {
|
||||
persistedToolLabelsRef.current.add(label)
|
||||
appendMessage({ role: 'system', text: '', kind: 'trail', tools: [buildToolTrailLine('clarify', clarify.question)] })
|
||||
appendMessage({ role: 'user', text: answer })
|
||||
} else {
|
||||
sys('prompt cancelled')
|
||||
}
|
||||
|
||||
setClarify(null)
|
||||
setStatus('running…')
|
||||
},
|
||||
[appendMessage, clarify, gw, sys]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sid) {
|
||||
return
|
||||
@ -1030,7 +1044,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}
|
||||
|
||||
if (ctrl(key, ch, 'c')) {
|
||||
if (approval) {
|
||||
if (clarify) {
|
||||
answerClarify('')
|
||||
} else if (approval) {
|
||||
gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {})
|
||||
setApproval(null)
|
||||
sys('denied')
|
||||
@ -1276,6 +1292,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
setActivity([])
|
||||
setTurnTrail([])
|
||||
turnToolsRef.current = []
|
||||
persistedToolLabelsRef.current.clear()
|
||||
|
||||
break
|
||||
|
||||
@ -1439,7 +1456,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
case 'message.complete': {
|
||||
const wasInterrupted = interruptedRef.current
|
||||
const savedReasoning = reasoningRef.current.trim()
|
||||
const savedTools = turnToolsRef.current.filter(isToolTrailResultLine)
|
||||
const persisted = persistedToolLabelsRef.current
|
||||
const savedTools = turnToolsRef.current
|
||||
.filter(l => isToolTrailResultLine(l) && ![...persisted].some(p => sameToolTrailGroup(p, l)))
|
||||
const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart()
|
||||
|
||||
idle()
|
||||
@ -1465,6 +1484,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}
|
||||
|
||||
turnToolsRef.current = []
|
||||
persistedToolLabelsRef.current.clear()
|
||||
setActivity([])
|
||||
|
||||
buf.current = ''
|
||||
@ -1494,6 +1514,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
setReasoning('')
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
persistedToolLabelsRef.current.clear()
|
||||
setStatus('ready')
|
||||
|
||||
break
|
||||
@ -2412,11 +2433,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
{clarify && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<ClarifyPrompt
|
||||
onAnswer={answer => {
|
||||
gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {})
|
||||
appendMessage({ role: 'user', text: answer })
|
||||
setClarify(null)
|
||||
}}
|
||||
cols={cols}
|
||||
onAnswer={answerClarify}
|
||||
onCancel={() => answerClarify('')}
|
||||
req={clarify}
|
||||
t={theme}
|
||||
/>
|
||||
@ -2441,6 +2460,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
{sudo && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔐"
|
||||
label="sudo password required"
|
||||
onSubmit={pw => {
|
||||
@ -2456,6 +2476,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
{secret && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔑"
|
||||
label={secret.prompt}
|
||||
onSubmit={val => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -25,7 +25,7 @@ export interface ClarifyReq {
|
||||
export interface Msg {
|
||||
role: Role
|
||||
text: string
|
||||
kind?: 'intro' | 'panel' | 'slash'
|
||||
kind?: 'intro' | 'panel' | 'slash' | 'trail'
|
||||
info?: SessionInfo
|
||||
panelData?: PanelData
|
||||
thinking?: string
|
||||
|
||||
Reference in New Issue
Block a user