import { spawnSync } from 'node:child_process' import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Box, Static, Text, useApp, useInput, useStdout } from 'ink' import { useCallback, useEffect, useRef, useState } from 'react' import { ActivityLane } from './components/activityLane.js' import { Banner, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { TextInput } from './components/textInput.js' import { Thinking } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' import { compactPreview, fmtK, hasInterpolation, pick } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, ActivityItem, ApprovalReq, ClarifyReq, Msg, PasteMode, PendingPaste, SecretReq, SessionInfo, SlashCatalog, SudoReq, Usage } from './types.js' // ── Constants ──────────────────────────────────────────────────────── const PLACEHOLDER = pick(PLACEHOLDERS) const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g const SMALL_PASTE = { chars: 400, lines: 4 } const LARGE_PASTE = { chars: 8000, lines: 80 } const EXCERPT = { chars: 1200, lines: 14 } const SECRET_PATTERNS = [ /AKIA[0-9A-Z]{16}/g, /AIza[0-9A-Za-z-_]{30,}/g, /gh[pousr]_[A-Za-z0-9]{20,}/g, /sk-[A-Za-z0-9]{20,}/g, /sk-ant-[A-Za-z0-9-]{20,}/g, /xox[baprs]-[A-Za-z0-9-]{10,}/g, /\b(?:api[_-]?key|token|secret)\b\s*[:=]\s*["']?[A-Za-z0-9_-]{12,}/gi ] // ── Pure helpers ───────────────────────────────────────────────────── const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) const classifyPaste = (text: string): PendingPaste['kind'] => { if (/error|warn|traceback|exception|stack|debug|\[\d{2}:\d{2}:\d{2}\]/i.test(text)) { return 'log' } if ( /```|function\s+\w+|class\s+\w+|import\s+.+from|const\s+\w+\s*=|def\s+\w+\(|<\w+/.test(text) || text.split('\n').filter(l => /[{}()[\];<>]/.test(l)).length >= 3 ) { return 'code' } return 'text' } const redactSecrets = (text: string) => { let redactions = 0 const cleaned = SECRET_PATTERNS.reduce( (t, pat) => t.replace(pat, val => { redactions++ return val.includes(':') || val.includes('=') ? `${val.split(/[:=]/)[0]}: [REDACTED_SECRET]` : '[REDACTED_SECRET]' }), text ) return { redactions, text: cleaned } } const pasteToken = (id: number) => `[[paste:${id}]]` const stripTokens = (text: string, re: RegExp) => text .replace(re, '') .replace(/\s{2,}/g, ' ') .trim() // ── StatusRule ──────────────────────────────────────────────────────── function StatusRule({ cols, color, dimColor, statusColor, parts }: { cols: number color: string dimColor: string statusColor: string parts: (string | false | undefined | null)[] }) { const label = parts.filter(Boolean).join(' · ') const lead = String(parts[0] ?? '') return ( {'─ '} {parts[0]} {label.slice(lead.length)} {' ' + '─'.repeat(Math.max(0, cols - label.length - 5))} ) } // ── PromptBox ──────────────────────────────────────────────────────── function PromptBox({ children, color }: { children: React.ReactNode; color: string }) { return ( {children} ) } // ── App ────────────────────────────────────────────────────────────── export function App({ gw }: { gw: GatewayClient }) { const { exit } = useApp() const { stdout } = useStdout() const [cols, setCols] = useState(stdout?.columns ?? 80) useEffect(() => { if (!stdout) { return } const sync = () => setCols(stdout.columns ?? 80) stdout.on('resize', sync) return () => { stdout.off('resize', sync) } }, [stdout]) // ── State ──────────────────────────────────────────────────────── const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) const [messages, setMessages] = useState([]) const [historyItems, setHistoryItems] = useState([]) const [status, setStatus] = useState('summoning hermes…') const [sid, setSid] = useState(null) const [theme, setTheme] = useState(DEFAULT_THEME) const [info, setInfo] = useState(null) const [introCollapsed, setIntroCollapsed] = useState(false) const [thinking, setThinking] = useState(false) const [turnKey, setTurnKey] = useState(0) const [activity, setActivity] = useState([]) 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 [sudo, setSudo] = useState(null) const [secret, setSecret] = useState(null) const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') const [thinkingText, setThinkingText] = useState('') const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [pastes, setPastes] = useState([]) const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null) const [streaming, setStreaming] = useState('') const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) // ── Refs ───────────────────────────────────────────────────────── const activityIdRef = useRef(0) const buf = useRef('') const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) const slashRef = useRef<(cmd: string) => boolean>(() => false) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const pasteCounterRef = useRef(0) const colsRef = useRef(cols) colsRef.current = cols // ── Hooks ──────────────────────────────────────────────────────── const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = useQueue() const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) function blocked() { return !!(clarify || approval || pasteReview || picker || secret || sudo) } const empty = !messages.length const isBlocked = blocked() // ── Resize RPC ─────────────────────────────────────────────────── useEffect(() => { if (!sid || !stdout) { return } const onResize = () => rpc('terminal.resize', { session_id: sid, cols: stdout.columns ?? 80 }) stdout.on('resize', onResize) return () => { stdout.off('resize', onResize) } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps // ── Core actions ───────────────────────────────────────────────── const appendMessage = useCallback((msg: Msg) => { setMessages(prev => [...prev, msg]) setHistoryItems(prev => [...prev, msg]) }, []) const appendHistory = useCallback((msg: Msg) => { setHistoryItems(prev => [...prev, msg]) }, []) const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info') => { setActivity(prev => { if (prev.at(-1)?.text === text && prev.at(-1)?.tone === tone) { return prev } activityIdRef.current++ return [...prev, { id: activityIdRef.current, text, tone }].slice(-8) }) }, []) const rpc = useCallback( (method: string, params: Record = {}) => gw.request(method, params).catch((e: Error) => { sys(`error: ${e.message}`) }), [gw, sys] ) const idle = () => { setThinking(false) setTools([]) setActivity([]) setBusy(false) setClarify(null) setApproval(null) setPasteReview(null) setSudo(null) setSecret(null) setReasoning('') setThinkingText('') setStreaming('') buf.current = '' } const die = () => { gw.kill() exit() } const clearIn = () => { setInput('') setInputBuf([]) setPasteReview(null) setQueueEdit(null) setHistoryIdx(null) historyDraftRef.current = '' } const resetSession = () => { setSid(null as any) // will be set by caller setHistoryItems([]) setMessages([]) setPastes([]) setActivity([]) setBgTasks(new Set()) setIntroCollapsed(false) setUsage(ZERO) lastStatusNoteRef.current = '' protocolWarnedRef.current = false } // ── Session management ─────────────────────────────────────────── const newSession = useCallback( (msg?: string) => rpc('session.create', { cols: colsRef.current }).then((r: any) => { if (!r) { return } resetSession() setSid(r.session_id) setStatus('ready') if (r.info) { setInfo(r.info) appendHistory(introMsg(r.info)) } else { setInfo(null) } if (msg) { sys(msg) } }), [appendHistory, rpc, sys] ) // ── Paste pipeline ─────────────────────────────────────────────── const listPasteIds = useCallback((text: string) => { const ids = new Set() for (const m of text.matchAll(PASTE_TOKEN_RE)) { const id = parseInt(m[1] ?? '-1', 10) if (id > 0) { ids.add(id) } } return [...ids] }, []) const resolvePasteTokens = useCallback( (text: string) => { const byId = new Map(pastes.map(p => [p.id, p])) const missingIds = new Set() const usedIds = new Set() let redactions = 0 const resolved = text.replace(PASTE_TOKEN_RE, (_m, rawId: string) => { const id = parseInt(rawId, 10) const paste = byId.get(id) if (!paste) { missingIds.add(id) return `[missing paste:${id}]` } usedIds.add(id) const cleaned = redactSecrets(paste.text) redactions += cleaned.redactions if (paste.mode === 'inline') { return cleaned.text } const lang = paste.kind === 'code' ? 'text' : '' const lines = cleaned.text.split('\n') if (paste.mode === 'excerpt') { let excerpt = lines.slice(0, EXCERPT.lines).join('\n') if (excerpt.length > EXCERPT.chars) { excerpt = excerpt.slice(0, EXCERPT.chars).trimEnd() + '…' } const truncated = lines.length > EXCERPT.lines || cleaned.text.length > excerpt.length const tail = truncated ? `\n…[paste #${id} truncated]` : '' return `[paste #${id} excerpt]\n\`\`\`${lang}\n${excerpt}${tail}\n\`\`\`` } return `[paste #${id} attached · ${paste.lineCount} lines]\n\`\`\`${lang}\n${cleaned.text}\n\`\`\`` }) return { missingIds: [...missingIds], redactions, text: resolved, usedIds: [...usedIds] } }, [pastes] ) const handleTextPaste = useCallback( ({ cursor, text, value }: { cursor: number; text: string; value: string }) => { pasteCounterRef.current++ const id = pasteCounterRef.current const lineCount = text.split('\n').length const mode: PasteMode = lineCount > SMALL_PASTE.lines || text.length > SMALL_PASTE.chars ? 'attach' : 'excerpt' const token = pasteToken(id) const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' const insert = `${lead}${token}${tail}` setPastes(prev => [ ...prev, { charCount: text.length, createdAt: Date.now(), id, kind: classifyPaste(text), lineCount, mode, text } ].slice(-24) ) pushActivity(`captured ${lineCount}L paste as ${token} (${mode})`) return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, [pushActivity] ) // ── Send ───────────────────────────────────────────────────────── const send = (text: string) => { const payload = resolvePasteTokens(text) if (payload.missingIds.length) { pushActivity(`missing paste token(s): ${payload.missingIds.join(', ')}`, 'warn') return } if (payload.redactions > 0) { pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') } inflightPasteIdsRef.current = payload.usedIds setLastUserMsg(text) setIntroCollapsed(true) appendMessage({ role: 'user', text }) setBusy(true) setStatus('running…') buf.current = '' interruptedRef.current = false gw.request('prompt.submit', { session_id: sid, text: payload.text }).catch((e: Error) => { inflightPasteIdsRef.current = [] sys(`error: ${e.message}`) setStatus('ready') setBusy(false) }) } const shellExec = (cmd: string) => { appendMessage({ 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() if (out) { sys(out) } if (r.code !== 0 || !out) { sys(`exit ${r.code}`) } }) .catch((e: Error) => sys(`error: ${e.message}`)) .finally(() => { setStatus('ready') setBusy(false) }) } const paste = () => rpc('clipboard.paste', { session_id: sid }).then((r: any) => pushActivity(r.attached ? `image #${r.count} attached` : r.message || 'no image in clipboard') ) const openEditor = () => { const editor = process.env.EDITOR || process.env.VISUAL || 'vi' const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') writeFileSync(file, [...inputBuf, input].join('\n')) process.stdout.write('\x1b[?1049l') const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' }) process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') if (code === 0) { const text = readFileSync(file, 'utf8').trimEnd() if (text) { setInput('') setInputBuf([]) submit(text) } } try { unlinkSync(file) } catch { /* noop */ } } 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) }) } // ── Dispatch ───────────────────────────────────────────────────── const dispatchSubmission = useCallback( (full: string, allowLarge = false) => { if (!full.trim() || !sid) { return } const clearInput = () => { setInputBuf([]) setInput('') setHistoryIdx(null) historyDraftRef.current = '' } if (full.startsWith('/') && slashRef.current(full)) { clearInput() return } if (full.startsWith('!')) { clearInput() shellExec(full.slice(1).trim()) return } const { missingIds } = resolvePasteTokens(full) if (missingIds.length) { pushActivity(`missing paste token(s): ${missingIds.join(', ')}`, 'warn') return } const largeIds = listPasteIds(full).filter(id => { const p = pastes.find(x => x.id === id) return !!p && (p.charCount >= LARGE_PASTE.chars || p.lineCount >= LARGE_PASTE.lines) }) if (!allowLarge && largeIds.length) { setPasteReview({ largeIds, text: full }) setStatus(`review large paste (${largeIds.length})`) return } clearInput() const editIdx = queueEditRef.current if (editIdx !== null) { replaceQ(editIdx, full) const picked = queueRef.current.splice(editIdx, 1)[0] syncQueue() setQueueEdit(null) if (picked && busy && sid) { queueRef.current.unshift(picked) syncQueue() gw.request('session.interrupt', { session_id: sid }).catch(() => {}) setStatus('interrupting…') return } if (picked && sid) { send(picked) } return } pushHistory(full) if (busy) { if (hasInterpolation(full)) { interpolate(full, enqueue) return } enqueue(full) return } if (hasInterpolation(full)) { setBusy(true) interpolate(full, send) return } send(full) }, // eslint-disable-next-line react-hooks/exhaustive-deps [busy, enqueue, gw, listPasteIds, pastes, resolvePasteTokens, sid] ) // ── Input handling ─────────────────────────────────────────────── useInput((ch, key) => { if (isBlocked) { if (pasteReview) { if (key.return) { const t = pasteReview.text setPasteReview(null) dispatchSubmission(t, true) } else if (key.escape || (key.ctrl && ch === 'c')) { setPasteReview(null) setStatus('ready') } return } if (key.ctrl && ch === 'c') { if (approval) { gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) setApproval(null) sys('denied') } else if (sudo) { gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) setSudo(null) sys('sudo cancelled') } else if (secret) { gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) setSecret(null) sys('secret entry cancelled') } else if (picker) { setPicker(false) } } else if (key.escape && picker) { setPicker(false) } return } if (completions.length && input && (key.upArrow || key.downArrow)) { setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)) return } if (!inputBuf.length && key.tab && completions.length) { const row = completions[compIdx] if (row) { setInput(input.slice(0, compReplace) + row.text) } return } if (key.upArrow && !inputBuf.length) { if (queueRef.current.length) { const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % queueRef.current.length setQueueEdit(idx) setHistoryIdx(null) setInput(queueRef.current[idx] ?? '') } else if (historyRef.current.length) { const idx = historyIdx === null ? historyRef.current.length - 1 : Math.max(0, historyIdx - 1) if (historyIdx === null) { historyDraftRef.current = input } setHistoryIdx(idx) setQueueEdit(null) setInput(historyRef.current[idx] ?? '') } return } if (key.downArrow && !inputBuf.length) { if (queueRef.current.length) { const idx = queueEditIdx === null ? queueRef.current.length - 1 : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length setQueueEdit(idx) setHistoryIdx(null) setInput(queueRef.current[idx] ?? '') } else if (historyIdx !== null) { const next = historyIdx + 1 if (next >= historyRef.current.length) { setHistoryIdx(null) setInput(historyDraftRef.current) } else { setHistoryIdx(next) setInput(historyRef.current[next] ?? '') } } return } if (key.ctrl && ch === 'c') { if (busy && sid) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) const partial = (streaming || buf.current).trimStart() if (partial) { appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) } else { sys('interrupted') } idle() setStatus('interrupted') setTimeout(() => setStatus('ready'), 1500) } else if (input || inputBuf.length) { clearIn() } else { die() } return } if (key.ctrl && ch === 'd') { die() } if (key.ctrl && ch === 'l') { setStatus('forging session…') newSession() return } if (key.ctrl && ch === 'v') { paste() return } if (key.ctrl && ch === 'g') { return openEditor() } 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 ?? {}, p.skin.banner_logo ?? '', p.skin.banner_hero ?? '') ) } rpc('commands.catalog', {}) .then((r: any) => { if (!r?.pairs) { return } setCatalog({ canon: (r.canon ?? {}) as Record, pairs: r.pairs as [string, string][], sub: (r.sub ?? {}) as Record }) }) .catch(() => {}) setStatus('forging session…') newSession() break case 'session.info': setInfo(p as SessionInfo) break case 'thinking.delta': if (p?.text) { setThinkingText(prev => prev + p.text) } break case 'message.start': setThinking(true) setTurnKey(k => k + 1) setBusy(true) setReasoning('') setThinkingText('') break case 'status.update': if (p?.text) { setStatus(p.text) if (p.kind && p.kind !== 'status' && lastStatusNoteRef.current !== p.text) { lastStatusNoteRef.current = p.text pushActivity( p.text, p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' ) } } break case 'gateway.protocol_error': setStatus('protocol warning') if (!protocolWarnedRef.current) { protocolWarnedRef.current = true pushActivity('protocol noise detected · /logs to inspect', 'warn') } break case 'reasoning.delta': if (p?.text) { setReasoning(prev => prev + p.text) } break case 'tool.progress': if (p?.preview) { setTools(prev => { const idx = prev.findIndex(t => t.name === p.name) return idx >= 0 ? [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] : prev }) } break case 'tool.start': setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }]) break case 'tool.complete': { const mark = p.error ? '✗' : '✓' setTools(prev => { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' pushActivity(`${label}${ctx ? ': ' + ctx : ''} ${mark}`, p.error ? 'error' : 'info') return 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 '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': setBgTasks(prev => { const next = new Set(prev) next.delete(p.task_id) return next }) sys(`[bg ${p.task_id}] ${p.text}`) break case 'btw.complete': setBgTasks(prev => { const next = new Set(prev) next.delete(`btw:${p.task_id ?? 'x'}`) return next }) sys(`[btw] ${p.text}`) break case 'message.delta': if (p?.text && !interruptedRef.current) { buf.current += p.rendered ?? p.text setStreaming(buf.current.trimStart()) } break case 'message.complete': { const wasInterrupted = interruptedRef.current idle() setStreaming('') if (inflightPasteIdsRef.current.length) { setPastes(prev => prev.filter(paste => !inflightPasteIdsRef.current.includes(paste.id))) inflightPasteIdsRef.current = [] } if (!wasInterrupted) { appendMessage({ role: 'assistant', text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) } buf.current = '' setStatus('ready') if (p?.usage) { setUsage(p.usage) } if (queueEditRef.current !== null) { break } const next = dequeue() if (next) { send(next) } break } case 'error': inflightPasteIdsRef.current = [] sys(`error: ${p?.message}`) idle() setStatus('ready') break } }, [appendMessage, dequeue, newSession, pushActivity, send, sys] ) const onExit = useCallback(() => { setStatus('gateway exited') exit() }, [exit]) useEffect(() => { gw.on('event', onEvent) gw.on('exit', onExit) return () => { gw.off('event', onEvent) gw.off('exit', onExit) } }, [gw, onEvent, onExit]) // ── Slash commands ─────────────────────────────────────────────── const slash = useCallback( (cmd: string): boolean => { const [name, ...rest] = cmd.slice(1).split(/\s+/) const arg = rest.join(' ') switch (name) { case 'help': { const rows = catalog?.pairs ?? [] const cap = 52 sys( [ ' Commands:', ...rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`), rows.length > cap ? ` … ${rows.length - cap} more` : '', '', ' Hotkeys:', ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`) ] .filter(Boolean) .join('\n') ) return true } case 'quit': case 'exit': case 'q': die() return true case 'clear': setStatus('forging session…') newSession() return true case 'new': setStatus('forging session…') newSession('new session started') return true case 'resume': setPicker(true) return true case 'compact': setCompact(c => (arg ? true : !c)) sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) 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 } writeOsc52Clipboard(target.text) sys('copied to clipboard') return true } case 'paste': if (!arg) { paste() return true } if (arg === 'list') { sys( pastes.length ? pastes .map( p => `#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` ) .join('\n') : 'no text pastes' ) return true } if (arg === 'clear') { setPastes([]) setInput(v => stripTokens(v, PASTE_TOKEN_RE)) setInputBuf(prev => prev.map(l => stripTokens(l, PASTE_TOKEN_RE)).filter(Boolean)) pushActivity('cleared paste shelf') return true } if (arg.startsWith('drop ')) { const id = parseInt(arg.split(/\s+/)[1] ?? '-1', 10) if (!id || !pastes.some(p => p.id === id)) { sys('usage: /paste drop ') return true } const re = new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g') setPastes(prev => prev.filter(p => p.id !== id)) setInput(v => stripTokens(v, re)) setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean)) pushActivity(`dropped paste #${id}`) return true } if (arg.startsWith('mode ')) { const [, rawId, rawMode] = arg.split(/\s+/) const id = parseInt(rawId ?? '-1', 10) const mode = rawMode as PasteMode if (!id || !['attach', 'excerpt', 'inline'].includes(mode) || !pastes.some(p => p.id === id)) { sys('usage: /paste mode ') return true } setPastes(prev => prev.map(p => (p.id === id ? { ...p, mode } : p))) pushActivity(`paste #${id} mode → ${mode}`) return true } sys('usage: /paste [list|mode |drop |clear]') return true case 'logs': sys(gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) || 'no gateway logs') return true case 'statusbar': case 'sb': setStatusBar(v => !v) sys(`status bar ${statusBar ? 'off' : 'on'}`) return true case 'queue': if (!arg) { sys(`${queueRef.current.length} queued message(s)`) return true } enqueue(arg) sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) return true case 'undo': if (!sid) { return true } rpc('session.undo', { session_id: sid }).then((r: any) => { if (r.removed > 0) { setMessages(prev => { const q = [...prev] while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { q.pop() } if (q.at(-1)?.role === 'user') { q.pop() } 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() } return q }) send(lastUserMsg) return true case 'background': case 'bg': if (!arg) { sys('/background ') return true } rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => { setBgTasks(prev => new Set(prev).add(r.task_id)) sys(`bg ${r.task_id} started`) }) return true case 'btw': if (!arg) { sys('/btw ') return true } rpc('prompt.btw', { session_id: sid, text: arg }).then(() => { setBgTasks(prev => new Set(prev).add('btw:x')) sys('btw running…') }) return true case 'model': if (!arg) { rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) } else { rpc('config.set', { key: 'model', value: arg.replace('--global', '').trim() }).then((r: any) => { sys(`model → ${r.value}`) setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) }) } return true case 'yolo': rpc('config.set', { key: 'yolo' }).then((r: any) => sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)) return true case 'reasoning': rpc('config.set', { key: 'reasoning', value: arg || 'medium' }).then((r: any) => sys(`reasoning: ${r.value}`)) return true case 'verbose': rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose: ${r.value}`)) return true case 'personality': if (arg) { rpc('config.set', { key: 'personality', value: arg }).then((r: any) => sys(`personality: ${r.value || 'default'}`) ) } else { gw.request('slash.exec', { command: 'personality', session_id: sid }) .then((r: any) => sys(r?.output || '(no output)')) .catch(() => sys('personality command failed')) } return true case 'compress': rpc('session.compress', { session_id: sid }).then((r: any) => sys(`compressed${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) ) return true case 'stop': rpc('process.stop', {}).then((r: any) => sys(`killed ${r.killed ?? 0} process(es)`)) return true case 'branch': case 'fork': rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { if (r?.session_id) { setSid(r.session_id) setHistoryItems([]) setMessages([]) sys(`branched → ${r.title}`) } }) return true case 'reload-mcp': case 'reload_mcp': rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP reloaded')) return true case 'title': rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => sys(`title: ${r.title || '(none)'}`) ) return true case 'usage': rpc('session.usage', { session_id: sid }).then((r: any) => { if (r) { setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) } sys( `${fmtK(r?.input ?? 0)} in · ${fmtK(r?.output ?? 0)} out · ${fmtK(r?.total ?? 0)} total · ${r?.calls ?? 0} calls` ) }) return true case 'save': rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved: ${r.file}`)) return true case 'history': rpc('session.history', { session_id: sid }).then((r: any) => sys(`${r.count} messages`)) return true case 'profile': rpc('config.get', { key: 'profile' }).then((r: any) => sys(r.display || r.home)) return true case 'voice': rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`) ) return true case 'insights': rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => sys(`${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) ) return true case 'rollback': { const [sub, ...rArgs] = (arg || 'list').split(/\s+/) if (!sub || sub === 'list') { rpc('rollback.list', { session_id: sid }).then((r: any) => { if (!r.checkpoints?.length) { return sys('no checkpoints') } sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) }) } else { const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { session_id: sid, hash }).then((r: any) => sys(r.rendered || r.diff || r.message || 'done') ) } return true } case 'browser': { const [act, ...bArgs] = (arg || 'status').split(/\s+/) rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') ) return true } case 'plugins': rpc('plugins.list', {}).then((r: any) => { if (!r.plugins?.length) { return sys('no plugins') } sys(r.plugins.map((p: any) => ` ${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`).join('\n')) }) return true default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || `/${name}: no output`)) .catch(() => { gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) .then((d: any) => { if (d.type === 'exec') { sys(d.output || '(no output)') } else if (d.type === 'alias') { slash(`/${d.target}${arg ? ' ' + arg : ''}`) } else if (d.type === 'plugin') { sys(d.output || '(no output)') } else if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`) send(d.message) } }) .catch(() => sys(`unknown command: /${name}`)) }) return true } }, [catalog, compact, gw, lastUserMsg, messages, newSession, pastes, pushActivity, rpc, send, sid, statusBar, sys] ) slashRef.current = slash // ── Submit ─────────────────────────────────────────────────────── 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 && busy && sid) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) const partial = (streaming || buf.current).trimStart() if (partial) { appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) } else { sys('interrupted') } idle() setStatus('interrupted') setTimeout(() => setStatus('ready'), 1500) return } if (dbl && queueRef.current.length) { const next = dequeue() if (next && sid) { setQueueEdit(null) dispatchSubmission(next, true) } } return } lastEmptyAt.current = 0 if (value.endsWith('\\')) { setInputBuf(prev => [...prev, value.slice(0, -1)]) setInput('') return } dispatchSubmission([...inputBuf, value].join('\n')) }, [dequeue, dispatchSubmission, inputBuf, sid] ) // ── Derived ────────────────────────────────────────────────────── const statusColor = status === 'ready' ? theme.color.ok : status.startsWith('error') ? theme.color.error : status === 'interrupted' ? theme.color.warn : theme.color.dim // ── Render ─────────────────────────────────────────────────────── return ( {(m, i) => ( {m.kind === 'intro' && m.info ? ( {introCollapsed ? ( {theme.brand.icon} {theme.brand.name} · {m.info.model.split('/').pop()} ) : ( <> )} ) : ( )} )} {streaming && ( )} {(thinking || tools.length > 0) && (!streaming || tools.length > 0) && ( )} {pasteReview && ( Review large paste before send pastes: {pasteReview.largeIds.map(id => `#${id}`).join(', ')} Enter to send · Esc/Ctrl+C to cancel )} {clarify && ( { gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) appendMessage({ role: 'user', text: answer }) setClarify(null) }} 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} /> )} {sudo && ( { gw.request('sudo.respond', { request_id: sudo.requestId, password: pw }).catch(() => {}) setSudo(null) setStatus('running…') }} t={theme} /> )} {secret && ( { gw.request('secret.respond', { request_id: secret.requestId, value: val }).catch(() => {}) setSecret(null) setStatus('running…') }} sub={`for ${secret.envVar}`} t={theme} /> )} {picker && ( setPicker(false)} onSelect={id => { setPicker(false) setStatus('resuming…') gw.request('session.resume', { cols, session_id: id }) .then((r: any) => { resetSession() setSid(r.session_id) setInfo(r.info ?? null) if (r.info) { appendHistory(introMsg(r.info)) } sys(`resumed session (${r.message_count} messages)`) setStatus('ready') }) .catch((e: Error) => { sys(`error: ${e.message}`) setStatus('ready') }) }} t={theme} /> )} {bgTasks.size > 0 && ( {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running · /stop to cancel )} {statusBar && ( 0 && `${bgTasks.size} bg`, usage.total > 0 && `${fmtK(usage.total)} tok` ]} statusColor={statusColor} /> )} {!isBlocked && ( {inputBuf.length ? '… ' : `${theme.brand.prompt} `} )} {!!completions.length && ( {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { const active = Math.max(0, compIdx - 8) + i === compIdx return ( {item.display} {item.meta ? {item.meta} : null} ) })} )} {!empty && !sid && ⚕ {status}} ) }