diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 6280041ca3..45feafff5b 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -368,6 +368,7 @@ export function App({ gw }: { gw: GatewayClient }) { const pasteCounterRef = useRef(0) const colsRef = useRef(cols) const turnToolsRef = useRef([]) + const persistedToolLabelsRef = useRef>(new Set()) const statusTimerRef = useRef | 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 && ( { - 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 && ( { @@ -2456,6 +2476,7 @@ export function App({ gw }: { gw: GatewayClient }) { {secret && ( { diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index 60e4ed16ae..c9e1100995 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -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({ {'> '} - + ) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index a05c9fc0ab..07a8fbd5af 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -20,6 +20,14 @@ export const MessageLine = memo(function MessageLine({ msg: Msg t: Theme }) { + if (msg.kind === 'trail' && msg.tools?.length) { + return ( + + + + ) + } + if (msg.role === 'tool') { const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 05c97665c3..69a2bb8a85 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -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 = ( + + ask + {req.question} + + ) + 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 ( - - ❓ {req.question} - + {heading} + {'> '} - + + + Enter send Β· Esc back Β· Ctrl+C cancel ) } return ( - - ❓ {req.question} - + {heading} + {[...choices, 'Other (type your answer)'].map((c, i) => ( {sel === i ? 'β–Έ ' : ' '} - - {i + 1}. {c} - + {i + 1}. {c} ))} - ↑/↓ select Β· Enter confirm Β· 1-{choices.length} quick pick + + ↑/↓ select Β· Enter confirm Β· 1-{choices.length} quick pick Β· Esc/Ctrl+C cancel ) } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index ec87ec4f31..6378a55c48 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 9507f41ca1..ddffc566c5 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -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