diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index c08f3f6462..8688b52d13 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -565,14 +565,19 @@ def _launch_tui():
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
if tsx.exists():
- sys.exit(subprocess.call([str(tsx), "src/entry.tsx"], cwd=str(tui_dir)))
+ argv = [str(tsx), "src/entry.tsx"]
+ else:
+ npm = shutil.which("npm")
+ if not npm:
+ print("npm not found in PATH. Source your nvm/node setup or set PATH.")
+ sys.exit(1)
+ argv = [npm, "start"]
- npm = shutil.which("npm")
- if not npm:
- print("npm not found in PATH. Source your nvm/node setup or set PATH.")
- sys.exit(1)
-
- sys.exit(subprocess.call([npm, "start"], cwd=str(tui_dir)))
+ try:
+ code = subprocess.call(argv, cwd=str(tui_dir))
+ except KeyboardInterrupt:
+ code = 130
+ sys.exit(code)
def cmd_chat(args):
diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx
index f2af728bb9..a950a14ab9 100644
--- a/ui-tui/src/app.tsx
+++ b/ui-tui/src/app.tsx
@@ -4,14 +4,15 @@ import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink'
-import { TextInput } from './components/textInput.js'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
import { Banner, SessionPanel } from './components/branding.js'
import { MaskedPrompt } from './components/maskedPrompt.js'
import { MessageLine } from './components/messageLine.js'
import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js'
import { QueuedMessages } from './components/queuedMessages.js'
import { SessionPicker } from './components/sessionPicker.js'
+import { TextInput } from './components/textInput.js'
import { Thinking } from './components/thinking.js'
import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js'
import { type GatewayClient, type GatewayEvent } from './gatewayClient.js'
@@ -41,20 +42,33 @@ const introMsg = (info: SessionInfo): Msg => ({
info
})
-function extractTabWord(input: string): string | null {
- const m = input.match(/((?:\.\.?\/|~\/|\/|@)[^\s]*)$/)
- return m?.[1] ?? null
-}
+const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/
-function StatusRule({ cols, color, dimColor, statusColor, parts }: {
- cols: number; color: string; dimColor: string; statusColor: string; parts: (string | false | undefined | null)[]
+function StatusRule({
+ cols,
+ color,
+ dimColor,
+ statusColor,
+ parts
+}: {
+ cols: number
+ color: string
+ dimColor: string
+ statusColor: string
+ parts: (string | false | undefined | null)[]
}) {
const label = parts.filter(Boolean).join(' · ')
+ const lead = String(parts[0] ?? '')
const fill = Math.max(0, cols - label.length - 5)
return (
- {'─ '}{parts[0]}{label.slice(String(parts[0] || '').length)}{' ' + '─'.repeat(fill)}
+ {'─ '}
+
+ {parts[0]}
+ {label.slice(lead.length)}
+
+ {' ' + '─'.repeat(fill)}
)
}
@@ -65,10 +79,15 @@ export function App({ gw }: { gw: GatewayClient }) {
const [cols, setCols] = useState(stdout?.columns ?? 80)
useEffect(() => {
- if (!stdout) return
+ if (!stdout) {
+ return
+ }
const sync = () => setCols(stdout.columns ?? 80)
stdout.on('resize', sync)
- return () => { stdout.off('resize', sync) }
+
+ return () => {
+ stdout.off('resize', sync)
+ }
}, [stdout])
const [input, setInput] = useState('')
@@ -108,7 +127,6 @@ export function App({ gw }: { gw: GatewayClient }) {
const lastEmptyAt = useRef(0)
const lastStatusNoteRef = useRef('')
const protocolWarnedRef = useRef(false)
- const stderrWarnedRef = useRef(false)
const pasteCounterRef = useRef(0)
const empty = !messages.length
@@ -167,31 +185,51 @@ export function App({ gw }: { gw: GatewayClient }) {
const compInputRef = useRef('')
useEffect(() => {
- if (blocked) { if (completions.length) { setCompletions([]); setCompIdx(0) }; return }
- if (input === compInputRef.current) return
+ if (blocked) {
+ if (completions.length) {
+ setCompletions([])
+ setCompIdx(0)
+ }
+
+ return
+ }
+
+ if (input === compInputRef.current) {
+ return
+ }
compInputRef.current = input
const isSlash = input.startsWith('/')
- const pathWord = !isSlash ? extractTabWord(input) : null
+ const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null
if (!isSlash && !pathWord) {
- if (completions.length) { setCompletions([]); setCompIdx(0) }
+ if (completions.length) {
+ setCompletions([])
+ setCompIdx(0)
+ }
+
return
}
const t = setTimeout(() => {
- if (compInputRef.current !== input) return
+ if (compInputRef.current !== input) {
+ return
+ }
const req = isSlash
? gw.request('complete.slash', { text: input })
: gw.request('complete.path', { word: pathWord })
- req.then((r: any) => {
- if (compInputRef.current !== input) return
- setCompletions(r?.items ?? [])
- setCompIdx(0)
- setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0))
- }).catch(() => {})
+ req
+ .then((r: any) => {
+ if (compInputRef.current !== input) {
+ return
+ }
+ setCompletions(r?.items ?? [])
+ setCompIdx(0)
+ setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0))
+ })
+ .catch(() => {})
}, 60)
return () => clearTimeout(t)
@@ -232,7 +270,6 @@ export function App({ gw }: { gw: GatewayClient }) {
setStatus('ready')
lastStatusNoteRef.current = ''
protocolWarnedRef.current = false
- stderrWarnedRef.current = false
if (r.info) {
setInfo(r.info)
@@ -277,7 +314,11 @@ export function App({ gw }: { gw: GatewayClient }) {
const expandPastes = (text: string) =>
text.replace(PASTE_REF_RE, (m, path) => {
- try { return readFileSync(path, 'utf8') } catch { return m }
+ try {
+ return readFileSync(path, 'utf8')
+ } catch {
+ return m
+ }
})
const collapsePaste = (text: string) => {
@@ -285,8 +326,14 @@ export function App({ gw }: { gw: GatewayClient }) {
const lineCount = text.split('\n').length
const pasteDir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'pastes')
mkdirSync(pasteDir, { recursive: true })
- const pasteFile = join(pasteDir, `paste_${pasteCounterRef.current}_${new Date().toTimeString().slice(0, 8).replace(/:/g, '')}.txt`)
+
+ const pasteFile = join(
+ pasteDir,
+ `paste_${pasteCounterRef.current}_${new Date().toTimeString().slice(0, 8).replace(/:/g, '')}.txt`
+ )
+
writeFileSync(pasteFile, text, 'utf8')
+
return `[Pasted text #${pasteCounterRef.current}: ${lineCount} lines → ${pasteFile}]`
}
@@ -310,9 +357,12 @@ export function App({ gw }: { gw: GatewayClient }) {
gw.request('shell.exec', { command: cmd })
.then((r: any) => {
const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim()
- sys(out || `exit ${r.code}`)
- if (r.code !== 0 && out) {
+ if (out) {
+ sys(out)
+ }
+
+ if (r.code !== 0 || !out) {
sys(`exit ${r.code}`)
}
})
@@ -349,8 +399,8 @@ export function App({ gw }: { gw: GatewayClient }) {
try {
unlinkSync(file)
- } catch (_) {
- /* cleanup best-effort */
+ } catch {
+ /* noop */
}
}
@@ -399,13 +449,18 @@ export function App({ gw }: { gw: GatewayClient }) {
}
if (completions.length && input && (key.upArrow || key.downArrow)) {
- setCompIdx(i => key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)
+ setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length))
+
return
}
if (!inputBuf.length && key.tab && completions.length) {
- const pick = completions[compIdx]
- if (pick) setInput(input.slice(0, compReplace) + pick.text)
+ const row = completions[compIdx]
+
+ if (row) {
+ setInput(input.slice(0, compReplace) + row.text)
+ }
+
return
}
@@ -459,9 +514,11 @@ export function App({ gw }: { gw: GatewayClient }) {
if (busy && sid) {
interruptedRef.current = true
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
+
if (buf.current.trim()) {
appendMessage({ role: 'assistant' as const, text: buf.current.trimStart() })
}
+
idle()
setStatus('interrupted')
sys('interrupted by user')
@@ -482,11 +539,13 @@ export function App({ gw }: { gw: GatewayClient }) {
if (key.ctrl && ch === 'l') {
setStatus('forging session…')
newSession()
+
return
}
if (key.ctrl && ch === 'v') {
paste()
+
return
}
@@ -530,6 +589,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'session.info':
setInfo(p as SessionInfo)
+
break
case 'thinking.delta':
@@ -559,9 +619,6 @@ export function App({ gw }: { gw: GatewayClient }) {
break
- case 'gateway.stderr':
- break
-
case 'gateway.protocol_error':
setStatus('protocol warning')
@@ -579,23 +636,24 @@ export function App({ gw }: { gw: GatewayClient }) {
break
- case 'tool.generating':
- break
-
case 'tool.progress':
if (p?.preview) {
setTools(prev => {
const idx = prev.findIndex(t => t.name === p.name)
- if (idx >= 0) return [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)]
+
+ if (idx >= 0) {
+ return [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)]
+ }
+
return prev
})
}
break
-
case 'tool.start': {
const ctx = (p.context as string) || ''
setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: ctx }])
+
break
}
@@ -605,8 +663,10 @@ export function App({ gw }: { gw: GatewayClient }) {
const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name
const ctx = done?.context || ''
appendMessage({ role: 'tool', text: `${label}${ctx ? ': ' + ctx : ''} ✓` })
+
return prev.filter(t => t.id !== p.tool_id)
})
+
break
case 'clarify.request':
@@ -644,7 +704,9 @@ export function App({ gw }: { gw: GatewayClient }) {
break
case 'message.delta':
- if (!p?.text || interruptedRef.current) break
+ if (!p?.text || interruptedRef.current) {
+ break
+ }
buf.current += p.rendered ?? p.text
setStreaming(buf.current.trimStart())
@@ -732,108 +794,164 @@ export function App({ gw }: { gw: GatewayClient }) {
.filter(Boolean)
.join('\n')
)
+
return true
}
case 'quit':
+
case 'exit':
+
case 'q':
die()
+
return true
case 'clear':
setStatus('forging session…')
newSession()
+
return true
case 'new':
setStatus('forging session…')
newSession('new session started')
+
return true
case 'compact':
setCompact(c => (arg ? true : !c))
sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`)
+
return true
case 'resume':
- if (!sid) { setPicker(true); return true }
setPicker(true)
- return true
+ return true
case 'copy': {
const all = messages.filter(m => m.role === 'assistant')
const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.length - 1]
- if (!target) { sys('nothing to copy'); return true }
+
+ if (!target) {
+ sys('nothing to copy')
+
+ return true
+ }
+
writeOsc52Clipboard(target.text)
sys('copied to clipboard')
+
return true
}
case 'paste':
paste()
- return true
+ return true
case 'logs': {
const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20))
sys(gw.getLogTail(limit) || 'no gateway logs')
+
return true
}
case 'statusbar':
+
case 'sb':
setStatusBar(v => !v)
sys(`status bar ${statusBar ? 'off' : 'on'}`)
+
return true
case 'queue':
- if (!arg) { sys(`${queueRef.current.length} queued message(s)`); return true }
+ if (!arg) {
+ sys(`${queueRef.current.length} queued message(s)`)
+
+ return true
+ }
+
enqueue(arg)
sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`)
+
return true
case 'undo':
- if (!sid) return true
+ if (!sid) {
+ return true
+ }
rpc('session.undo', { session_id: sid }).then((r: any) => {
if (r.removed > 0) {
setMessages(prev => {
const q = [...prev]
- while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop()
- if (q.at(-1)?.role === 'user') q.pop()
+
+ while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
+ q.pop()
+ }
+
+ if (q.at(-1)?.role === 'user') {
+ q.pop()
+ }
+
return q
})
sys(`undid ${r.removed} messages`)
- } else sys('nothing to undo')
+ } else {
+ sys('nothing to undo')
+ }
})
+
return true
case 'retry':
- if (!lastUserMsg) { sys('nothing to retry'); return true }
- if (sid) gw.request('session.undo', { session_id: sid }).catch(() => {})
+ if (!lastUserMsg) {
+ sys('nothing to retry')
+
+ return true
+ }
+
+ if (sid) {
+ gw.request('session.undo', { session_id: sid }).catch(() => {})
+ }
setMessages(prev => {
const q = [...prev]
- while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop()
+
+ while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
+ q.pop()
+ }
+
return q
})
send(lastUserMsg)
+
return true
default:
rpc('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => {
- if (r?.output) sys(r.output)
- else sys(`/${name}: no output`)
+ if (r?.output) {
+ sys(r.output)
+ } else {
+ sys(`/${name}: no output`)
+ }
})
.catch(() => {
gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid })
.then((d: any) => {
- if (d.type === 'exec') sys(d.output || '(no output)')
- else if (d.type === 'alias') slash(`/${d.target}${arg ? ' ' + arg : ''}`)
- else if (d.type === 'plugin') sys(d.output || '(no output)')
- else if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`); send(d.message) }
+ if (d.type === 'exec') {
+ sys(d.output || '(no output)')
+ } else if (d.type === 'alias') {
+ slash(`/${d.target}${arg ? ' ' + arg : ''}`)
+ } else if (d.type === 'plugin') {
+ sys(d.output || '(no output)')
+ } else if (d.type === 'skill') {
+ sys(`⚡ loading skill: ${d.name}`)
+ send(d.message)
+ }
})
.catch(() => sys(`unknown command: /${name}`))
})
+
return true
}
},
@@ -899,11 +1017,13 @@ export function App({ gw }: { gw: GatewayClient }) {
syncQueue()
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
setStatus('interrupting…')
+
return
}
if (picked && sid) {
send(picked)
+
return
}
@@ -965,16 +1085,14 @@ export function App({ gw }: { gw: GatewayClient }) {
{(m, i) => (
- {m.kind === 'intro' && m.info
- ? (
-
-
-
-
- )
- : (
-
- )}
+ {m.kind === 'intro' && m.info ? (
+
+
+
+
+ ) : (
+
+ )}
)}
@@ -1052,11 +1170,13 @@ export function App({ gw }: { gw: GatewayClient }) {
setSid(r.session_id)
setMessages([])
setInfo(r.info ?? null)
- if (r.info) appendHistory(introMsg(r.info))
+
+ if (r.info) {
+ appendHistory(introMsg(r.info))
+ }
setUsage(ZERO)
lastStatusNoteRef.current = ''
protocolWarnedRef.current = false
- stderrWarnedRef.current = false
sys(`resumed session (${r.message_count} messages)`)
setStatus('ready')
})
@@ -1071,23 +1191,38 @@ export function App({ gw }: { gw: GatewayClient }) {
- {' '}
+
- 0 && `${fmtK(usage.total)} tok`]} />
+ 0 && `${fmtK(usage.total)} tok`]}
+ statusColor={statusColor}
+ />
{!blocked && (
- {inputBuf.length ? '… ' : `${theme.brand.prompt} `}
+
+ {inputBuf.length ? '… ' : `${theme.brand.prompt} `}
+
)}
@@ -1096,9 +1231,12 @@ export function App({ gw }: { gw: GatewayClient }) {
{completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => {
const active = Math.max(0, compIdx - 8) + i === compIdx
+
return (
- {item.display}
+
+ {item.display}
+
{item.meta ? {item.meta} : null}
)