diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9977d40f5..da603b727 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -232,8 +232,13 @@ def _resolve_model() -> str: def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) usage = { + "model": getattr(agent, "model", "") or "", "input": g("session_input_tokens", "session_prompt_tokens"), "output": g("session_output_tokens", "session_completion_tokens"), + "cache_read": g("session_cache_read_tokens"), + "cache_write": g("session_cache_write_tokens"), + "prompt": g("session_prompt_tokens"), + "completion": g("session_completion_tokens"), "total": g("session_total_tokens"), "calls": g("session_api_calls"), } @@ -245,6 +250,25 @@ def _get_usage(agent) -> dict: usage["context_used"] = ctx_used usage["context_max"] = ctx_max usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) + usage["compressions"] = getattr(comp, "compression_count", 0) or 0 + try: + from agent.usage_pricing import CanonicalUsage, estimate_usage_cost + cost = estimate_usage_cost( + usage["model"], + CanonicalUsage( + input_tokens=usage["input"], + output_tokens=usage["output"], + cache_read_tokens=usage["cache_read"], + cache_write_tokens=usage["cache_write"], + ), + provider=getattr(agent, "provider", None), + base_url=getattr(agent, "base_url", None), + ) + usage["cost_status"] = cost.status + if cost.amount_usd is not None: + usage["cost_usd"] = float(cost.amount_usd) + except Exception: + pass return usage diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 5a66dce58..16152bcf9 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -46,6 +46,7 @@ const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() const LARGE_PASTE = { chars: 8000, lines: 80 } const EXCERPT = { chars: 1200, lines: 14 } +const MAX_HISTORY = 800 const SECRET_PATTERNS = [ /AKIA[0-9A-Z]{16}/g, @@ -286,6 +287,8 @@ export function App({ gw }: { gw: GatewayClient }) { const pasteCounterRef = useRef(0) const colsRef = useRef(cols) const turnToolsRef = useRef([]) + const statusTimerRef = useRef | null>(null) + const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) colsRef.current = cols reasoningRef.current = reasoning @@ -322,8 +325,15 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Core actions ───────────────────────────────────────────────── const appendMessage = useCallback((msg: Msg) => { - setMessages(prev => [...prev, msg]) - setHistoryItems(prev => [...prev, msg]) + const cap = (items: Msg[]) => + items.length <= MAX_HISTORY + ? items + : items[0]?.kind === 'intro' + ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] + : items.slice(-MAX_HISTORY) + + setMessages(prev => cap([...prev, msg])) + setHistoryItems(prev => cap([...prev, msg])) }, []) const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) @@ -378,6 +388,8 @@ export function App({ gw }: { gw: GatewayClient }) { } const resetSession = () => { + idle() + setReasoning('') setSid(null as any) // will be set by caller setHistoryItems([]) setMessages([]) @@ -385,6 +397,7 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity([]) setBgTasks(new Set()) setUsage(ZERO) + turnToolsRef.current = [] lastStatusNoteRef.current = '' protocolWarnedRef.current = false } @@ -541,6 +554,11 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') } + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + statusTimerRef.current = null + } + inflightPasteIdsRef.current = payload.usedIds setLastUserMsg(text) appendMessage({ role: 'user', text }) @@ -855,7 +873,15 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity([]) turnToolsRef.current = [] setStatus('interrupted') - setTimeout(() => setStatus('ready'), 1500) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus('ready') + }, 1500) } else if (input || inputBuf.length) { clearIn() } else { @@ -1077,7 +1103,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'btw.complete': setBgTasks(prev => { const next = new Set(prev) - next.delete(`btw:${p.task_id ?? 'x'}`) + next.delete('btw:x') return next }) @@ -1096,6 +1122,8 @@ export function App({ gw }: { gw: GatewayClient }) { const wasInterrupted = interruptedRef.current const savedReasoning = reasoningRef.current.trim() const savedTools = [...turnToolsRef.current] + const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart() + idle() setReasoning('') setStreaming('') @@ -1108,7 +1136,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!wasInterrupted) { appendMessage({ role: 'assistant', - text: (p?.rendered ?? p?.text ?? buf.current).trimStart(), + text: finalText, thinking: savedReasoning || undefined, tools: savedTools.length ? savedTools : undefined }) @@ -1152,20 +1180,24 @@ export function App({ gw }: { gw: GatewayClient }) { [appendMessage, dequeue, newSession, pushActivity, send, sys] ) - const onExit = useCallback(() => { - setStatus('gateway exited') - exit() - }, [exit]) + onEventRef.current = onEvent useEffect(() => { - gw.on('event', onEvent) - gw.on('exit', onExit) + const handler = (ev: GatewayEvent) => onEventRef.current(ev) + + const exitHandler = () => { + setStatus('gateway exited') + exit() + } + + gw.on('event', handler) + gw.on('exit', exitHandler) return () => { - gw.off('event', onEvent) - gw.off('exit', onExit) + gw.off('event', handler) + gw.off('exit', exitHandler) } - }, [gw, onEvent, onExit]) + }, [gw, exit]) // ── Slash commands ─────────────────────────────────────────────── @@ -1505,8 +1537,36 @@ export function App({ gw }: { gw: GatewayClient }) { setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) } + if (!r?.calls) { + sys('no API calls yet') + + return + } + + const f = (v: number) => (v ?? 0).toLocaleString() + const ln = (k: string, v: string) => ` ${k.padEnd(26)}${v.padStart(10)}` + const hr = ` ${'─'.repeat(36)}` + + const cost = + r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + sys( - `${fmtK(r?.input ?? 0)} in · ${fmtK(r?.output ?? 0)} out · ${fmtK(r?.total ?? 0)} total · ${r?.calls ?? 0} calls` + [ + hr, + ln('Model:', r.model ?? ''), + ln('Input tokens:', f(r.input)), + ln('Cache read tokens:', f(r.cache_read)), + ln('Cache write tokens:', f(r.cache_write)), + ln('Output tokens:', f(r.output)), + ln('Total tokens:', f(r.total)), + ln('API calls:', f(r.calls)), + cost && ln('Cost:', cost), + hr, + r.context_max && ` Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)`, + r.compressions && ` Compressions: ${r.compressions}` + ] + .filter(Boolean) + .join('\n') ) }) @@ -1634,7 +1694,15 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity([]) turnToolsRef.current = [] setStatus('interrupted') - setTimeout(() => setStatus('ready'), 1500) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus('ready') + }, 1500) return } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 8b4dc5c33..54dcb17eb 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -48,16 +48,22 @@ interface Props { export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { const [cur, setCur] = useState(value.length) + const curRef = useRef(cur) const vRef = useRef(value) const selfChange = useRef(false) const pasteBuf = useRef('') const pasteTimer = useRef | null>(null) const pastePos = useRef(0) - const undo = useRef>([]) - const redo = useRef>([]) - curRef.current = cur - vRef.current = value + const undoStack = useRef>([]) + const redoStack = useRef>([]) + + const onChangeRef = useRef(onChange) + const onSubmitRef = useRef(onSubmit) + const onPasteRef = useRef(onPaste) + onChangeRef.current = onChange + onSubmitRef.current = onSubmit + onPasteRef.current = onPaste useEffect(() => { if (selfChange.current) { @@ -65,36 +71,58 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } else { setCur(value.length) curRef.current = value.length - undo.current = [] - redo.current = [] + vRef.current = value + undoStack.current = [] + redoStack.current = [] } }, [value]) - const commit = (nextValue: string, nextCursor: number, track = true) => { - const currentValue = vRef.current - const currentCursor = curRef.current - const c = Math.max(0, Math.min(nextCursor, nextValue.length)) + useEffect( + () => () => { + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } + }, + [] + ) - if (track && nextValue !== currentValue) { - undo.current.push({ cursor: currentCursor, value: currentValue }) + // ── Buffer ops (synchronous, ref-based — no stale closures) ───── - if (undo.current.length > 200) { - undo.current.shift() + const commit = (next: string, nextCur: number, track = true) => { + const prev = vRef.current + const c = Math.max(0, Math.min(nextCur, next.length)) + + if (track && next !== prev) { + undoStack.current.push({ cursor: curRef.current, value: prev }) + + if (undoStack.current.length > 200) { + undoStack.current.shift() } - redo.current = [] + redoStack.current = [] } setCur(c) curRef.current = c - vRef.current = nextValue + vRef.current = next - if (nextValue !== currentValue) { + if (next !== prev) { selfChange.current = true - onChange(nextValue) + onChangeRef.current(next) } } + const swap = (from: typeof undoStack, to: typeof redoStack) => { + const entry = from.current.pop() + + if (!entry) { + return + } + + to.current.push({ cursor: curRef.current, value: vRef.current }) + commit(entry.value, entry.cursor, false) + } + const flushPaste = () => { const pasted = pasteBuf.current const at = pastePos.current @@ -105,20 +133,20 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } - const currentValue = vRef.current - const handled = onPaste?.({ cursor: at, text: pasted, value: currentValue }) + const v = vRef.current + const handled = onPasteRef.current?.({ cursor: at, text: pasted, value: v }) if (handled) { - commit(handled.value, handled.cursor) - - return + return commit(handled.value, handled.cursor) } - if (pasted.length && PRINTABLE.test(pasted)) { - commit(currentValue.slice(0, at) + pasted + currentValue.slice(at), at + pasted.length) + if (PRINTABLE.test(pasted)) { + commit(v.slice(0, at) + pasted + v.slice(at), at + pasted.length) } } + // ── Input handler (reads only from refs) ──────────────────────── + useInput( (inp, k) => { if ( @@ -136,42 +164,24 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' if (k.return) { if (k.shift || k.meta) { - commit(value.slice(0, cur) + '\n' + value.slice(cur), cur + 1) + commit(vRef.current.slice(0, curRef.current) + '\n' + vRef.current.slice(curRef.current), curRef.current + 1) } else { - onSubmit?.(value) + onSubmitRef.current?.(vRef.current) } return } - let c = cur - let v = value + let c = curRef.current + let v = vRef.current const mod = k.ctrl || k.meta if (k.ctrl && inp === 'z') { - const prev = undo.current.pop() - - if (!prev) { - return - } - - redo.current.push({ cursor: curRef.current, value: vRef.current }) - commit(prev.value, prev.cursor, false) - - return + return swap(undoStack, redoStack) } if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { - const next = redo.current.pop() - - if (!next) { - return - } - - undo.current.push({ cursor: curRef.current, value: vRef.current }) - commit(next.value, next.cursor, false) - - return + return swap(redoStack, undoStack) } if (k.home || (k.ctrl && inp === 'a')) { @@ -212,22 +222,18 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } if (raw === '\n') { - commit(v.slice(0, c) + '\n' + v.slice(c), c + 1) - - return + return commit(v.slice(0, c) + '\n' + v.slice(c), c + 1) } if (raw.length > 1 || raw.includes('\n')) { if (!pasteBuf.current) { pastePos.current = c } - pasteBuf.current += raw if (pasteTimer.current) { clearTimeout(pasteTimer.current) } - pasteTimer.current = setTimeout(flushPaste, 50) return @@ -248,6 +254,8 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' { isActive: focus } ) + // ── Render ────────────────────────────────────────────────────── + if (!focus) { return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} } @@ -256,15 +264,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} } - let r = '' + const rendered = + [...value].map((ch, i) => (i === cur ? INV + ch + INV_OFF : ch)).join('') + + (cur === value.length ? INV + ' ' + INV_OFF : '') - for (let i = 0; i < value.length; i++) { - r += i === cur ? INV + value[i] + INV_OFF : value[i] - } - - if (cur === value.length) { - r += INV + ' ' + INV_OFF - } - - return {r} + return {rendered} } diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index b8c247d97..29d034957 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -11,4 +11,4 @@ if (!process.stdin.isTTY) { const gw = new GatewayClient() gw.start() -render(, { exitOnCtrlC: false }) +render(, { exitOnCtrlC: false, maxFps: 60 })