chore: formatting etc

This commit is contained in:
Brooklyn Nicholson 2026-04-03 20:14:57 -05:00
parent 56a69e519b
commit 5a5d90c85a
14 changed files with 698 additions and 237 deletions

View file

@ -5,20 +5,31 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AltScreen } from './altScreen.js'
import { Banner, SessionPanel } from './components/branding.js'
import { CommandPalette } from './components/commandPalette.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 { MaskedPrompt } from './components/maskedPrompt.js'
import { SessionPicker } from './components/sessionPicker.js'
import { Thinking } from './components/thinking.js'
import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js'
import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js'
import { type GatewayClient, type GatewayEvent } from './gatewayClient.js'
import * as inputHistory from './lib/history.js'
import { writeOsc52Clipboard } from './lib/osc52.js'
import { upsert } from './lib/messages.js'
import { writeOsc52Clipboard } from './lib/osc52.js'
import { paletteForLine, tabAdvance } from './lib/slash.js'
import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js'
import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SecretReq, SessionInfo, SudoReq, Usage } from './types.js'
import type {
ActiveTool,
ApprovalReq,
ClarifyReq,
Msg,
SecretReq,
SessionInfo,
SlashCatalog,
SudoReq,
Usage
} from './types.js'
const PLACEHOLDER = pick(PLACEHOLDERS)
@ -53,6 +64,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [historyIdx, setHistoryIdx] = useState<number | null>(null)
const [scrollOffset, setScrollOffset] = useState(0)
const [queuedDisplay, setQueuedDisplay] = useState<string[]>([])
const [catalog, setCatalog] = useState<SlashCatalog | null>(null)
const buf = useRef('')
const stickyRef = useRef(true)
@ -92,6 +104,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const pushHistory = (text: string) => {
const trimmed = text.trim()
if (trimmed && historyRef.current.at(-1) !== trimmed) {
historyRef.current.push(trimmed)
inputHistory.append(trimmed)
@ -143,12 +156,18 @@ export function App({ gw }: { gw: GatewayClient }) {
const newSession = (msg?: string) =>
rpc('session.create').then((r: any) => {
if (!r) return
if (!r) {
return
}
setSid(r.session_id)
setMessages([])
setUsage(ZERO)
setStatus('ready')
if (msg) sys(msg)
if (msg) {
sys(msg)
}
})
const idle = () => {
@ -276,6 +295,16 @@ export function App({ gw }: { gw: GatewayClient }) {
return
}
if (!inputBuf.length && key.tab && input.startsWith('/')) {
const next = tabAdvance(input, catalog)
if (next) {
setInput(next)
}
return
}
if (key.pageUp) {
scrollUp(5)
@ -373,6 +402,20 @@ export function App({ gw }: { gw: GatewayClient }) {
setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {}))
}
rpc('commands.catalog', {})
.then((r: any) => {
if (!r?.pairs) {
return
}
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
pairs: r.pairs as [string, string][],
sub: (r.sub ?? {}) as Record<string, string[]>
})
})
.catch(() => {})
setStatus('forging session…')
newSession()
@ -384,7 +427,10 @@ export function App({ gw }: { gw: GatewayClient }) {
break
case 'thinking.delta':
if (p?.text) setThinkingText(prev => prev + p.text)
if (p?.text) {
setThinkingText(prev => prev + p.text)
}
break
case 'message.start':
@ -449,24 +495,29 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'approval.request':
setApproval({ command: p.command, description: p.description })
setStatus('approval needed')
break
case 'sudo.request':
setSudo({ requestId: p.request_id })
setStatus('sudo password needed')
break
case 'secret.request':
setSecret({ requestId: p.request_id, prompt: p.prompt, envVar: p.env_var })
setStatus('secret input needed')
break
case 'background.complete':
sys(`[bg ${p.task_id}] ${p.text}`)
break
case 'btw.complete':
sys(`[btw] ${p.text}`)
break
case 'message.delta':
@ -547,22 +598,31 @@ export function App({ gw }: { gw: GatewayClient }) {
const arg = rest.join(' ')
switch (name) {
case 'help':
case 'help': {
const rows = catalog?.pairs ?? []
const cap = 52
const lines = rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`)
sys(
[
' Commands:',
...COMMANDS.map(([c, d]) => ` ${c.padEnd(12)} ${d}`),
...lines,
rows.length > cap ? `${rows.length - cap} more` : '',
'',
' Hotkeys:',
...HOTKEYS.map(([k, d]) => ` ${k.padEnd(12)} ${d}`)
].join('\n')
...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`)
]
.filter(Boolean)
.join('\n')
)
return true
}
case 'clear':
setStatus('forging session…')
newSession()
return true
case 'quit': // falls through
@ -575,6 +635,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'new':
setStatus('forging session…')
newSession('new session started')
return true
case 'undo':
@ -582,44 +643,51 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
rpc('session.undo', { session_id: sid })
.then((r: any) => {
if (r.removed > 0) {
setMessages(prev => {
const q = [...prev]
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()
}
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
q.pop()
}
if (q.at(-1)?.role === 'user') {
q.pop()
}
if (q.at(-1)?.role === 'user') {
q.pop()
}
return q
})
sys(`undid ${r.removed} messages`)
} else {
sys('nothing to undo')
}
})
return q
})
sys(`undid ${r.removed} messages`)
} 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(() => {})
}
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
case 'compact':
@ -633,14 +701,13 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
rpc('session.compress', { session_id: sid })
.then((r: any) => {
sys('context compressed')
rpc('session.compress', { session_id: sid }).then((r: any) => {
sys('context compressed')
if (r.usage) {
setUsage(r.usage)
}
})
if (r.usage) {
setUsage(r.usage)
}
})
return true
@ -695,319 +762,484 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'resume':
setPicker(true)
return true
case 'history':
if (!sid) { setPicker(true); return true }
rpc('session.history', { session_id: sid })
.then((r: any) => sys(`session ${sid}: ${r.count} messages in context`))
if (!sid) {
setPicker(true)
return true
}
rpc('session.history', { session_id: sid }).then((r: any) =>
sys(`session ${sid}: ${r.count} messages in context`)
)
return true
case 'title':
if (!sid) return true
if (!arg) {
rpc('session.title', { session_id: sid })
.then((r: any) => sys(`title: ${r.title || '(none)'} session: ${r.session_key}`))
if (!sid) {
return true
}
rpc('session.title', { session_id: sid, title: arg })
.then(() => sys(`title → ${arg}`))
if (!arg) {
rpc('session.title', { session_id: sid }).then((r: any) =>
sys(`title: ${r.title || '(none)'} session: ${r.session_key}`)
)
return true
}
rpc('session.title', { session_id: sid, title: arg }).then(() => sys(`title → ${arg}`))
return true
case 'tools':
if (!info?.tools || !Object.keys(info.tools).length) {
sys('no tools loaded')
return true
}
sys(
Object.entries(info.tools)
.map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`)
.join('\n')
)
return true
case 'skills':
if (!arg || arg === 'list') {
if (!info?.skills || !Object.keys(info.skills).length) {
sys('no skills loaded')
return true
}
sys(Object.entries(info.skills).map(([k, vs]) => `${k}: ${vs.join(', ')}`).join('\n'))
sys(
Object.entries(info.skills)
.map(([k, vs]) => `${k}: ${vs.join(', ')}`)
.join('\n')
)
return true
}
if (arg.startsWith('search ')) {
rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() })
.then((r: any) => {
if (!r.results?.length) { sys('no results'); return }
sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n'))
})
rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }).then((r: any) => {
if (!r.results?.length) {
sys('no results')
return
}
sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n'))
})
return true
}
if (arg.startsWith('install ')) {
rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() })
.then((r: any) => sys(r.installed ? `installed ${r.name}` : 'install failed'))
rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }).then((r: any) =>
sys(r.installed ? `installed ${r.name}` : 'install failed')
)
return true
}
if (arg === 'browse' || arg.startsWith('browse ')) {
rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() })
.then((r: any) => {
if (!r.results?.length) { sys('no skills available'); return }
sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n'))
})
rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }).then((r: any) => {
if (!r.results?.length) {
sys('no skills available')
return
}
sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n'))
})
return true
}
if (arg.startsWith('inspect ')) {
rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() })
.then((r: any) => sys(JSON.stringify(r.info, null, 2)))
rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }).then((r: any) =>
sys(JSON.stringify(r.info, null, 2))
)
return true
}
sys('usage: /skills [list|search <q>|install <name>|browse|inspect <name>]')
return true
case 'verbose':
rpc('config.set', { key: 'verbose', value: arg || 'cycle' })
.then((r: any) => sys(`verbose → ${r.value}`))
rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose → ${r.value}`))
return true
case 'yolo':
rpc('config.set', { key: 'yolo', value: '' })
.then((r: any) => sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`))
rpc('config.set', { key: 'yolo', value: '' }).then((r: any) =>
sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`)
)
return true
case 'reasoning':
if (!arg) {
sys('usage: /reasoning <none|low|medium|high|xhigh|show|hide>')
return true
}
rpc('config.set', { key: 'reasoning', value: arg })
.then((r: any) => sys(`reasoning → ${r.value}`))
rpc('config.set', { key: 'reasoning', value: arg }).then((r: any) => sys(`reasoning → ${r.value}`))
return true
case 'stop':
rpc('process.stop')
.then((r: any) => sys(`killed ${r.killed} process(es)`))
rpc('process.stop').then((r: any) => sys(`killed ${r.killed} process(es)`))
return true
case 'profile':
gw.request('config.get', { key: 'profile' })
.then((r: any) => sys(`profile: ${r.display}`))
.catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`))
return true
case 'save':
if (!sid) return true
rpc('session.save', { session_id: sid })
.then((r: any) => sys(`saved to ${r.file}`))
if (!sid) {
return true
}
rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved to ${r.file}`))
return true
case 'provider':
rpc('config.get', { key: 'provider' })
.then((r: any) => {
const lines = [`model: ${r.model} provider: ${r.provider}`]
if (r.providers?.length) lines.push(`available: ${r.providers.join(', ')}`)
sys(lines.join('\n'))
})
rpc('config.get', { key: 'provider' }).then((r: any) => {
const lines = [`model: ${r.model} provider: ${r.provider}`]
if (r.providers?.length) {
lines.push(`available: ${r.providers.join(', ')}`)
}
sys(lines.join('\n'))
})
return true
case 'prompt':
if (!arg) {
rpc('config.get', { key: 'prompt' })
.then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`))
rpc('config.get', { key: 'prompt' }).then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`))
return true
}
rpc('config.set', { key: 'prompt', value: arg })
.then((r: any) => sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared'))
rpc('config.set', { key: 'prompt', value: arg }).then((r: any) =>
sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared')
)
return true
case 'personality':
if (!arg) {
sys('usage: /personality <name> (concise, creative, analytical, friendly, none)')
return true
}
rpc('config.set', { key: 'personality', value: arg })
.then((r: any) => sys(`personality → ${r.value || 'default'}`))
rpc('config.set', { key: 'personality', value: arg }).then((r: any) =>
sys(`personality → ${r.value || 'default'}`)
)
return true
case 'plan':
send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.')
return true
case 'background':
case 'bg':
if (!arg) {
sys('usage: /background <prompt>')
return true
}
rpc('prompt.background', { session_id: sid, text: arg })
.then((r: any) => sys(`background task ${r.task_id} started`))
rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) =>
sys(`background task ${r.task_id} started`)
)
return true
case 'btw':
if (!arg) {
sys('usage: /btw <question>')
return true
}
rpc('prompt.btw', { session_id: sid, text: arg })
.then(() => sys('btw running…'))
rpc('prompt.btw', { session_id: sid, text: arg }).then(() => sys('btw running…'))
return true
case 'queue':
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 'rollback':
if (!sid) return true
if (!arg) {
rpc('rollback.list', { session_id: sid })
.then((r: any) => {
if (!r.enabled) { sys('checkpoints not enabled — use hermes --checkpoints'); return }
if (!r.checkpoints?.length) { sys('no checkpoints'); return }
sys(r.checkpoints.map((c: any, i: number) =>
` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})`
).join('\n'))
})
if (!sid) {
return true
}
if (!arg) {
rpc('rollback.list', { session_id: sid }).then((r: any) => {
if (!r.enabled) {
sys('checkpoints not enabled — use hermes --checkpoints')
return
}
if (!r.checkpoints?.length) {
sys('no checkpoints')
return
}
sys(
r.checkpoints
.map((c: any, i: number) => ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})`)
.join('\n')
)
})
return true
}
if (arg.startsWith('diff ')) {
const ref = arg.slice(5).trim()
rpc('rollback.list', { session_id: sid }).then((r: any) => {
const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref
if (!hash) { sys(`checkpoint ${ref} not found`); return }
rpc('rollback.diff', { session_id: sid, hash })
.then((d: any) => sys(d.stat || d.diff || 'no changes'))
if (!hash) {
sys(`checkpoint ${ref} not found`)
return
}
rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => sys(d.stat || d.diff || 'no changes'))
})
return true
}
{
const parts = arg.trim().split(/\s+/)
const ref = parts[0]!
const file = parts[1]
rpc('rollback.list', { session_id: sid }).then((r: any) => {
const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref
if (!hash) { sys(`checkpoint ${ref} not found`); return }
rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) })
.then((d: any) => sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`))
if (!hash) {
sys(`checkpoint ${ref} not found`)
return
}
rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }).then((d: any) =>
sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`)
)
})
}
return true
case 'insights':
rpc('insights.get', { days: arg ? parseInt(arg) : 30 })
.then((r: any) => sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`))
rpc('insights.get', { days: arg ? parseInt(arg) : 30 }).then((r: any) =>
sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`)
)
return true
case 'toolsets':
if (!info?.tools) {
sys('no toolsets loaded')
return true
}
sys(Object.entries(info.tools).map(([k, vs]) => `${k}: ${vs.length} tools`).join('\n'))
sys(
Object.entries(info.tools)
.map(([k, vs]) => `${k}: ${vs.length} tools`)
.join('\n')
)
return true
case 'paste':
sys('clipboard paste: use your terminal\'s paste shortcut (images not yet supported in TUI)')
sys("clipboard paste: use your terminal's paste shortcut (images not yet supported in TUI)")
return true
case 'reload-mcp':
case 'reload_mcp':
rpc('reload.mcp', { session_id: sid })
.then(() => sys('MCP servers reloaded'))
rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP servers reloaded'))
return true
case 'browser':
if (!arg || arg === 'status') {
rpc('browser.manage', { action: 'status' })
.then((r: any) => sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected'))
rpc('browser.manage', { action: 'status' }).then((r: any) =>
sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected')
)
} else if (arg === 'connect' || arg.startsWith('connect ')) {
const url = arg.split(/\s+/)[1]
rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) })
.then((r: any) => sys(`browser connected: ${r.url}`))
rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }).then((r: any) =>
sys(`browser connected: ${r.url}`)
)
} else if (arg === 'disconnect') {
rpc('browser.manage', { action: 'disconnect' })
.then(() => sys('browser disconnected'))
rpc('browser.manage', { action: 'disconnect' }).then(() => sys('browser disconnected'))
} else {
sys('usage: /browser [connect|disconnect|status]')
}
return true
case 'platforms':
case 'gateway':
sys('gateway status is not available in TUI mode')
return true
case 'statusbar':
case 'sb':
setStatusBar(v => !v)
sys(`status bar ${statusBar ? 'off' : 'on'}`)
return true
case 'voice':
if (!arg || arg === 'status') {
rpc('voice.toggle', { action: 'status' })
.then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`))
rpc('voice.toggle', { action: 'status' }).then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`))
} else if (arg === 'on' || arg === 'off') {
rpc('voice.toggle', { action: arg })
.then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`))
rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`))
} else if (arg === 'record') {
rpc('voice.record', { action: 'start' })
.then(() => sys('recording… (use /voice stop to transcribe)'))
rpc('voice.record', { action: 'start' }).then(() => sys('recording… (use /voice stop to transcribe)'))
} else if (arg === 'stop') {
rpc('voice.record', { action: 'stop' })
.then((r: any) => {
if (r.text) { send(r.text) } else { sys('no speech detected') }
})
rpc('voice.record', { action: 'stop' }).then((r: any) => {
if (r.text) {
send(r.text)
} else {
sys('no speech detected')
}
})
} else if (arg === 'tts') {
const last = messages.filter(m => m.role === 'assistant').at(-1)
if (last) {
rpc('voice.tts', { text: last.text })
.then(() => sys('speaking…'))
rpc('voice.tts', { text: last.text }).then(() => sys('speaking…'))
} else {
sys('no response to speak')
}
} else {
sys('usage: /voice [on|off|status|record|stop|tts]')
}
return true
case 'plugins':
rpc('plugins.list')
.then((r: any) => {
if (!r.plugins?.length) { sys('no plugins installed'); return }
sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n'))
})
rpc('plugins.list').then((r: any) => {
if (!r.plugins?.length) {
sys('no plugins installed')
return
}
sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n'))
})
return true
case 'cron':
if (!arg || arg === 'list') {
rpc('cron.manage', { action: 'list' })
.then((r: any) => {
const jobs = r.jobs || r.schedules || []
if (!jobs.length) { sys('no cron jobs'); return }
sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n'))
})
rpc('cron.manage', { action: 'list' }).then((r: any) => {
const jobs = r.jobs || r.schedules || []
if (!jobs.length) {
sys('no cron jobs')
return
}
sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n'))
})
} else {
const parts = arg.split(/\s+/)
const sub = parts[0]!
if (sub === 'add' || sub === 'create') {
const name = parts[1] || ''
const schedule = parts[2] || ''
const prompt = parts.slice(3).join(' ')
rpc('cron.manage', { action: 'add', name, schedule, prompt })
.then((r: any) => sys(r.message || r.status || 'created'))
rpc('cron.manage', { action: 'add', name, schedule, prompt }).then((r: any) =>
sys(r.message || r.status || 'created')
)
} else {
rpc('cron.manage', { action: sub, name: parts[1] || '' })
.then((r: any) => sys(r.message || r.status || JSON.stringify(r)))
rpc('cron.manage', { action: sub, name: parts[1] || '' }).then((r: any) =>
sys(r.message || r.status || JSON.stringify(r))
)
}
}
return true
case 'update':
sys('update not available in TUI mode — run: pip install -U hermes-agent')
return true
case 'hermes':
if (!arg) {
sys(
'usage: /hermes <args…> non-interactive `hermes` CLI (e.g. sessions list, chat -q "hi"). Interactive setup/browse/edit must run in a separate terminal.'
)
return true
}
rpc('cli.exec', { argv: arg.split(/\s+/).filter(Boolean) })
.then((r: any) => {
if (r.blocked) {
sys(r.hint ?? 'blocked')
return
}
sys(r.output ?? '(no output)')
if (r.code !== 0) {
sys(`exit ${r.code}`)
}
})
.catch((e: Error) => sys(`error: ${e.message}`))
return true
case 'model':
@ -1017,8 +1249,7 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
rpc('config.set', { key: 'model', value: arg })
.then(() => sys(`model → ${arg}`))
rpc('config.set', { key: 'model', value: arg }).then(() => sys(`model → ${arg}`))
return true
@ -1029,8 +1260,7 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
rpc('config.set', { key: 'skin', value: arg })
.then(() => sys(`skin → ${arg} (restart to apply)`))
rpc('config.set', { key: 'skin', value: arg }).then(() => sys(`skin → ${arg} (restart to apply)`))
return true
@ -1060,11 +1290,12 @@ export function App({ gw }: { gw: GatewayClient }) {
})
.catch(() => sys(`unknown command: /${name}`))
})
return true
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar]
[catalog, compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar]
)
const submit = useCallback(
@ -1320,7 +1551,7 @@ export function App({ gw }: { gw: GatewayClient }) {
/>
)}
{!blocked && input.startsWith('/') && <CommandPalette filter={input} t={theme} />}
{!blocked && input.startsWith('/') && <CommandPalette matches={paletteForLine(input, catalog)} t={theme} />}
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />

View file

@ -1,23 +1,21 @@
import { Box, Text } from 'ink'
import { COMMANDS } from '../constants.js'
import type { Theme } from '../theme.js'
export function CommandPalette({ filter, t }: { filter: string; t: Theme }) {
const matches = COMMANDS.filter(([cmd]) => cmd.startsWith(filter))
export function CommandPalette({ matches, t }: { matches: [string, string][]; t: Theme }) {
if (!matches.length) {
return null
}
return (
<Box flexDirection="column">
{matches.map(([cmd, desc]) => (
<Text key={cmd}>
<Box flexDirection="column" marginBottom={1}>
{matches.map(([cmd, desc], i) => (
<Text key={`${i}-${cmd}`}>
<Text bold color={t.color.amber}>
{cmd}
</Text>
<Text color={t.color.dim}> {desc}</Text>
{desc ? <Text color={t.color.dim}> {desc}</Text> : null}
</Text>
))}
</Box>

View file

@ -147,37 +147,47 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
if (line.match(/^>\s?/)) {
const quoteLines: string[] = []
while (i < lines.length && lines[i]!.match(/^>\s?/)) {
quoteLines.push(lines[i]!.replace(/^>\s?/, ''))
i++
}
nodes.push(
<Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => (
<Text color={t.color.dim} key={qi}>
{' │ '}<MdInline t={t} text={ql} />
{' │ '}
<MdInline t={t} text={ql} />
</Text>
))}
</Box>
)
continue
}
if (line.includes('|') && line.trim().startsWith('|')) {
const tableRows: string[][] = []
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
const row = lines[i]!.trim()
if (!/^[|\s:-]+$/.test(row)) {
tableRows.push(
row.split('|').filter(Boolean).map(c => c.trim())
row
.split('|')
.filter(Boolean)
.map(c => c.trim())
)
}
i++
}
if (tableRows.length) {
const widths = tableRows[0]!.map((_, ci) =>
Math.max(...tableRows.map(r => (r[ci] ?? '').length))
)
const widths = tableRows[0]!.map((_, ci) => Math.max(...tableRows.map(r => (r[ci] ?? '').length)))
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{tableRows.map((row, ri) => (
@ -188,6 +198,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
</Box>
)
}
continue
}

View file

@ -5,7 +5,11 @@ import { useState } from 'react'
import type { Theme } from '../theme.js'
export function MaskedPrompt({
icon, label, onSubmit, sub, t
icon,
label,
onSubmit,
sub,
t
}: {
icon: string
label: string
@ -17,8 +21,10 @@ export function MaskedPrompt({
return (
<Box flexDirection="column">
<Text bold color={t.color.warn}>{icon} {label}</Text>
{sub && <Text color={t.color.dim}> {sub}</Text>}
<Text bold color={t.color.warn}>
{icon} {label}
</Text>
{sub && <Text color={t.color.dim}> {sub}</Text>}
<Box>
<Text color={t.color.label}>{'> '}</Text>

View file

@ -14,8 +14,15 @@ interface SessionItem {
function age(ts: number): string {
const d = (Date.now() / 1000 - ts) / 86400
if (d < 1) return 'today'
if (d < 2) return 'yesterday'
if (d < 1) {
return 'today'
}
if (d < 2) {
return 'yesterday'
}
return `${Math.floor(d)}d ago`
}
@ -46,16 +53,32 @@ export function SessionPicker({
}, [gw])
useInput((ch, key) => {
if (key.escape) return onCancel()
if (key.upArrow && sel > 0) setSel(s => s - 1)
if (key.downArrow && sel < items.length - 1) setSel(s => s + 1)
if (key.return && items[sel]) onSelect(items[sel]!.id)
if (key.escape) {
return onCancel()
}
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
if (key.downArrow && sel < items.length - 1) {
setSel(s => s + 1)
}
if (key.return && items[sel]) {
onSelect(items[sel]!.id)
}
const n = parseInt(ch)
if (n >= 1 && n <= Math.min(9, items.length)) onSelect(items[n - 1]!.id)
if (n >= 1 && n <= Math.min(9, items.length)) {
onSelect(items[n - 1]!.id)
}
})
if (loading) return <Text color={t.color.dim}>loading sessions</Text>
if (loading) {
return <Text color={t.color.dim}>loading sessions</Text>
}
if (!items.length) {
return (
@ -71,10 +94,13 @@ export function SessionPicker({
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>Resume Session</Text>
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
<Text bold color={t.color.amber}>
Resume Session
</Text>
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{visible.map((s, vi) => {
const i = off + vi
return (
<Text key={s.id}>
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
@ -82,12 +108,13 @@ export function SessionPicker({
{i + 1}. {s.title || s.preview || s.id.slice(0, 8)}
</Text>
<Text color={t.color.dim}>
{' '}({s.message_count} msgs, {age(s.started_at)})
{' '}
({s.message_count} msgs, {age(s.started_at)})
</Text>
</Text>
)
})}
{off + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - off - VISIBLE} more</Text>}
{off + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>/ select · Enter resume · 1-9 quick · Esc cancel</Text>
</Box>
)

View file

@ -6,7 +6,17 @@ import { pick } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool } from '../types.js'
export function Thinking({ reasoning, t, thinking, tools }: { reasoning: string; t: Theme; thinking?: string; tools: ActiveTool[] }) {
export function Thinking({
reasoning,
t,
thinking,
tools
}: {
reasoning: string
t: Theme
thinking?: string
tools: ActiveTool[]
}) {
const [frame, setFrame] = useState(0)
const [verb] = useState(() => pick(VERBS))
const [face] = useState(() => pick(FACES))

View file

@ -1,50 +1,6 @@
import type { Theme } from './theme.js'
import type { Role, Usage } from './types.js'
export const COMMANDS: [string, string][] = [
['/help', 'commands & hotkeys'],
['/new', 'new session'],
['/resume', 'resume a previous session'],
['/title', 'set session title'],
['/history', 'show session list'],
['/clear', 'reset session + chat'],
['/undo', 'drop last exchange'],
['/retry', 'resend last message'],
['/save', 'save conversation to file'],
['/compact', 'toggle compact [focus]'],
['/compress', 'compress context'],
['/model', 'switch model'],
['/skin', 'change theme'],
['/provider', 'show model/provider info'],
['/prompt', 'set custom system prompt'],
['/personality', 'set personality preset'],
['/verbose', 'cycle tool verbosity'],
['/yolo', 'toggle auto-approve mode'],
['/reasoning', 'set reasoning level'],
['/tools', 'list active tools'],
['/toolsets', 'list toolsets'],
['/skills', 'list skills'],
['/stop', 'kill background processes'],
['/background', 'run prompt in background'],
['/btw', 'side question (no tools)'],
['/plan', 'invoke plan skill'],
['/queue', 'queue prompt for next turn'],
['/profile', 'show active profile'],
['/cost', 'token usage stats'],
['/context', 'context window info'],
['/insights', 'usage analytics'],
['/copy', 'copy last response'],
['/paste', 'clipboard info'],
['/config', 'show config'],
['/status', 'session info'],
['/statusbar', 'toggle status bar'],
['/voice', 'voice mode toggle'],
['/reload-mcp', 'reload MCP servers'],
['/rollback', 'checkpoint info'],
['/browser', 'browser tools info'],
['/quit', 'exit hermes']
]
export const FACES = [
'(。•́︿•̀。)',
'(◔_◔)',
@ -67,6 +23,7 @@ export const HOTKEYS: [string, string][] = [
['Ctrl+C', 'interrupt / clear / exit'],
['Ctrl+D', 'exit'],
['Ctrl+L', 'clear screen'],
['Tab', 'complete /commands (registry-aware)'],
['↑/↓', 'queue edit (if queued) / input history'],
['PgUp/PgDn', 'scroll messages'],
['Esc', 'clear input'],

View file

@ -1,5 +1,5 @@
import React from 'react'
import { render } from 'ink'
import React from 'react'
import { App } from './app.js'
import { GatewayClient } from './gatewayClient.js'

View file

@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs'
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
@ -17,34 +17,51 @@ function decode(s: string): string {
}
export function load(): string[] {
if (cache) return cache
if (cache) {
return cache
}
try {
if (existsSync(file)) {
cache = readFileSync(file, 'utf8')
.split('\n')
.filter(Boolean)
.map(decode)
.slice(-MAX)
cache = readFileSync(file, 'utf8').split('\n').filter(Boolean).map(decode).slice(-MAX)
} else {
cache = []
}
} catch {
cache = []
}
return cache
}
export function append(line: string): void {
const trimmed = line.trim()
if (!trimmed) return
if (!trimmed) {
return
}
const items = load()
if (items.at(-1) === trimmed) return
if (items.at(-1) === trimmed) {
return
}
items.push(trimmed)
if (items.length > MAX) items.splice(0, items.length - MAX)
if (items.length > MAX) {
items.splice(0, items.length - MAX)
}
try {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
appendFileSync(file, encode(trimmed) + '\n')
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
export function all(): string[] {

124
ui-tui/src/lib/slash.ts Normal file
View file

@ -0,0 +1,124 @@
import type { SlashCatalog } from '../types.js'
/** Match SlashCommandCompleter: command names, subcommands, then skills. */
export function paletteForLine(line: string, c: SlashCatalog | null): [string, string][] {
if (!c || !line.startsWith('/')) {
return []
}
const parts = line.split(/\s+/)
const baseRaw = parts[0]!
const base = baseRaw.toLowerCase()
const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' '))
if (inSub) {
const subText = parts.length > 1 ? parts.slice(1).join(' ') : ''
if (subText.includes(' ') || parts.length > 2) {
return []
}
const head = subText.split(/\s+/)[0] ?? ''
if (subText.includes(' ') && head !== subText) {
return []
}
const canonical = c.canon[base] ?? baseRaw
const subs = c.sub[canonical]
if (!subs?.length) {
return []
}
const lo = head.toLowerCase()
return subs
.filter(s => s.toLowerCase().startsWith(lo) && s.toLowerCase() !== lo)
.slice(0, 14)
.map(s => [s, ''])
}
const word = line.slice(1)
return c.pairs
.filter(([k]) => k.slice(1).startsWith(word))
.slice(0, 16)
.map(([k, d]) => [k, d])
}
/** Tab: longest common prefix of palette matches, or first unique completion + space. */
export function tabAdvance(line: string, c: SlashCatalog | null): string | null {
if (!c || !line.startsWith('/')) {
return null
}
const rows = paletteForLine(line, c)
if (!rows.length) {
return null
}
const parts = line.split(/\s+/)
const baseRaw = parts[0]!
const base = baseRaw.toLowerCase()
const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' '))
if (inSub) {
const subText = parts.length > 1 ? parts.slice(1).join(' ') : ''
const head = subText.split(/\s+/)[0] ?? ''
const picks = rows.map(([s]) => s)
if (picks.length === 1) {
return `${baseRaw} ${picks[0]!} `
}
const cp = commonPrefix(picks)
if (cp.length > head.length) {
return `${baseRaw} ${cp}`
}
return null
}
const word = line.slice(1)
const names = rows.map(([k]) => k.slice(1))
const cp = commonPrefix(names)
if (names.length === 1) {
return `/${names[0]!} `
}
if (cp.length > word.length) {
return `/${cp}`
}
return null
}
function commonPrefix(xs: string[]): string {
if (!xs.length) {
return ''
}
let n = 0
outer: while (true) {
const ch = xs[0]![n]
if (ch === undefined) {
break
}
for (const x of xs) {
if (x[n] !== ch) {
break outer
}
}
n++
}
return xs[0]!.slice(0, n)
}

View file

@ -1,5 +1,5 @@
import React from 'react'
import { render } from 'ink'
import React from 'react'
import { App } from './app.js'
import { GatewayClient } from './gatewayClient.js'

View file

@ -43,3 +43,10 @@ export interface SecretReq {
prompt: string
requestId: string
}
/** From `commands.catalog` — mirrors hermes_cli.commands COMMANDS + SUBCOMMANDS + skills. */
export interface SlashCatalog {
canon: Record<string, string>
pairs: [string, string][]
sub: Record<string, string[]>
}