fix(tui): stabilize multiline input, persist tool traces, and port CLI-style context status bar

This commit is contained in:
Brooklyn Nicholson 2026-04-08 23:59:56 -05:00
parent c49bbbe8c2
commit b66550ed08
9 changed files with 262 additions and 129 deletions

View file

@ -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"

View file

@ -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__

View file

@ -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

View file

@ -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 (
<Text color={color}>
<Text color={t.color.bronze}>
{'─ '}
<Text color={dimColor}>
<Text color={statusColor}>{parts[0]}</Text>
{label.slice(lead.length)}
</Text>
{' ' + '─'.repeat(Math.max(0, cols - label.length - 5))}
<Text color={statusColor}>{status}</Text>
<Text color={t.color.dim}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (
<Text color={t.color.dim}>
{' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
</Text>
) : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
{' ' + '─'.repeat(pad)}
</Text>
)
}
@ -186,7 +239,6 @@ export function App({ gw }: { gw: GatewayClient }) {
const [secret, setSecret] = useState<SecretReq | null>(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<PendingPaste[]>([])
@ -201,13 +253,16 @@ export function App({ gw }: { gw: GatewayClient }) {
const buf = useRef('')
const inflightPasteIdsRef = useRef<number[]>([])
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<string[]>([])
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 }) {
<Thinking key={turnKey} reasoning={reasoning} t={theme} tools={tools} />
)}
<ActivityLane items={activity} t={theme} />
{busy && <ActivityLane items={activity} t={theme} />}
{pasteReview && (
<PromptBox color={theme.color.warn}>
@ -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 && (
<StatusRule
color={theme.color.bronze}
bgCount={bgTasks.size}
cols={cols}
dimColor={theme.color.dim}
parts={[
status,
sid,
info?.model?.split('/').pop(),
bgTasks.size > 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 && (
<Box>
<Box width={3}>
<Text bold color={theme.color.gold}>
{inputBuf.length ? '… ' : `${theme.brand.prompt} `}
</Text>
</Box>
<Box flexDirection="column">
{inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={theme.color.dim}>{i === 0 ? `${theme.brand.prompt} ` : ' '}</Text>
</Box>
<TextInput
onChange={setInput}
onPaste={handleTextPaste}
onSubmit={submit}
placeholder={
empty
? PLACEHOLDER
: busy
? 'Ctrl+C to interrupt…'
: inputBuf.length
? 'continue (or Enter to send)'
: ''
}
value={input}
/>
<Text color={theme.color.cornsilk}>{line || ' '}</Text>
</Box>
))}
<Box>
<Box width={3}>
<Text bold color={theme.color.gold}>
{inputBuf.length ? ' ' : `${theme.brand.prompt} `}
</Text>
</Box>
<TextInput
onChange={setInput}
onPaste={handleTextPaste}
onSubmit={submit}
placeholder={empty ? PLACEHOLDER : busy ? 'Ctrl+C to interrupt…' : ''}
value={input}
/>
</Box>
</Box>
)}

View file

@ -53,6 +53,12 @@ export const MessageLine = memo(function MessageLine({
return (
<Box flexDirection="column" marginTop={msg.role === 'user' ? 1 : 0}>
{msg.thinking && (
<Text color={t.color.dim} dimColor wrap="truncate-end">
💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)}
</Text>
)}
<Box>
<Box flexShrink={0} width={3}>
<Text bold={msg.role === 'user'} color={prefix}>
@ -62,6 +68,20 @@ export const MessageLine = memo(function MessageLine({
<Box width={Math.max(20, cols - 5)}>{content}</Box>
</Box>
{!!msg.tools?.length && (
<Box flexDirection="column">
{msg.tools.map((tool, i) => (
<Text
color={tool.endsWith(' ✗') ? t.color.error : t.color.dim}
dimColor={!tool.endsWith(' ✗')}
key={`${tool}-${i}`}
>
{t.brand.tool} {tool}
</Text>
))}
</Box>
)}
</Box>
)
})

View file

@ -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

View file

@ -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({
</Text>
))}
{!tools.length && (
{!tools.length && !hasReasoning && (
<Text color={t.color.dim}>
<Spinner color={t.color.dim} /> {face} {verb}
</Text>

View file

@ -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']
]

View file

@ -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