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