diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d4793db59..e5ddf497a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -565,11 +565,18 @@ def _launch_tui(): sys.exit(1) print("Installing TUI dependencies…") result = subprocess.run( - [npm, "install", "--silent"], - cwd=str(tui_dir), capture_output=True, text=True, + [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], + cwd=str(tui_dir), + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, ) if result.returncode != 0: - print(f"npm install failed:\n{result.stderr}") + err = (result.stderr or "").strip() + preview = "\n".join(err.splitlines()[-30:]) + print("npm install failed.") + if preview: + print(preview) sys.exit(1) tsx = tui_dir / "node_modules" / ".bin" / "tsx" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index dd375b836..654c9e9e3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -230,12 +230,21 @@ 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) - return { + usage = { "input": g("session_input_tokens", "session_prompt_tokens"), "output": g("session_output_tokens", "session_completion_tokens"), "total": g("session_total_tokens"), "calls": g("session_api_calls"), } + comp = getattr(agent, "context_compressor", None) + if comp: + ctx_used = getattr(comp, "last_prompt_tokens", 0) or usage["total"] or 0 + ctx_max = getattr(comp, "context_length", 0) or 0 + if ctx_max: + usage["context_used"] = ctx_used + usage["context_max"] = ctx_max + usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) + return usage def _session_info(agent) -> dict: @@ -248,6 +257,7 @@ def _session_info(agent) -> dict: "release_date": "", "update_behind": None, "update_command": "", + "usage": _get_usage(agent), } try: from hermes_cli import __version__, __release_date__ diff --git a/ui-tui/README.md b/ui-tui/README.md index 5ff56e617..8783b18fb 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -84,31 +84,31 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an ### Main chat input -| Key | Behavior | -|---|---| -| `Enter` | Submit the current draft | -| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message | -| `\` + `Enter` | Append the line to the multiline buffer instead of sending | -| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending | -| `Ctrl+D` | Exit | -| `Ctrl+G` | Open `$EDITOR` with the current draft | -| `Ctrl+L` | New session (same as `/clear`) | -| `Ctrl+V` | Paste clipboard image (same as `/paste`) | -| `Esc` | Clear the current draft | -| `Tab` | Apply the active completion | -| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history | -| `Left/Right` | Move the cursor | -| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key | -| `Home` / `Ctrl+A` | Start of line | -| `End` / `Ctrl+E` | End of line | -| `Backspace` / `Delete` | Delete the character to the left of the cursor | -| modified `Backspace` / `Delete` | Delete the previous word | -| `Ctrl+W` | Delete the previous word | -| `Ctrl+U` | Delete from the cursor back to the start of the line | -| `Ctrl+K` | Delete from the cursor to the end of the line | -| `Meta+B` / `Meta+F` | Move by word | -| `!cmd` | Run a shell command through the gateway | -| `{!cmd}` | Inline shell interpolation before send or queue | +| Key | Behavior | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Enter` | Submit the current draft | +| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message | +| `Shift+Enter` / `Alt+Enter` | Insert a newline in the current draft | +| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) | +| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending | +| `Ctrl+D` | Exit | +| `Ctrl+G` | Open `$EDITOR` with the current draft | +| `Ctrl+L` | New session (same as `/clear`) | +| `Ctrl+V` | Paste clipboard image (same as `/paste`) | +| `Tab` | Apply the active completion | +| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history | +| `Left/Right` | Move the cursor | +| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key | +| `Home` / `Ctrl+A` | Start of line | +| `End` / `Ctrl+E` | End of line | +| `Backspace` / `Delete` | Delete the character to the left of the cursor | +| modified `Backspace` / `Delete` | Delete the previous word | +| `Ctrl+W` | Delete the previous word | +| `Ctrl+U` | Delete from the cursor back to the start of the line | +| `Ctrl+K` | Delete from the cursor to the end of the line | +| `Meta+B` / `Meta+F` | Move by word | +| `!cmd` | Run a shell command through the gateway | +| `{!cmd}` | Inline shell interpolation before send or queue | Notes: @@ -118,20 +118,20 @@ Notes: ### Prompt and picker modes -| Context | Keys | Behavior | -|---|---|---| -| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice | -| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` | -| approval prompt | `Esc`, `Ctrl+C` | Deny | -| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice | -| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice | -| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry | -| clarify free-text mode | `Enter` | Submit typed answer | -| sudo / secret prompt | `Enter` | Submit typed value | -| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response | -| resume picker | `Up/Down`, `Enter` | Move and resume the selected session | -| resume picker | `1-9` | Quick-pick one of the first nine visible sessions | -| resume picker | `Esc`, `Ctrl+C` | Close the picker | +| Context | Keys | Behavior | +| --------------------------- | ------------------- | ------------------------------------------------- | +| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice | +| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` | +| approval prompt | `Esc`, `Ctrl+C` | Deny | +| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice | +| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice | +| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry | +| clarify free-text mode | `Enter` | Submit typed answer | +| sudo / secret prompt | `Enter` | Submit typed value | +| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response | +| resume picker | `Up/Down`, `Enter` | Move and resume the selected session | +| resume picker | `1-9` | Quick-pick one of the first nine visible sessions | +| resume picker | `Esc`, `Ctrl+C` | Close the picker | Notes: @@ -213,28 +213,28 @@ That lets Python own aliases, plugins, skills, and registry-backed commands with Primary event types the client handles today: -| Event | Payload | -|---|---| -| `gateway.ready` | `{ skin? }` | -| `session.info` | session metadata for banner + tool/skill panels | -| `message.start` | start assistant streaming | -| `message.delta` | `{ text, rendered? }` | -| `message.complete` | `{ text, rendered?, usage, status }` | -| `thinking.delta` | `{ text }` | -| `reasoning.delta` | `{ text }` | -| `status.update` | `{ kind, text }` | -| `tool.start` | `{ tool_id, name, context? }` | -| `tool.progress` | `{ name, preview }` | -| `tool.complete` | `{ tool_id, name }` | -| `clarify.request` | `{ question, choices?, request_id }` | -| `approval.request` | `{ command, description }` | -| `sudo.request` | `{ request_id }` | -| `secret.request` | `{ prompt, env_var, request_id }` | -| `background.complete` | `{ task_id, text }` | -| `btw.complete` | `{ text }` | -| `error` | `{ message }` | -| `gateway.stderr` | synthesized from child stderr | -| `gateway.protocol_error` | synthesized from malformed stdout | +| Event | Payload | +| ------------------------ | ----------------------------------------------- | +| `gateway.ready` | `{ skin? }` | +| `session.info` | session metadata for banner + tool/skill panels | +| `message.start` | start assistant streaming | +| `message.delta` | `{ text, rendered? }` | +| `message.complete` | `{ text, rendered?, usage, status }` | +| `thinking.delta` | `{ text }` | +| `reasoning.delta` | `{ text }` | +| `status.update` | `{ kind, text }` | +| `tool.start` | `{ tool_id, name, context? }` | +| `tool.progress` | `{ name, preview }` | +| `tool.complete` | `{ tool_id, name }` | +| `clarify.request` | `{ question, choices?, request_id }` | +| `approval.request` | `{ command, description }` | +| `sudo.request` | `{ request_id }` | +| `secret.request` | `{ prompt, env_var, request_id }` | +| `background.complete` | `{ task_id, text }` | +| `btw.complete` | `{ text }` | +| `error` | `{ message }` | +| `gateway.stderr` | synthesized from child stderr | +| `gateway.protocol_error` | synthesized from malformed stdout | ## Theme model diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 93fc5159c..0ac815611 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -104,30 +104,83 @@ const stripTokens = (text: string, re: RegExp) => // ── StatusRule ──────────────────────────────────────────────────────── +function ctxBarColor(pct: number | undefined, t: Theme) { + if (pct == null) { + return t.color.dim + } + + if (pct >= 95) { + return t.color.statusCritical + } + + if (pct > 80) { + return t.color.statusBad + } + + if (pct >= 50) { + return t.color.statusWarn + } + + return t.color.statusGood +} + +function ctxBar(pct: number | undefined, w = 10) { + const p = Math.max(0, Math.min(100, pct ?? 0)) + const filled = Math.round((p / 100) * w) + + return '█'.repeat(filled) + '░'.repeat(w - filled) +} + function StatusRule({ cols, - color, - dimColor, + status, statusColor, - parts + model, + usage, + bgCount, + t }: { cols: number - color: string - dimColor: string + status: string statusColor: string - parts: (string | false | undefined | null)[] + model: string + usage: Usage + bgCount: number + t: Theme }) { - const label = parts.filter(Boolean).join(' · ') - const lead = String(parts[0] ?? '') + const pct = usage.context_percent + const barColor = ctxBarColor(pct, t) + + const ctxLabel = usage.context_max + ? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` + : usage.total > 0 + ? `${fmtK(usage.total)} tok` + : '' + + const pctLabel = pct != null ? `${pct}%` : '' + const bar = usage.context_max ? ctxBar(pct) : '' + + const segs = [status, model, ctxLabel, bar ? `[${bar}]` : '', pctLabel, bgCount > 0 ? `${bgCount} bg` : ''].filter( + Boolean + ) + + const inner = segs.join(' │ ') + const pad = Math.max(0, cols - inner.length - 5) return ( - + {'─ '} - - {parts[0]} - {label.slice(lead.length)} - - {' ' + '─'.repeat(Math.max(0, cols - label.length - 5))} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} + {bar ? ( + + {' │ '} + [{bar}] {pctLabel} + + ) : null} + {bgCount > 0 ? │ {bgCount} bg : null} + {' ' + '─'.repeat(pad)} ) } @@ -186,7 +239,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [secret, setSecret] = useState(null) const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') - const [thinkingText, setThinkingText] = useState('') const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [pastes, setPastes] = useState([]) @@ -201,13 +253,16 @@ export function App({ gw }: { gw: GatewayClient }) { const buf = useRef('') const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) + const reasoningRef = useRef('') const slashRef = useRef<(cmd: string) => boolean>(() => false) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const pasteCounterRef = useRef(0) const colsRef = useRef(cols) + const turnToolsRef = useRef([]) colsRef.current = cols + reasoningRef.current = reasoning // ── Hooks ──────────────────────────────────────────────────────── @@ -275,15 +330,12 @@ export function App({ gw }: { gw: GatewayClient }) { const idle = () => { setThinking(false) setTools([]) - setActivity([]) setBusy(false) setClarify(null) setApproval(null) setPasteReview(null) setSudo(null) setSecret(null) - setReasoning('') - setThinkingText('') setStreaming('') buf.current = '' } @@ -330,6 +382,11 @@ export function App({ gw }: { gw: GatewayClient }) { if (r.info) { setInfo(r.info) + + if (r.info.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + appendHistory(introMsg(r.info)) } else { setInfo(null) @@ -766,6 +823,9 @@ export function App({ gw }: { gw: GatewayClient }) { } idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] setStatus('interrupted') setTimeout(() => setStatus('ready'), 1500) } else if (input || inputBuf.length) { @@ -797,10 +857,6 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.ctrl && ch === 'g') { return openEditor() } - - if (key.escape) { - clearIn() - } }) // ── Gateway events ─────────────────────────────────────────────── @@ -839,13 +895,13 @@ export function App({ gw }: { gw: GatewayClient }) { case 'session.info': setInfo(p as SessionInfo) + if (p?.usage) { + setUsage(prev => ({ ...prev, ...p.usage })) + } + break case 'thinking.delta': - if (p?.text) { - setThinkingText(prev => prev + p.text) - } - break case 'message.start': @@ -853,7 +909,8 @@ export function App({ gw }: { gw: GatewayClient }) { setTurnKey(k => k + 1) setBusy(true) setReasoning('') - setThinkingText('') + setActivity([]) + turnToolsRef.current = [] break @@ -913,7 +970,9 @@ export function App({ gw }: { gw: GatewayClient }) { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' - pushActivity(`${label}${ctx ? ': ' + ctx : ''} ${mark}`, p.error ? 'error' : 'info') + const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` + pushActivity(line, p.error ? 'error' : 'info') + turnToolsRef.current = [...turnToolsRef.current, line].slice(-8) return prev.filter(t => t.id !== p.tool_id) }) @@ -976,7 +1035,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.complete': { const wasInterrupted = interruptedRef.current + const savedReasoning = reasoningRef.current.trim() + const savedTools = [...turnToolsRef.current] idle() + setReasoning('') setStreaming('') if (inflightPasteIdsRef.current.length) { @@ -985,9 +1047,17 @@ export function App({ gw }: { gw: GatewayClient }) { } if (!wasInterrupted) { - appendMessage({ role: 'assistant', text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) + appendMessage({ + role: 'assistant', + text: (p?.rendered ?? p?.text ?? buf.current).trimStart(), + thinking: savedReasoning || undefined, + tools: savedTools.length ? savedTools : undefined + }) } + turnToolsRef.current = [] + setActivity([]) + buf.current = '' setStatus('ready') @@ -1012,6 +1082,9 @@ export function App({ gw }: { gw: GatewayClient }) { inflightPasteIdsRef.current = [] sys(`error: ${p?.message}`) idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] setStatus('ready') break @@ -1498,6 +1571,9 @@ export function App({ gw }: { gw: GatewayClient }) { } idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] setStatus('interrupted') setTimeout(() => setStatus('ready'), 1500) @@ -1577,7 +1653,7 @@ export function App({ gw }: { gw: GatewayClient }) { )} - + {busy && } {pasteReview && ( @@ -1663,6 +1739,10 @@ export function App({ gw }: { gw: GatewayClient }) { setSid(r.session_id) setInfo(r.info ?? null) + if (r.info?.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + if (r.info) { appendHistory(introMsg(r.info)) } @@ -1692,43 +1772,43 @@ export function App({ gw }: { gw: GatewayClient }) { {statusBar && ( 0 && `${bgTasks.size} bg`, - usage.total > 0 && `${fmtK(usage.total)} tok` - ]} + model={info?.model?.split('/').pop() ?? ''} + status={status} statusColor={statusColor} + t={theme} + usage={usage} /> )} {!isBlocked && ( - - - - {inputBuf.length ? '… ' : `${theme.brand.prompt} `} - - + + {inputBuf.map((line, i) => ( + + + {i === 0 ? `${theme.brand.prompt} ` : ' '} + - + {line || ' '} + + ))} + + + + + {inputBuf.length ? ' ' : `${theme.brand.prompt} `} + + + + + )} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index cdec6f3e7..71246e473 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -53,6 +53,12 @@ export const MessageLine = memo(function MessageLine({ return ( + {msg.thinking && ( + + 💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)} + + )} + @@ -62,6 +68,20 @@ export const MessageLine = memo(function MessageLine({ {content} + + {!!msg.tools?.length && ( + + {msg.tools.map((tool, i) => ( + + {t.brand.tool} {tool} + + ))} + + )} ) }) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 38d45358c..e7b92dc38 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -117,7 +117,11 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } if (k.return) { - onSubmit?.(value) + if (k.shift || k.meta) { + commit(value.slice(0, cur) + '\n' + value.slice(cur), cur + 1) + } else { + onSubmit?.(value) + } return } @@ -163,6 +167,12 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } + if (raw === '\n') { + commit(v.slice(0, c) + '\n' + v.slice(c), c + 1) + + return + } + if (raw.length > 1 || raw.includes('\n')) { if (!pasteBuf.current) { pastePos.current = c diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a813765bb..b2aff0355 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -44,6 +44,7 @@ export const Thinking = memo(function Thinking({ const verb = VERBS[tick % VERBS.length] ?? 'thinking' const face = FACES[tick % FACES.length] ?? '(•_•)' const tail = reasoning.slice(-160).replace(/\n/g, ' ') + const hasReasoning = !!tail return ( <> @@ -54,7 +55,7 @@ export const Thinking = memo(function Thinking({ ))} - {!tools.length && ( + {!tools.length && !hasReasoning && ( {face} {verb}… diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 63e5f8da3..9734b0c27 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -27,13 +27,13 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+V', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], - ['Esc', 'clear input'], ['Ctrl+A/E', 'home / end of line'], ['Ctrl+W', 'delete word'], ['Ctrl+U/K', 'delete to start / end'], ['Ctrl+←/→', 'jump word'], ['Home/End', 'start / end of line'], - ['\\+Enter', 'multi-line continuation'], + ['Shift+Enter / Alt+Enter', 'insert newline'], + ['\\+Enter', 'multi-line continuation (fallback)'], ['!cmd', 'run shell command'], ['{!cmd}', 'interpolate shell output inline'] ] diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 5c6f0a76a..3254c2674 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -26,6 +26,8 @@ export interface Msg { text: string kind?: 'intro' info?: SessionInfo + thinking?: string + tools?: string[] } export type Role = 'assistant' | 'system' | 'tool' | 'user' @@ -43,6 +45,9 @@ export interface SessionInfo { export interface Usage { calls: number + context_max?: number + context_percent?: number + context_used?: number input: number output: number total: number