diff --git a/ui-tui/.gitignore b/ui-tui/.gitignore new file mode 100644 index 000000000..1eae0cf67 --- /dev/null +++ b/ui-tui/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/ui-tui/package.json b/ui-tui/package.json index dd404863f..2ea39f685 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx --watch src/main.tsx", - "start": "tsx src/main.tsx", + "dev": "tsx --watch src/entry.tsx", + "start": "tsx src/entry.tsx", "build": "tsc", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx new file mode 100644 index 000000000..9623d10ad --- /dev/null +++ b/ui-tui/src/app.tsx @@ -0,0 +1,942 @@ +import { Box, Text, useApp, useInput, useStdout } from 'ink' +import TextInput from 'ink-text-input' +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 { MessageLine } from './components/messageLine.js' +import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' +import { QueuedMessages } from './components/queuedMessages.js' +import { Thinking } from './components/thinking.js' +import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import type { GatewayClient } from './gatewayClient.js' +import { type GatewayEvent } from './gatewayClient.js' +import { upsert } from './lib/messages.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, SessionInfo, Usage } from './types.js' + +const PLACEHOLDER = pick(PLACEHOLDERS) + +export function App({ gw }: { gw: GatewayClient }) { + const { exit } = useApp() + const { stdout } = useStdout() + const cols = stdout?.columns ?? 80 + const rows = stdout?.rows ?? 24 + + const [input, setInput] = useState('') + const [inputBuf, setInputBuf] = useState([]) + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('summoning hermes…') + const [sid, setSid] = useState(null) + const [theme, setTheme] = useState(DEFAULT_THEME) + const [info, setInfo] = useState(null) + const [thinking, setThinking] = useState(false) + const [tools, setTools] = useState([]) + const [busy, setBusy] = useState(false) + const [compact, setCompact] = useState(false) + const [usage, setUsage] = useState(ZERO) + const [clarify, setClarify] = useState(null) + const [approval, setApproval] = useState(null) + const [reasoning, setReasoning] = useState('') + const [lastUserMsg, setLastUserMsg] = useState('') + const [queueEditIdx, setQueueEditIdx] = useState(null) + const [historyIdx, setHistoryIdx] = useState(null) + const [scrollOffset, setScrollOffset] = useState(0) + const [queuedDisplay, setQueuedDisplay] = useState([]) + + const buf = useRef('') + const stickyRef = useRef(true) + const queueRef = useRef([]) + const historyRef = useRef([]) + const historyDraftRef = useRef('') + const queueEditRef = useRef(null) + const lastEmptyAt = useRef(0) + + const empty = !messages.length + const blocked = !!(clarify || approval) + + const syncQueue = () => setQueuedDisplay([...queueRef.current]) + + const setQueueEdit = (idx: number | null) => { + queueEditRef.current = idx + setQueueEditIdx(idx) + } + + const enqueue = (text: string) => { + queueRef.current.push(text) + syncQueue() + } + + const dequeue = () => { + const [head, ...rest] = queueRef.current + queueRef.current = rest + syncQueue() + + return head + } + + const replaceQ = (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() + } + + const pushHistory = (text: string) => { + const trimmed = text.trim() + + if (trimmed && historyRef.current.at(-1) !== trimmed) { + historyRef.current.push(trimmed) + } + } + + useEffect(() => { + if (stickyRef.current) { + setScrollOffset(0) + } + }, [messages.length]) + + const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2) + + const viewport = useMemo(() => { + if (!messages.length) { + return { above: 0, end: 0, start: 0 } + } + + const end = Math.max(0, messages.length - scrollOffset) + const width = Math.max(20, cols - 5) + + let budget = msgBudget + let start = end + + for (let i = end - 1; i >= 0 && budget > 0; i--) { + const msg = messages[i]! + const margin = msg.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 + budget -= margin + estimateRows(msg.role === 'user' ? userDisplay(msg.text) : msg.text, width) + + if (budget >= 0) { + start = i + } + } + + if (start === end && end > 0) { + start = end - 1 + } + + return { above: start, end, start } + }, [cols, messages, msgBudget, scrollOffset]) + + const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), []) + + const idle = () => { + setThinking(false) + setTools([]) + setBusy(false) + setClarify(null) + setApproval(null) + setReasoning('') + } + + const die = () => { + gw.kill() + exit() + } + + const clearIn = () => { + setInput('') + setInputBuf([]) + setQueueEdit(null) + setHistoryIdx(null) + historyDraftRef.current = '' + } + + const scrollBot = () => { + setScrollOffset(0) + stickyRef.current = true + } + + const scrollUp = (n: number) => { + setScrollOffset(prev => Math.min(Math.max(0, messages.length - 1), prev + n)) + stickyRef.current = false + } + + const scrollDown = (n: number) => { + setScrollOffset(prev => { + const v = Math.max(0, prev - n) + + if (!v) { + stickyRef.current = true + } + + return v + }) + } + + const send = (text: string) => { + setLastUserMsg(text) + setMessages(prev => [...prev, { role: 'user', text }]) + scrollBot() + setStatus('thinking…') + setBusy(true) + buf.current = '' + gw.request('prompt.submit', { session_id: sid, text }).catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + setBusy(false) + }) + } + + const shellExec = (cmd: string) => { + setMessages(prev => [...prev, { role: 'user', text: `!${cmd}` }]) + setBusy(true) + setStatus('running…') + 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) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => { + setStatus('ready') + setBusy(false) + }) + } + + const interpolate = (text: string, then: (result: string) => void) => { + setStatus('interpolating…') + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + Promise.all( + matches.map(match => + gw + .request('shell.exec', { command: match[1]! }) + .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim()) + .catch(() => '(error)') + ) + ).then(results => { + let out = text + + for (let i = matches.length - 1; i >= 0; i--) { + out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) + } + + then(out) + }) + } + + useInput((ch, key) => { + if (blocked) { + if (key.ctrl && ch === 'c' && approval) { + gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) + setApproval(null) + sys('denied') + } + + return + } + + if (key.pageUp) { + scrollUp(5) + + return + } + + if (key.pageDown) { + scrollDown(5) + + return + } + + if (key.upArrow && !inputBuf.length) { + if (queueRef.current.length) { + const len = queueRef.current.length + const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % len + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') + } else if (historyRef.current.length) { + const hist = historyRef.current + const idx = historyIdx === null ? hist.length - 1 : Math.max(0, historyIdx - 1) + + if (historyIdx === null) { + historyDraftRef.current = input + } + + setHistoryIdx(idx) + setQueueEdit(null) + setInput(hist[idx] ?? '') + } + + return + } + + if (key.downArrow && !inputBuf.length) { + if (queueRef.current.length) { + const len = queueRef.current.length + const idx = queueEditIdx === null ? len - 1 : (queueEditIdx - 1 + len) % len + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') + } else if (historyIdx !== null) { + const hist = historyRef.current + const next = historyIdx + 1 + + if (next >= hist.length) { + setHistoryIdx(null) + setInput(historyDraftRef.current) + } else { + setHistoryIdx(next) + setInput(hist[next] ?? '') + } + } + + return + } + + if (key.ctrl && ch === 'c') { + if (busy && sid) { + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + idle() + setStatus('interrupted') + sys('interrupted by user') + setTimeout(() => setStatus('ready'), 1500) + } else if (input || inputBuf.length) { + clearIn() + } else { + die() + } + + return + } + + if (key.ctrl && ch === 'd') { + die() + } + + if (key.ctrl && ch === 'l') { + setMessages([]) + } + + if (key.escape) { + clearIn() + } + }) + + const onEvent = useCallback( + (ev: GatewayEvent) => { + const p = ev.payload as any + + switch (ev.type) { + case 'gateway.ready': + if (p?.skin) { + setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) + } + + setStatus('forging session…') + gw.request('session.create') + .then((r: any) => { + setSid(r.session_id) + setStatus('ready') + }) + .catch((e: Error) => setStatus(`error: ${e.message}`)) + + break + + case 'session.info': + setInfo(p as SessionInfo) + + break + + case 'thinking.delta': + break + + case 'message.start': + setThinking(true) + setBusy(true) + setReasoning('') + setStatus('thinking…') + + break + + case 'status.update': + if (p?.text) { + setStatus(p.text) + } + + break + + case 'reasoning.delta': + if (p?.text) { + setReasoning(prev => prev + p.text) + } + + break + + case 'tool.generating': + if (p?.name) { + setStatus(`preparing ${p.name}…`) + } + + break + + case 'tool.progress': + if (p?.preview) { + setMessages(prev => + prev.at(-1)?.role === 'tool' + ? [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] + : [...prev, { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] + ) + } + + break + + case 'tool.start': + setTools(prev => [...prev, { id: p.tool_id, name: p.name }]) + setStatus(`running ${p.name}…`) + setMessages(prev => [...prev, { role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}…` }]) + + break + + case 'tool.complete': + setTools(prev => prev.filter(t => t.id !== p.tool_id)) + + break + + case 'clarify.request': + setClarify({ choices: p.choices, question: p.question, requestId: p.request_id }) + setStatus('waiting for input…') + + break + + case 'approval.request': + setApproval({ command: p.command, description: p.description }) + setStatus('approval needed') + + break + + case 'message.delta': + if (!p?.text) { + break + } + + buf.current += p.text + setThinking(false) + setTools([]) + setReasoning('') + setMessages(prev => upsert(prev, 'assistant', buf.current.trimStart())) + + break + case 'message.complete': { + idle() + setMessages(prev => upsert(prev, 'assistant', (p?.text ?? buf.current).trimStart())) + buf.current = '' + setStatus('ready') + + if (p?.usage) { + setUsage(p.usage) + } + + if (p?.status === 'interrupted') { + sys('response interrupted') + } + + if (queueEditRef.current !== null) { + break + } + + const next = dequeue() + + if (next) { + setLastUserMsg(next) + setMessages(prev => [...prev, { role: 'user' as const, text: next }]) + setStatus('thinking…') + setBusy(true) + buf.current = '' + gw.request('prompt.submit', { session_id: ev.session_id, text: next }).catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + setBusy(false) + }) + } + + break + } + + case 'error': + sys(`error: ${p?.message}`) + idle() + setStatus('ready') + + break + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [gw, sys] + ) + + useEffect(() => { + gw.on('event', onEvent) + gw.on('exit', () => { + setStatus('gateway exited') + exit() + }) + + return () => { + gw.off('event', onEvent) + } + }, [exit, gw, onEvent]) + + const slash = useCallback( + (cmd: string): boolean => { + const [name, ...rest] = cmd.slice(1).split(/\s+/) + const arg = rest.join(' ') + + switch (name) { + case 'help': + sys( + [ + ' Commands:', + ...COMMANDS.map(([c, d]) => ` ${c.padEnd(12)} ${d}`), + '', + ' Hotkeys:', + ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(12)} ${d}`) + ].join('\n') + ) + + return true + + case 'clear': + setMessages([]) + + return true + + case 'quit': // falls through + + case 'exit': + die() + + return true + + case 'new': + setStatus('forging session…') + gw.request('session.create') + .then((r: any) => { + setSid(r.session_id) + setMessages([]) + setUsage(ZERO) + setStatus('ready') + sys('new session started') + }) + .catch((e: Error) => setStatus(`error: ${e.message}`)) + + return true + + case 'undo': + if (!sid) { + return true + } + + gw.request('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() + } + + return q + }) + sys(`undid ${r.removed} messages`) + } else { + sys('nothing to undo') + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + + return true + + case 'retry': + if (!lastUserMsg) { + sys('nothing to retry') + + return true + } + + setMessages(prev => { + const q = [...prev] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + return q + }) + send(lastUserMsg) + + return true + + case 'compact': + setCompact(c => (arg ? true : !c)) + sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) + + return true + + case 'compress': + if (!sid) { + return true + } + + gw.request('session.compress', { session_id: sid }) + .then((r: any) => { + sys('context compressed') + + if (r.usage) { + setUsage(r.usage) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + + return true + + case 'cost': // falls through + + case 'usage': + sys( + `in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}` + ) + + 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 + } + + process.stdout.write(`\x1b]52;c;${Buffer.from(target.text).toString('base64')}\x07`) + sys('copied to clipboard') + + return true + } + + case 'context': { + const pct = Math.min(100, Math.round((usage.total / MAX_CTX) * 100)) + const bar = Math.round((pct / 100) * 30) + const icon = pct < 50 ? '✓' : pct < 80 ? '⚠' : '✗' + sys( + `context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(bar)}${'░'.repeat(30 - bar)}] ${icon}` + ) + + return true + } + + case 'config': + sys( + `model: ${info?.model ?? '?'} session: ${sid ?? 'none'} compact: ${compact}\ntools: ${flat(info?.tools ?? {}).length} skills: ${flat(info?.skills ?? {}).length}` + ) + + return true + + case 'status': + sys( + `session: ${sid ?? 'none'} status: ${status} tokens: ${fmtK(usage.input)}↑ ${fmtK(usage.output)}↓ (${usage.calls} calls)` + ) + + return true + + case 'skills': + 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') + ) + + return true + + case 'model': + if (!arg) { + sys('usage: /model ') + + return true + } + + gw.request('config.set', { key: 'model', value: arg }) + .then(() => sys(`model → ${arg}`)) + .catch((e: Error) => sys(`error: ${e.message}`)) + + return true + + case 'skin': + if (!arg) { + sys('usage: /skin ') + + return true + } + + gw.request('config.set', { key: 'skin', value: arg }) + .then(() => sys(`skin → ${arg} (restart to apply)`)) + .catch((e: Error) => sys(`error: ${e.message}`)) + + return true + + default: + return false + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [compact, gw, info, lastUserMsg, messages, sid, status, sys, usage] + ) + + const submit = useCallback( + (value: string) => { + if (!value.trim() && !inputBuf.length) { + const now = Date.now() + const dbl = now - lastEmptyAt.current < 450 + lastEmptyAt.current = now + + if (dbl && queueRef.current.length) { + if (busy && sid) { + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + setStatus('interrupting…') + + return + } + + const next = dequeue() + + if (next && sid) { + setQueueEdit(null) + send(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + if (value.endsWith('\\')) { + setInputBuf(prev => [...prev, value.slice(0, -1)]) + setInput('') + + return + } + + const full = [...inputBuf, value].join('\n') + setInputBuf([]) + setInput('') + setHistoryIdx(null) + historyDraftRef.current = '' + + if (!full.trim() || !sid) { + return + } + + const editIdx = queueEditRef.current + + if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { + replaceQ(editIdx, full) + setQueueEdit(null) + + return + } + + if (editIdx !== null) { + setQueueEdit(null) + } + + pushHistory(full) + + if (busy && !full.startsWith('/') && !full.startsWith('!')) { + if (hasInterpolation(full)) { + interpolate(full, enqueue) + + return + } + + enqueue(full) + + return + } + + if (full.startsWith('!')) { + shellExec(full.slice(1).trim()) + + return + } + + if (full.startsWith('/') && slash(full)) { + return + } + + if (hasInterpolation(full)) { + setBusy(true) + interpolate(full, send) + + return + } + + send(full) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [busy, gw, inputBuf, sid, slash, sys] + ) + + const statusColor = + status === 'ready' + ? theme.color.ok + : status.startsWith('error') + ? theme.color.error + : status === 'interrupted' + ? theme.color.warn + : theme.color.dim + + return ( + + + {empty ? ( + <> + + {info && } + {!sid ? ( + ⚕ {status} + ) : ( + + type / for commands + {' · '} + ! for shell + {' · '} + Ctrl+C to interrupt + + )} + + ) : ( + + + {theme.brand.icon}{' '} + + + {theme.brand.name} + + + {info?.model ? ` · ${info.model.split('/').pop()}` : ''} + {' · '} + {status} + {busy && ' · Ctrl+C to stop'} + + {usage.total > 0 && ( + + {' · '} + {fmtK(usage.input)}↑ {fmtK(usage.output)}↓ ({usage.calls} calls) + + )} + + )} + + + {viewport.above > 0 && ( + + ↑ {viewport.above} above · PgUp/PgDn to scroll + + )} + + {messages.slice(viewport.start, viewport.end).map((m, i) => { + const ri = viewport.start + i + + return ( + 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0} + > + + + ) + })} + + {scrollOffset > 0 && ( + + ↓ {scrollOffset} below · PgDn or Enter to return + + )} + + {thinking && } + + + {clarify && ( + { + gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) + setMessages(prev => [...prev, { role: 'user', text: answer }]) + setClarify(null) + setStatus('thinking…') + }} + req={clarify} + t={theme} + /> + )} + + {approval && ( + { + gw.request('approval.respond', { choice, session_id: sid }).catch(() => {}) + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }} + req={approval} + t={theme} + /> + )} + + {!blocked && input.startsWith('/') && } + + + + {'─'.repeat(cols - 2)} + + {!blocked && ( + + + + {inputBuf.length ? '… ' : `${theme.brand.prompt} `} + + + + + )} + + + ) +} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx new file mode 100644 index 000000000..ba46d0147 --- /dev/null +++ b/ui-tui/src/components/branding.tsx @@ -0,0 +1,113 @@ +import { Box, Text, useStdout } from 'ink' + +import { caduceus, logo, LOGO_WIDTH } from '../banner.js' +import { flat } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { SessionInfo } from '../types.js' + +export function ArtLines({ lines }: { lines: [string, string][] }) { + return ( + <> + {lines.map(([c, text], i) => ( + + {text} + + ))} + + ) +} + +export function Banner({ t }: { t: Theme }) { + const cols = useStdout().stdout?.columns ?? 80 + + return ( + + {cols >= LOGO_WIDTH ? ( + + ) : ( + + {t.brand.icon} NOUS HERMES + + )} + + + {t.brand.icon} Nous Research + · Messenger of the Digital Gods + + + ) +} + +export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { + const cols = useStdout().stdout?.columns ?? 100 + const wide = cols >= 90 + const w = wide ? cols - 46 : cols - 10 + const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) + + const truncLine = (pfx: string, items: string[]) => { + let line = '' + + for (const item of items.sort()) { + const next = line ? `${line}, ${item}` : item + + if (pfx.length + next.length > w) { + return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` + } + + line = next + } + + return line + } + + const section = (title: string, data: Record, max = 8) => { + const entries = Object.entries(data).sort() + const shown = entries.slice(0, max) + const overflow = entries.length - max + + return ( + + + Available {title} + + {shown.map(([k, vs]) => ( + + {strip(k)}: + {truncLine(strip(k) + ': ', vs)} + + ))} + {overflow > 0 && (and {overflow} more…)} + + ) + } + + return ( + + {wide && ( + + + + Nous Research + + )} + + + {t.brand.icon} {t.brand.name} + + {section('Tools', info.tools)} + {section('Skills', info.skills)} + + + {flat(info.tools).length} tools{' · '} + {flat(info.skills).length} skills + {' · '} + /help for commands + + + {info.model.split('/').pop()} + {' · '}Ctrl+C to interrupt + + + + ) +} diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx new file mode 100644 index 000000000..be7f755d0 --- /dev/null +++ b/ui-tui/src/components/commandPalette.tsx @@ -0,0 +1,25 @@ +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)) + + if (!matches.length) { + return null + } + + return ( + + {matches.map(([cmd, desc]) => ( + + + {cmd} + + — {desc} + + ))} + + ) +} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx new file mode 100644 index 000000000..c0d71403b --- /dev/null +++ b/ui-tui/src/components/markdown.tsx @@ -0,0 +1,152 @@ +import { Box, Text } from 'ink' +import type { ReactNode } from 'react' + +import type { Theme } from '../theme.js' + +function MdInline({ t, text }: { t: Theme; text: string }) { + const parts: ReactNode[] = [] + const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g + + let last = 0 + let match: RegExpExecArray | null + + while ((match = re.exec(text)) !== null) { + if (match.index > last) { + parts.push( + + {text.slice(last, match.index)} + + ) + } + + if (match[2] && match[3]) { + parts.push( + + {match[2]} + + ) + } else if (match[4]) { + parts.push( + + {match[4]} + + ) + } else if (match[5]) { + parts.push( + + {match[5]} + + ) + } else if (match[6]) { + parts.push( + + {match[6]} + + ) + } else if (match[7]) { + parts.push( + + {match[7]} + + ) + } + + last = match.index + match[0].length + } + + if (last < text.length) { + parts.push( + + {text.slice(last)} + + ) + } + + return {parts.length ? parts : {text}} +} + +export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) { + const lines = text.split('\n') + const nodes: ReactNode[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i]! + const key = nodes.length + + if (compact && !line.trim()) { + i++ + + continue + } + + if (line.startsWith('```')) { + const lang = line.slice(3).trim() + const block: string[] = [] + + for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) { + block.push(lines[i]!) + } + + i++ + nodes.push( + + {lang && {'─ ' + lang}} + {block.map((l, j) => ( + + {l} + + ))} + + ) + + continue + } + + const heading = line.match(/^#{1,3}\s+(.*)/) + + if (heading) { + nodes.push( + + {heading[1]} + + ) + i++ + + continue + } + + const bullet = line.match(/^\s*[-*]\s(.*)/) + + if (bullet) { + nodes.push( + + + + + ) + i++ + + continue + } + + const numbered = line.match(/^\s*(\d+)\.\s(.*)/) + + if (numbered) { + nodes.push( + + {numbered[1]}. + + + ) + i++ + + continue + } + + nodes.push() + i++ + } + + return {nodes} +} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx new file mode 100644 index 000000000..36d86acc7 --- /dev/null +++ b/ui-tui/src/components/messageLine.tsx @@ -0,0 +1,46 @@ +import { Box, Text } from 'ink' + +import { LONG_MSG, ROLE } from '../constants.js' +import { userDisplay } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { Msg } from '../types.js' + +import { Md } from './markdown.js' + +export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; t: Theme }) { + const { body, glyph, prefix } = ROLE[msg.role](t) + + const content = (() => { + if (msg.role === 'assistant') { + return + } + + if (msg.role === 'user' && msg.text.length > LONG_MSG) { + const displayed = userDisplay(msg.text) + const [head, ...rest] = displayed.split('[long message]') + + return ( + + {head} + + [long message] + + {rest.join('')} + + ) + } + + return {msg.text} + })() + + return ( + + + + {glyph}{' '} + + + {content} + + ) +} diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx new file mode 100644 index 000000000..cc9f74388 --- /dev/null +++ b/ui-tui/src/components/prompts.tsx @@ -0,0 +1,127 @@ +import { Box, Text, useInput } from 'ink' +import TextInput from 'ink-text-input' +import { useState } from 'react' + +import type { Theme } from '../theme.js' +import type { ApprovalReq, ClarifyReq } from '../types.js' + +export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) { + const [sel, setSel] = useState(3) + const opts = ['once', 'session', 'always', 'deny'] as const + const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const + + useInput((ch, key) => { + if (key.upArrow && sel > 0) { + setSel(s => s - 1) + } + + if (key.downArrow && sel < 3) { + setSel(s => s + 1) + } + + if (key.return) { + onChoice(opts[sel]!) + } + + if (ch === 'o') { + onChoice('once') + } + + if (ch === 's') { + onChoice('session') + } + + if (ch === 'a') { + onChoice('always') + } + + if (ch === 'd' || key.escape) { + onChoice('deny') + } + }) + + return ( + + + ⚠️ DANGEROUS COMMAND: {req.description} + + {req.command} + + {opts.map((o, i) => ( + + {sel === i ? '▸ ' : ' '} + + [{o[0]}] {labels[o]} + + + ))} + ↑/↓ select · Enter confirm · o/s/a/d quick pick + + ) +} + +export function ClarifyPrompt({ onAnswer, req, t }: { onAnswer: (s: string) => void; req: ClarifyReq; t: Theme }) { + const [sel, setSel] = useState(0) + const [custom, setCustom] = useState('') + const [typing, setTyping] = useState(false) + const choices = req.choices ?? [] + + useInput((ch, key) => { + if (typing) { + return + } + + if (key.upArrow && sel > 0) { + setSel(s => s - 1) + } + + if (key.downArrow && sel < choices.length) { + setSel(s => s + 1) + } + + if (key.return) { + if (sel === choices.length) { + setTyping(true) + } else if (choices[sel]) { + onAnswer(choices[sel]!) + } + } + + const n = parseInt(ch) + + if (n >= 1 && n <= choices.length) { + onAnswer(choices[n - 1]!) + } + }) + + if (typing || !choices.length) { + return ( + + + ❓ {req.question} + + + {'> '} + + + + ) + } + + return ( + + + ❓ {req.question} + + {[...choices, 'Other (type your answer)'].map((c, i) => ( + + {sel === i ? '▸ ' : ' '} + + {i + 1}. {c} + + + ))} + ↑/↓ select · Enter confirm · 1-{choices.length} quick pick + + ) +} diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx new file mode 100644 index 000000000..07119fac3 --- /dev/null +++ b/ui-tui/src/components/queuedMessages.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from 'ink' + +import { compactPreview } from '../lib/text.js' +import type { Theme } from '../theme.js' + +export function QueuedMessages({ + cols, + queueEditIdx, + queued, + t +}: { + cols: number + queueEditIdx: number | null + queued: string[] + t: Theme +}) { + if (!queued.length) { + return null + } + + const qWindow = 3 + const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queued.length - qWindow)) + const qEnd = Math.min(queued.length, qStart + qWindow) + + return ( + + + queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} + + {qStart > 0 && ( + + {' '} + … + + )} + {queued.slice(qStart, qEnd).map((item, i) => { + const idx = qStart + i + const active = queueEditIdx === idx + + return ( + + {active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))} + + ) + })} + {qEnd < queued.length && ( + + {' '}…and {queued.length - qEnd} more + + )} + + ) +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx new file mode 100644 index 000000000..f5d5cd3da --- /dev/null +++ b/ui-tui/src/components/thinking.tsx @@ -0,0 +1,41 @@ +import { Box, Text } from 'ink' +import { useEffect, useState } from 'react' + +import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js' +import { pick } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { ActiveTool } from '../types.js' + +export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; tools: ActiveTool[] }) { + const [frame, setFrame] = useState(0) + const [verb] = useState(() => pick(VERBS)) + const [face] = useState(() => pick(FACES)) + + useEffect(() => { + const id = setInterval(() => setFrame(f => (f + 1) % SPINNER.length), 80) + + return () => clearInterval(id) + }, []) + + return ( + + {tools.length ? ( + tools.map(tool => ( + + {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}… + + )) + ) : ( + + {SPINNER[frame]} {face} {verb}… + + )} + {reasoning && ( + + {' 💭 '} + {reasoning.slice(-120).replace(/\n/g, ' ')} + + )} + + ) +} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts new file mode 100644 index 000000000..a8d2a99c1 --- /dev/null +++ b/ui-tui/src/constants.ts @@ -0,0 +1,115 @@ +import type { Theme } from './theme.js' +import type { Role, Usage } from './types.js' + +export const COMMANDS: [string, string][] = [ + ['/help', 'commands & hotkeys'], + ['/model', 'switch model'], + ['/skin', 'change theme'], + ['/clear', 'reset chat'], + ['/new', 'new session'], + ['/undo', 'drop last exchange'], + ['/retry', 'resend last message'], + ['/compact', 'toggle compact [focus]'], + ['/cost', 'token usage stats'], + ['/copy', 'copy last response'], + ['/context', 'context window info'], + ['/compress', 'compress context'], + ['/skills', 'list skills'], + ['/config', 'show config'], + ['/status', 'session info'], + ['/quit', 'exit hermes'] +] + +export const FACES = [ + '(。•́︿•̀。)', + '(◔_◔)', + '(¬‿¬)', + '( •_•)>⌐■-■', + '(⌐■_■)', + '(´・_・`)', + '◉_◉', + '(°ロ°)', + '( ˘⌣˘)♡', + 'ヽ(>∀<☆)☆', + '٩(๑❛ᴗ❛๑)۶', + '(⊙_⊙)', + '(¬_¬)', + '( ͡° ͜ʖ ͡°)', + 'ಠ_ಠ' +] + +export const HOTKEYS: [string, string][] = [ + ['Ctrl+C', 'interrupt / clear / exit'], + ['Ctrl+D', 'exit'], + ['Ctrl+L', 'clear screen'], + ['↑/↓', 'queue edit (if queued) / input history'], + ['PgUp/PgDn', 'scroll messages'], + ['Esc', 'clear input'], + ['\\+Enter', 'multi-line continuation'], + ['!cmd', 'run shell command'], + ['{!cmd}', 'interpolate shell output inline'] +] + +export const INTERPOLATION_RE = /\{!(.+?)\}/g + +export const LONG_MSG = 300 +export const MAX_CTX = 128_000 + +export const PLACEHOLDERS = [ + 'Ask me anything…', + 'Try "explain this codebase"', + 'Try "write a test for…"', + 'Try "refactor the auth module"', + 'Try "/help" for commands', + 'Try "fix the lint errors"', + 'Try "how does the config loader work?"' +] + +export const ROLE: Record { body: string; glyph: string; prefix: string }> = { + assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }), + system: t => ({ body: t.color.error, glyph: '!', prefix: t.color.error }), + tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), + user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) +} + +export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +export const TOOL_VERBS: Record = { + browser: '🌐 browsing', + clarify: '❓ asking', + create_file: '📝 creating', + delegate_task: '🤖 delegating', + delete_file: '🗑️ deleting', + execute_code: '⚡ executing', + image_generate: '🎨 generating', + list_files: '📂 listing', + memory: '🧠 remembering', + patch: '🩹 patching', + read_file: '📖 reading', + run_command: '⚙️ running', + search_code: '🔍 searching', + search_files: '🔍 searching', + terminal: '💻 terminal', + web_search: '🌐 searching', + write_file: '✏️ writing' +} + +export const VERBS = [ + 'pondering', + 'contemplating', + 'musing', + 'cogitating', + 'ruminating', + 'deliberating', + 'mulling', + 'reflecting', + 'processing', + 'reasoning', + 'analyzing', + 'computing', + 'synthesizing', + 'formulating', + 'brainstorming' +] + +export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx new file mode 100644 index 000000000..ecb9e4a82 --- /dev/null +++ b/ui-tui/src/entry.tsx @@ -0,0 +1,15 @@ +'use strict' + +import { render } from 'ink' + +import { App } from './app.js' +import { GatewayClient } from './gatewayClient.js' + +if (!process.stdin.isTTY) { + console.log('hermes-tui: no TTY') + process.exit(0) +} + +const gw = new GatewayClient() +gw.start() +render(, { exitOnCtrlC: false }) diff --git a/ui-tui/src/lib/messages.ts b/ui-tui/src/lib/messages.ts new file mode 100644 index 000000000..fc265abd4 --- /dev/null +++ b/ui-tui/src/lib/messages.ts @@ -0,0 +1,5 @@ +import type { Msg, Role } from '../types.js' + +export function upsert(prev: Msg[], role: Role, text: string): Msg[] { + return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] +} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts new file mode 100644 index 000000000..68aa468c1 --- /dev/null +++ b/ui-tui/src/lib/text.ts @@ -0,0 +1,34 @@ +import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' + +export const compactPreview = (s: string, max: number) => { + const one = s.replace(/\s+/g, ' ').trim() + + return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one +} + +export const estimateRows = (text: string, w: number) => + text.split('\n').reduce((s, l) => s + Math.max(1, Math.ceil(Math.max(1, l.length) / w)), 0) + +export const flat = (r: Record) => Object.values(r).flat() + +export const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`) + +export const hasInterpolation = (s: string) => { + INTERPOLATION_RE.lastIndex = 0 + + return INTERPOLATION_RE.test(s) +} + +export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! + +export const userDisplay = (text: string): string => { + if (text.length <= LONG_MSG) { + return text + } + + const first = text.split('\n')[0]?.trim() ?? '' + const words = first.split(/\s+/).filter(Boolean) + const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) + + return `${prefix || '(message)'} [long message]` +} diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx index c035b72ad..ecb9e4a82 100644 --- a/ui-tui/src/main.tsx +++ b/ui-tui/src/main.tsx @@ -1,1657 +1,9 @@ 'use strict' -import { Box, render, Text, useApp, useInput, useStdout } from 'ink' -import TextInput from 'ink-text-input' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { render } from 'ink' -import { AltScreen } from './altScreen.js' -import { caduceus, logo, LOGO_WIDTH } from './banner.js' -import { GatewayClient, type GatewayEvent } from './gatewayClient.js' -import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' - -// ── Types ─────────────────────────────────────────────────────────── - -type Role = 'user' | 'assistant' | 'system' | 'tool' - -interface Msg { - role: Role - text: string -} -interface SessionInfo { - model: string - tools: Record - skills: Record -} -interface ActiveTool { - id: string - name: string -} -interface ClarifyReq { - requestId: string - question: string - choices: string[] | null -} -interface ApprovalReq { - command: string - description: string -} -interface Usage { - input: number - output: number - total: number - calls: number -} - -// ── Constants ─────────────────────────────────────────────────────── - -const ZERO: Usage = { input: 0, output: 0, total: 0, calls: 0 } -const MAX_CTX = 128_000 -const LONG_MSG = 300 - -const COMMANDS: [string, string][] = [ - ['/help', 'commands & hotkeys'], - ['/model', 'switch model'], - ['/skin', 'change theme'], - ['/clear', 'reset chat'], - ['/new', 'new session'], - ['/undo', 'drop last exchange'], - ['/retry', 'resend last message'], - ['/compact', 'toggle compact [focus]'], - ['/cost', 'token usage stats'], - ['/copy', 'copy last response'], - ['/context', 'context window info'], - ['/compress', 'compress context'], - ['/skills', 'list skills'], - ['/config', 'show config'], - ['/status', 'session info'], - ['/quit', 'exit hermes'] -] - -const HOTKEYS: [string, string][] = [ - ['Ctrl+C', 'interrupt / clear / exit'], - ['Ctrl+D', 'exit'], - ['Ctrl+L', 'clear screen'], - ['↑/↓', 'queue edit (if queued) / input history'], - ['PgUp/PgDn', 'scroll messages'], - ['Esc', 'clear input'], - ['\\+Enter', 'multi-line continuation'], - ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'] -] - -const PLACEHOLDERS = [ - 'Ask me anything…', - 'Try "explain this codebase"', - 'Try "write a test for…"', - 'Try "refactor the auth module"', - 'Try "/help" for commands', - 'Try "fix the lint errors"', - 'Try "how does the config loader work?"' -] - -const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - -const FACES = [ - '(。•́︿•̀。)', - '(◔_◔)', - '(¬‿¬)', - '( •_•)>⌐■-■', - '(⌐■_■)', - '(´・_・`)', - '◉_◉', - '(°ロ°)', - '( ˘⌣˘)♡', - 'ヽ(>∀<☆)☆', - '٩(๑❛ᴗ❛๑)۶', - '(⊙_⊙)', - '(¬_¬)', - '( ͡° ͜ʖ ͡°)', - 'ಠ_ಠ' -] - -const VERBS = [ - 'pondering', - 'contemplating', - 'musing', - 'cogitating', - 'ruminating', - 'deliberating', - 'mulling', - 'reflecting', - 'processing', - 'reasoning', - 'analyzing', - 'computing', - 'synthesizing', - 'formulating', - 'brainstorming' -] - -const TOOL_VERBS: Record = { - read_file: '📖 reading', - write_file: '✏️ writing', - search_code: '🔍 searching', - run_command: '⚙️ running', - execute_code: '⚡ executing', - list_files: '📂 listing', - web_search: '🌐 searching', - create_file: '📝 creating', - delete_file: '🗑️ deleting', - memory: '🧠 remembering', - clarify: '❓ asking', - delegate_task: '🤖 delegating', - browser: '🌐 browsing', - terminal: '💻 terminal', - patch: '🩹 patching', - search_files: '🔍 searching', - image_generate: '🎨 generating' -} - -const ROLE: Record { glyph: string; prefix: string; body: string }> = { - user: t => ({ glyph: t.brand.prompt, prefix: t.color.label, body: t.color.label }), - assistant: t => ({ glyph: t.brand.tool, prefix: t.color.bronze, body: t.color.cornsilk }), - system: t => ({ glyph: '!', prefix: t.color.error, body: t.color.error }), - tool: t => ({ glyph: '⚡', prefix: t.color.dim, body: t.color.dim }) -} - -// ── Pure helpers ──────────────────────────────────────────────────── - -const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! -const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`) -const flat = (r: Record) => Object.values(r).flat() - -const estimateRows = (text: string, w: number) => - text.split('\n').reduce((s, l) => s + Math.max(1, Math.ceil(Math.max(1, l.length) / w)), 0) - -const compactPreview = (s: string, max: number) => { - const one = s.replace(/\s+/g, ' ').trim() - - return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one -} - -const userDisplay = (text: string): string => { - if (text.length <= LONG_MSG) { - return text - } - - const first = text.split('\n')[0]?.trim() ?? '' - const words = first.split(/\s+/).filter(Boolean) - const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) - - return `${prefix || '(message)'} [long message]` -} - -const INTERPOLATION_RE = /\{!(.+?)\}/g -const hasInterpolation = (s: string) => INTERPOLATION_RE.test(s) - -const PLACEHOLDER = pick(PLACEHOLDERS) - -// ── Components ────────────────────────────────────────────────────── - -function ArtLines({ lines }: { lines: [string, string][] }) { - return ( - <> - {lines.map(([c, text], i) => ( - - {text} - - ))} - - ) -} - -function Banner({ t }: { t: Theme }) { - const cols = useStdout().stdout?.columns ?? 80 - - return ( - - {cols >= LOGO_WIDTH ? ( - - ) : ( - - {t.brand.icon} NOUS HERMES - - )} - - - {t.brand.icon} Nous Research - · Messenger of the Digital Gods - - - ) -} - -function SessionPanel({ t, info }: { t: Theme; info: SessionInfo }) { - const cols = useStdout().stdout?.columns ?? 100 - const wide = cols >= 90 - const w = wide ? cols - 46 : cols - 10 - const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) - - const truncLine = (pfx: string, items: string[]) => { - let line = '' - - for (const item of items.sort()) { - const next = line ? `${line}, ${item}` : item - - if (pfx.length + next.length > w) { - return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` - } - - line = next - } - - return line - } - - const section = (title: string, data: Record, max = 8) => { - const entries = Object.entries(data).sort() - const shown = entries.slice(0, max) - const overflow = entries.length - max - - return ( - - - Available {title} - - {shown.map(([k, vs]) => ( - - {strip(k)}: - {truncLine(strip(k) + ': ', vs)} - - ))} - {overflow > 0 && (and {overflow} more…)} - - ) - } - - return ( - - {wide && ( - - - - Nous Research - - )} - - - {t.brand.icon} {t.brand.name} - - {section('Tools', info.tools)} - {section('Skills', info.skills)} - - - {flat(info.tools).length} tools{' · '} - {flat(info.skills).length} skills - {' · '} - /help for commands - - - {info.model.split('/').pop()} - {' · '}Ctrl+C to interrupt - - - - ) -} - -function CommandPalette({ t, filter }: { t: Theme; filter: string }) { - const m = COMMANDS.filter(([cmd]) => cmd.startsWith(filter)) - - if (!m.length) { - return null - } - - return ( - - {m.map(([cmd, desc]) => ( - - - {cmd} - - — {desc} - - ))} - - ) -} - -function Thinking({ t, tools, reasoning }: { t: Theme; tools: ActiveTool[]; reasoning: string }) { - const [frame, setFrame] = useState(0) - const [verb] = useState(() => pick(VERBS)) - const [face] = useState(() => pick(FACES)) - - useEffect(() => { - const id = setInterval(() => setFrame(f => (f + 1) % SPINNER.length), 80) - - return () => clearInterval(id) - }, []) - - return ( - - {tools.length ? ( - tools.map(tool => ( - - {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}… - - )) - ) : ( - - {SPINNER[frame]} {face} {verb}… - - )} - {reasoning && ( - - {' 💭 '} - {reasoning.slice(-120).replace(/\n/g, ' ')} - - )} - - ) -} - -// ── Interactive prompts ───────────────────────────────────────────── - -function ClarifyPrompt({ t, req, onAnswer }: { t: Theme; req: ClarifyReq; onAnswer: (s: string) => void }) { - const [sel, setSel] = useState(0) - const [custom, setCustom] = useState('') - const [typing, setTyping] = useState(false) - const choices = req.choices ?? [] - - useInput((ch, key) => { - if (typing) { - return - } - - if (key.upArrow && sel > 0) { - setSel(s => s - 1) - } - - if (key.downArrow && sel < choices.length) { - setSel(s => s + 1) - } - - if (key.return) { - if (sel === choices.length) { - setTyping(true) - } else if (choices[sel]) { - onAnswer(choices[sel]!) - } - } - - const n = parseInt(ch) - - if (n >= 1 && n <= choices.length) { - onAnswer(choices[n - 1]!) - } - }) - - if (typing || !choices.length) { - return ( - - - ❓ {req.question} - - - {'> '} - - - - ) - } - - return ( - - - ❓ {req.question} - - {[...choices, 'Other (type your answer)'].map((c, i) => ( - - {sel === i ? '▸ ' : ' '} - - {i + 1}. {c} - - - ))} - ↑/↓ select · Enter confirm · 1-{choices.length} quick pick - - ) -} - -function ApprovalPrompt({ t, req, onChoice }: { t: Theme; req: ApprovalReq; onChoice: (s: string) => void }) { - const [sel, setSel] = useState(3) - const opts = ['once', 'session', 'always', 'deny'] as const - const labels = { once: 'Allow once', session: 'Allow this session', always: 'Always allow', deny: 'Deny' } as const - - useInput((ch, key) => { - if (key.upArrow && sel > 0) { - setSel(s => s - 1) - } - - if (key.downArrow && sel < 3) { - setSel(s => s + 1) - } - - if (key.return) { - onChoice(opts[sel]!) - } - - if (ch === 'o') { - onChoice('once') - } - - if (ch === 's') { - onChoice('session') - } - - if (ch === 'a') { - onChoice('always') - } - - if (ch === 'd' || key.escape) { - onChoice('deny') - } - }) - - return ( - - - ⚠️ DANGEROUS COMMAND: {req.description} - - {req.command} - - {opts.map((o, i) => ( - - {sel === i ? '▸ ' : ' '} - - [{o[0]}] {labels[o]} - - - ))} - ↑/↓ select · Enter confirm · o/s/a/d quick pick - - ) -} - -// ── Markdown ──────────────────────────────────────────────────────── - -function Md({ t, text, compact }: { t: Theme; text: string; compact?: boolean }) { - const lines = text.split('\n') - const nodes: React.ReactNode[] = [] - let i = 0 - - while (i < lines.length) { - const line = lines[i]! - const k = nodes.length - - if (compact && !line.trim()) { - i++ - - continue - } - - if (line.startsWith('```')) { - const lang = line.slice(3).trim() - const block: string[] = [] - - for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) { - block.push(lines[i]!) - } - - i++ - nodes.push( - - {lang && {'─ ' + lang}} - {block.map((l, j) => ( - - {l} - - ))} - - ) - - continue - } - - const hm = line.match(/^#{1,3}\s+(.*)/) - - if (hm) { - nodes.push( - - {hm[1]} - - ) - i++ - - continue - } - - const bm = line.match(/^\s*[-*]\s(.*)/) - - if (bm) { - nodes.push( - - - - - ) - i++ - - continue - } - - const nm = line.match(/^\s*(\d+)\.\s(.*)/) - - if (nm) { - nodes.push( - - {nm[1]}. - - - ) - i++ - - continue - } - - nodes.push() - i++ - } - - return {nodes} -} - -function MdInline({ t, text }: { t: Theme; text: string }) { - const parts: React.ReactNode[] = [] - const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g - - let last = 0, - m: RegExpExecArray | null - - while ((m = re.exec(text)) !== null) { - if (m.index > last) { - parts.push( - - {text.slice(last, m.index)} - - ) - } - - if (m[2] && m[3]) { - parts.push( - - {m[2]} - - ) - } else if (m[4]) { - parts.push( - - {m[4]} - - ) - } else if (m[5]) { - parts.push( - - {m[5]} - - ) - } else if (m[6]) { - parts.push( - - {m[6]} - - ) - } else if (m[7]) { - parts.push( - - {m[7]} - - ) - } - - last = m.index + m[0].length - } - - if (last < text.length) { - parts.push( - - {text.slice(last)} - - ) - } - - return {parts.length ? parts : {text}} -} - -// ── Message ───────────────────────────────────────────────────────── - -function MessageLine({ t, msg, compact }: { t: Theme; msg: Msg; compact?: boolean }) { - const { glyph, prefix, body } = ROLE[msg.role](t) - - const content = (() => { - if (msg.role === 'assistant') { - return - } - - if (msg.role === 'user' && msg.text.length > LONG_MSG) { - const d = userDisplay(msg.text) - const [head, ...rest] = d.split('[long message]') - - return ( - - {head} - - [long message] - - {rest.join('')} - - ) - } - - return {msg.text} - })() - - return ( - - - - {glyph}{' '} - - - {content} - - ) -} - -// ── App ───────────────────────────────────────────────────────────── - -function App({ gw }: { gw: GatewayClient }) { - const { exit } = useApp() - const { stdout } = useStdout() - const cols = stdout?.columns ?? 80 - const rows = stdout?.rows ?? 24 - - // ── State ───────────────────────────────────────────────────────── - - const [input, setInput] = useState('') - const [inputBuf, setInputBuf] = useState([]) - const [messages, setMessages] = useState([]) - const [status, setStatus] = useState('summoning hermes…') - const [sid, setSid] = useState(null) - const [theme, setTheme] = useState(DEFAULT_THEME) - const [info, setInfo] = useState(null) - const [thinking, setThinking] = useState(false) - const [tools, setTools] = useState([]) - const [busy, setBusy] = useState(false) - const [compact, setCompact] = useState(false) - const [usage, setUsage] = useState(ZERO) - const [clarify, setClarify] = useState(null) - const [approval, setApproval] = useState(null) - const [reasoning, setReasoning] = useState('') - const [lastUserMsg, setLastUserMsg] = useState('') - const [queueEditIdx, setQueueEditIdx] = useState(null) - const [historyIdx, setHistoryIdx] = useState(null) - const [scrollOffset, setScrollOffset] = useState(0) - const [queuedDisplay, setQueuedDisplay] = useState([]) - - const buf = useRef('') - const stickyRef = useRef(true) - const queueRef = useRef([]) - const historyRef = useRef([]) - const historyDraftRef = useRef('') - const queueEditRef = useRef(null) - const lastEmptyAt = useRef(0) - - const empty = !messages.length - const blocked = !!(clarify || approval) - - // ── Queue / history helpers ─────────────────────────────────────── - - const syncQueue = () => setQueuedDisplay([...queueRef.current]) - - const setQueueEdit = (idx: number | null) => { - queueEditRef.current = idx - setQueueEditIdx(idx) - } - - const enqueue = (text: string) => { - queueRef.current.push(text) - syncQueue() - } - - const dequeue = () => { - const [h, ...rest] = queueRef.current - queueRef.current = rest - syncQueue() - - return h - } - - const replaceQ = (i: number, text: string) => { - queueRef.current[i] = text - syncQueue() - } - - const pushHistory = (text: string) => { - const t = text.trim() - - if (t && historyRef.current.at(-1) !== t) { - historyRef.current.push(t) - } - } - - // ── Derived ─────────────────────────────────────────────────────── - - useEffect(() => { - if (stickyRef.current) { - setScrollOffset(0) - } - }, [messages.length]) - - const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2) - - const viewport = useMemo(() => { - if (!messages.length) { - return { start: 0, end: 0, above: 0 } - } - - const end = Math.max(0, messages.length - scrollOffset) - const w = Math.max(20, cols - 5) - - let budget = msgBudget, - start = end - - for (let i = end - 1; i >= 0 && budget > 0; i--) { - const m = messages[i]! - const margin = m.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 - budget -= margin + estimateRows(m.role === 'user' ? userDisplay(m.text) : m.text, w) - - if (budget >= 0) { - start = i - } - } - - if (start === end && end > 0) { - start = end - 1 - } - - return { start, end, above: start } - }, [messages, scrollOffset, msgBudget, cols]) - - // ── Actions ─────────────────────────────────────────────────────── - - const sys = useCallback((text: string) => setMessages(p => [...p, { role: 'system' as const, text }]), []) - - const idle = () => { - setThinking(false) - setTools([]) - setBusy(false) - setClarify(null) - setApproval(null) - setReasoning('') - } - - const die = () => { - gw.kill() - exit() - } - - const clearIn = () => { - setInput('') - setInputBuf([]) - setQueueEdit(null) - setHistoryIdx(null) - historyDraftRef.current = '' - } - - const scrollBot = () => { - setScrollOffset(0) - stickyRef.current = true - } - - const scrollUp = (n: number) => { - setScrollOffset(p => Math.min(Math.max(0, messages.length - 1), p + n)) - stickyRef.current = false - } - - const scrollDown = (n: number) => { - setScrollOffset(p => { - const v = Math.max(0, p - n) - - if (!v) { - stickyRef.current = true - } - - return v - }) - } - - const send = (text: string) => { - setLastUserMsg(text) - setMessages(p => [...p, { role: 'user', text }]) - scrollBot() - setStatus('thinking…') - setBusy(true) - buf.current = '' - gw.request('prompt.submit', { session_id: sid, text }).catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - setBusy(false) - }) - } - - const shellExec = (cmd: string) => { - setMessages(p => [...p, { role: 'user', text: `!${cmd}` }]) - setBusy(true) - setStatus('running…') - 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) { - sys(`exit ${r.code}`) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - .finally(() => { - setStatus('ready') - setBusy(false) - }) - } - - const interpolate = (text: string, then: (result: string) => void) => { - setStatus('interpolating…') - const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] - Promise.all( - matches.map(m => - gw - .request('shell.exec', { command: m[1]! }) - .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim()) - .catch(() => '(error)') - ) - ).then(results => { - let out = text - - for (let i = matches.length - 1; i >= 0; i--) { - out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) - } - - then(out) - }) - } - - // ── Hotkeys ─────────────────────────────────────────────────────── - - useInput((ch, key) => { - if (blocked) { - if (key.ctrl && ch === 'c' && approval) { - gw.request('approval.respond', { session_id: sid, choice: 'deny' }).catch(() => {}) - setApproval(null) - sys('denied') - } - - return - } - - if (key.pageUp) { - scrollUp(5) - - return - } - - if (key.pageDown) { - scrollDown(5) - - return - } - - if (key.upArrow && !inputBuf.length) { - if (queueRef.current.length) { - const len = queueRef.current.length - const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % len - setQueueEdit(idx) - setHistoryIdx(null) - setInput(queueRef.current[idx] ?? '') - } else if (historyRef.current.length) { - const h = historyRef.current - const idx = historyIdx === null ? h.length - 1 : Math.max(0, historyIdx - 1) - - if (historyIdx === null) { - historyDraftRef.current = input - } - - setHistoryIdx(idx) - setQueueEdit(null) - setInput(h[idx] ?? '') - } - - return - } - - if (key.downArrow && !inputBuf.length) { - if (queueRef.current.length) { - const len = queueRef.current.length - const idx = queueEditIdx === null ? len - 1 : (queueEditIdx - 1 + len) % len - setQueueEdit(idx) - setHistoryIdx(null) - setInput(queueRef.current[idx] ?? '') - } else if (historyIdx !== null) { - const h = historyRef.current - const next = historyIdx + 1 - - if (next >= h.length) { - setHistoryIdx(null) - setInput(historyDraftRef.current) - } else { - setHistoryIdx(next) - setInput(h[next] ?? '') - } - } - - return - } - - if (key.ctrl && ch === 'c') { - if (busy && sid) { - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - idle() - setStatus('interrupted') - sys('interrupted by user') - setTimeout(() => setStatus('ready'), 1500) - } else if (input || inputBuf.length) { - clearIn() - } else { - die() - } - - return - } - - if (key.ctrl && ch === 'd') { - die() - } - - if (key.ctrl && ch === 'l') { - setMessages([]) - } - - if (key.escape) { - clearIn() - } - }) - - // ── Gateway events ──────────────────────────────────────────────── - - const onEvent = useCallback( - (ev: GatewayEvent) => { - const p = ev.payload as any - - switch (ev.type) { - case 'gateway.ready': - if (p?.skin) { - setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) - } - - setStatus('forging session…') - gw.request('session.create') - .then((r: any) => { - setSid(r.session_id) - setStatus('ready') - }) - .catch((e: Error) => setStatus(`error: ${e.message}`)) - - break - - case 'session.info': - setInfo(p as SessionInfo) - - break - - case 'thinking.delta': - break - - case 'message.start': - setThinking(true) - setBusy(true) - setReasoning('') - setStatus('thinking…') - - break - - case 'status.update': - if (p?.text) { - setStatus(p.text) - } - - break - - case 'reasoning.delta': - if (p?.text) { - setReasoning(prev => prev + p.text) - } - - break - - case 'tool.generating': - if (p?.name) { - setStatus(`preparing ${p.name}…`) - } - - break - - case 'tool.progress': - if (p?.preview) { - setMessages(prev => - prev.at(-1)?.role === 'tool' - ? [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] - : [...prev, { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] - ) - } - - break - - case 'tool.start': - setTools(prev => [...prev, { id: p.tool_id, name: p.name }]) - setStatus(`running ${p.name}…`) - setMessages(prev => [...prev, { role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}…` }]) - - break - - case 'tool.complete': - setTools(prev => prev.filter(t => t.id !== p.tool_id)) - - break - - case 'clarify.request': - setClarify({ requestId: p.request_id, question: p.question, choices: p.choices }) - setStatus('waiting for input…') - - break - - case 'approval.request': - setApproval({ command: p.command, description: p.description }) - setStatus('approval needed') - - break - - case 'message.delta': - if (!p?.text) { - break - } - - buf.current += p.text - setThinking(false) - setTools([]) - setReasoning('') - setMessages(prev => upsert(prev, 'assistant', buf.current.trimStart())) - - break - case 'message.complete': { - idle() - setMessages(prev => upsert(prev, 'assistant', (p?.text ?? buf.current).trimStart())) - buf.current = '' - setStatus('ready') - - if (p?.usage) { - setUsage(p.usage) - } - - if (p?.status === 'interrupted') { - sys('response interrupted') - } - - if (queueEditRef.current !== null) { - break - } - - const next = dequeue() - - if (next) { - setLastUserMsg(next) - setMessages(prev => [...prev, { role: 'user' as const, text: next }]) - setStatus('thinking…') - setBusy(true) - buf.current = '' - gw.request('prompt.submit', { session_id: ev.session_id, text: next }).catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - setBusy(false) - }) - } - - break - } - - case 'error': - sys(`error: ${p?.message}`) - idle() - setStatus('ready') - - break - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sys] - ) - - useEffect(() => { - gw.on('event', onEvent) - gw.on('exit', () => { - setStatus('gateway exited') - exit() - }) - - return () => { - gw.off('event', onEvent) - } - }, [gw, exit, onEvent]) - - // ── Slash commands ──────────────────────────────────────────────── - - const slash = useCallback( - (cmd: string): boolean => { - const [name, ...rest] = cmd.slice(1).split(/\s+/) - const arg = rest.join(' ') - - switch (name) { - case 'help': - sys( - [ - ' Commands:', - ...COMMANDS.map(([c, d]) => ` ${c.padEnd(12)} ${d}`), - '', - ' Hotkeys:', - ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(12)} ${d}`) - ].join('\n') - ) - - return true - - case 'clear': - setMessages([]) - - return true - - case 'quit': // falls through - - case 'exit': - die() - - return true - - case 'new': - setStatus('forging session…') - gw.request('session.create') - .then((r: any) => { - setSid(r.session_id) - setMessages([]) - setUsage(ZERO) - setStatus('ready') - sys('new session started') - }) - .catch((e: Error) => setStatus(`error: ${e.message}`)) - - return true - - case 'undo': - if (!sid) { - return true - } - - gw.request('session.undo', { session_id: sid }) - .then((r: any) => { - if (r.removed > 0) { - setMessages(p => { - const q = [...p] - - 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') - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - - return true - - case 'retry': - if (!lastUserMsg) { - sys('nothing to retry') - - return true - } - - setMessages(p => { - const q = [...p] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - return q - }) - send(lastUserMsg) - - return true - - case 'compact': - setCompact(c => (arg ? true : !c)) - sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) - - return true - - case 'compress': - if (!sid) { - return true - } - - gw.request('session.compress', { session_id: sid }) - .then((r: any) => { - sys('context compressed') - - if (r.usage) { - setUsage(r.usage) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - - return true - - case 'cost': // falls through - - case 'usage': - sys( - `in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}` - ) - - 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 - } - - process.stdout.write(`\x1b]52;c;${Buffer.from(target.text).toString('base64')}\x07`) - sys('copied to clipboard') - - return true - } - - case 'context': { - const pct = Math.min(100, Math.round((usage.total / MAX_CTX) * 100)) - const bar = Math.round((pct / 100) * 30) - sys( - `context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(bar)}${'░'.repeat(30 - bar)}] ${pct < 50 ? '✓' : pct < 80 ? '⚠' : '✗'}` - ) - - return true - } - - case 'config': - sys( - `model: ${info?.model ?? '?'} session: ${sid ?? 'none'} compact: ${compact}\ntools: ${flat(info?.tools ?? {}).length} skills: ${flat(info?.skills ?? {}).length}` - ) - - return true - - case 'status': - sys( - `session: ${sid ?? 'none'} status: ${status} tokens: ${fmtK(usage.input)}↑ ${fmtK(usage.output)}↓ (${usage.calls} calls)` - ) - - return true - - case 'skills': - 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') - ) - - return true - - case 'model': - if (!arg) { - sys('usage: /model ') - - return true - } - - gw.request('config.set', { key: 'model', value: arg }) - .then(() => sys(`model → ${arg}`)) - .catch((e: Error) => sys(`error: ${e.message}`)) - - return true - - case 'skin': - if (!arg) { - sys('usage: /skin ') - - return true - } - - gw.request('config.set', { key: 'skin', value: arg }) - .then(() => sys(`skin → ${arg} (restart to apply)`)) - .catch((e: Error) => sys(`error: ${e.message}`)) - - return true - - default: - return false - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sid, status, sys, compact, info, usage, messages, lastUserMsg] - ) - - // ── Submit ──────────────────────────────────────────────────────── - - const submit = useCallback( - (value: string) => { - // double-enter flushes queue head - if (!value.trim() && !inputBuf.length) { - const now = Date.now() - const dbl = now - lastEmptyAt.current < 450 - lastEmptyAt.current = now - - if (dbl && queueRef.current.length) { - if (busy && sid) { - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - setStatus('interrupting…') - - return - } - - const next = dequeue() - - if (next && sid) { - setQueueEdit(null) - send(next) - } - } - - return - } - - lastEmptyAt.current = 0 - - // multi-line continuation - if (value.endsWith('\\')) { - setInputBuf(prev => [...prev, value.slice(0, -1)]) - setInput('') - - return - } - - const full = [...inputBuf, value].join('\n') - setInputBuf([]) - setInput('') - setHistoryIdx(null) - historyDraftRef.current = '' - - if (!full.trim() || !sid) { - return - } - - // queue edit mode → replace, don't send - const editIdx = queueEditRef.current - - if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { - replaceQ(editIdx, full) - setQueueEdit(null) - - return - } - - if (editIdx !== null) { - setQueueEdit(null) - } - - pushHistory(full) - - // queue if busy (slash/shell bypass; interpolation resolves then queues) - if (busy && !full.startsWith('/') && !full.startsWith('!')) { - if (hasInterpolation(full)) { - interpolate(full, enqueue) - - return - } - - enqueue(full) - - return - } - - if (full.startsWith('!')) { - shellExec(full.slice(1).trim()) - - return - } - - if (full.startsWith('/') && slash(full)) { - return - } - - if (hasInterpolation(full)) { - setBusy(true) - interpolate(full, send) - - return - } - - send(full) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sid, slash, sys, inputBuf, busy] - ) - - // ── Render ──────────────────────────────────────────────────────── - - const statusColor = - status === 'ready' - ? theme.color.ok - : status.startsWith('error') - ? theme.color.error - : status === 'interrupted' - ? theme.color.warn - : theme.color.dim - - const qW = 3 - const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queuedDisplay.length - qW)) - const qEnd = Math.min(queuedDisplay.length, qStart + qW) - - return ( - - - {/* ── Header ──────────────────────────────────────────────── */} - - {empty ? ( - <> - - {info && } - {!sid ? ( - ⚕ {status} - ) : ( - - type / for commands - {' · '} - ! for shell - {' · '} - Ctrl+C to interrupt - - )} - - ) : ( - - - {theme.brand.icon}{' '} - - - {theme.brand.name} - - - {info?.model ? ` · ${info.model.split('/').pop()}` : ''} - {' · '} - {status} - {busy && ' · Ctrl+C to stop'} - - {usage.total > 0 && ( - - {' · '} - {fmtK(usage.input)}↑ {fmtK(usage.output)}↓ ({usage.calls} calls) - - )} - - )} - - {/* ── Messages ────────────────────────────────────────────── */} - - - {viewport.above > 0 && ( - - ↑ {viewport.above} above · PgUp/PgDn to scroll - - )} - - {messages.slice(viewport.start, viewport.end).map((m, i) => { - const ri = viewport.start + i - - return ( - 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0} - > - - - ) - })} - - {scrollOffset > 0 && ( - - ↓ {scrollOffset} below · PgDn or Enter to return - - )} - - {thinking && } - - - {/* ── Prompts / chrome ─────────────────────────────────────── */} - - {clarify && ( - { - gw.request('clarify.respond', { request_id: clarify.requestId, answer }).catch(() => {}) - setMessages(p => [...p, { role: 'user', text: answer }]) - setClarify(null) - setStatus('thinking…') - }} - req={clarify} - t={theme} - /> - )} - - {approval && ( - { - gw.request('approval.respond', { session_id: sid, choice }).catch(() => {}) - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }} - req={approval} - t={theme} - /> - )} - - {!blocked && input.startsWith('/') && } - - {queuedDisplay.length > 0 && ( - - - queued ({queuedDisplay.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} - - {qStart > 0 && ( - - {' '} - … - - )} - {queuedDisplay.slice(qStart, qEnd).map((q, i) => { - const idx = qStart + i, - active = queueEditIdx === idx - - return ( - - {active ? '▸' : ' '} {idx + 1}. {compactPreview(q, Math.max(16, cols - 10))} - - ) - })} - {qEnd < queuedDisplay.length && ( - - {' '}…and {queuedDisplay.length - qEnd} more - - )} - - )} - - {'─'.repeat(cols - 2)} - - {!blocked && ( - - - - {inputBuf.length ? '… ' : `${theme.brand.prompt} `} - - - - - )} - - - ) -} - -// ── Helpers ────────────────────────────────────────────────────────── - -function upsert(prev: Msg[], role: Role, text: string): Msg[] { - return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] -} - -// ── Boot ──────────────────────────────────────────────────────────── +import { App } from './app.js' +import { GatewayClient } from './gatewayClient.js' if (!process.stdin.isTTY) { console.log('hermes-tui: no TTY') diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts new file mode 100644 index 000000000..4b4084eb4 --- /dev/null +++ b/ui-tui/src/types.ts @@ -0,0 +1,35 @@ +export interface ActiveTool { + id: string + name: string +} + +export interface ApprovalReq { + command: string + description: string +} + +export interface ClarifyReq { + choices: string[] | null + question: string + requestId: string +} + +export interface Msg { + role: Role + text: string +} + +export type Role = 'assistant' | 'system' | 'tool' | 'user' + +export interface SessionInfo { + model: string + skills: Record + tools: Record +} + +export interface Usage { + calls: number + input: number + output: number + total: number +}