hermes-agent/ui-tui/src/app.tsx
2026-04-09 16:27:06 -05:00

1967 lines
57 KiB
TypeScript

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, 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 { type PasteEvent, 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, sameToolTrailGroup } 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 STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
const LARGE_PASTE = { chars: 8000, lines: 80 }
const EXCERPT = { chars: 1200, lines: 14 }
const MAX_HISTORY = 800
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()
const toTranscriptMessages = (rows: unknown): Msg[] => {
if (!Array.isArray(rows)) {
return []
}
return rows.flatMap(row => {
if (!row || typeof row !== 'object') {
return []
}
const role = (row as any).role
const text = (row as any).text
if (
(role !== 'assistant' && role !== 'system' && role !== 'tool' && role !== 'user') ||
typeof text !== 'string' ||
!text.trim()
) {
return []
}
return [{ role, text }]
})
}
// ── StatusRule ────────────────────────────────────────────────────────
function ctxBarColor(pct: number | undefined, t: Theme) {
if (pct == null) {
return t.color.dim
}
if (pct >= 95) {
return t.color.statusCritical
}
if (pct > 80) {
return t.color.statusBad
}
if (pct >= 50) {
return t.color.statusWarn
}
return t.color.statusGood
}
function ctxBar(pct: number | undefined, w = 10) {
const p = Math.max(0, Math.min(100, pct ?? 0))
const filled = Math.round((p / 100) * w)
return '█'.repeat(filled) + '░'.repeat(w - filled)
}
function StatusRule({
cols,
status,
statusColor,
model,
usage,
bgCount,
t
}: {
cols: number
status: string
statusColor: string
model: string
usage: Usage
bgCount: number
t: Theme
}) {
const pct = usage.context_percent
const barColor = ctxBarColor(pct, t)
const ctxLabel = usage.context_max
? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
: usage.total > 0
? `${fmtK(usage.total)} tok`
: ''
const pctLabel = pct != null ? `${pct}%` : ''
const bar = usage.context_max ? ctxBar(pct) : ''
const segs = [status, model, ctxLabel, bar ? `[${bar}]` : '', pctLabel, bgCount > 0 ? `${bgCount} bg` : ''].filter(
Boolean
)
const inner = segs.join(' │ ')
const pad = Math.max(0, cols - inner.length - 5)
return (
<Text color={t.color.bronze}>
{'─ '}
<Text color={statusColor}>{status}</Text>
<Text color={t.color.dim}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (
<Text color={t.color.dim}>
{' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
</Text>
) : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
{' ' + '─'.repeat(pad)}
</Text>
)
}
// ── PromptBox ────────────────────────────────────────────────────────
function PromptBox({ children, color }: { children: React.ReactNode; color: string }) {
return (
<Box borderColor={color} borderStyle="round" flexDirection="column" marginTop={1} paddingX={1}>
{children}
</Box>
)
}
// ── 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)
// Enable bracketed paste so image-only clipboard paste reaches the app
if (stdout.isTTY) {
stdout.write('\x1b[?2004h')
}
return () => {
stdout.off('resize', sync)
if (stdout.isTTY) {
stdout.write('\x1b[?2004l')
}
}
}, [stdout])
// ── State ────────────────────────────────────────────────────────
const [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])
const [messages, setMessages] = useState<Msg[]>([])
const [historyItems, setHistoryItems] = useState<Msg[]>([])
const [status, setStatus] = useState('summoning hermes…')
const [sid, setSid] = useState<string | null>(null)
const [theme, setTheme] = useState<Theme>(DEFAULT_THEME)
const [info, setInfo] = useState<SessionInfo | null>(null)
const [thinking, setThinking] = useState(false)
const [turnKey, setTurnKey] = useState(0)
const [activity, setActivity] = useState<ActivityItem[]>([])
const [tools, setTools] = useState<ActiveTool[]>([])
const [busy, setBusy] = useState(false)
const [compact, setCompact] = useState(false)
const [usage, setUsage] = useState<Usage>(ZERO)
const [clarify, setClarify] = useState<ClarifyReq | null>(null)
const [approval, setApproval] = useState<ApprovalReq | null>(null)
const [sudo, setSudo] = useState<SudoReq | null>(null)
const [secret, setSecret] = useState<SecretReq | null>(null)
const [picker, setPicker] = useState(false)
const [reasoning, setReasoning] = useState('')
const [statusBar, setStatusBar] = useState(true)
const [lastUserMsg, setLastUserMsg] = useState('')
const [pastes, setPastes] = useState<PendingPaste[]>([])
const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null)
const [streaming, setStreaming] = useState('')
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
const [catalog, setCatalog] = useState<SlashCatalog | null>(null)
// ── Refs ─────────────────────────────────────────────────────────
const activityIdRef = useRef(0)
const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null)
const buf = useRef('')
const inflightPasteIdsRef = useRef<number[]>([])
const interruptedRef = useRef(false)
const reasoningRef = useRef('')
const slashRef = useRef<(cmd: string) => boolean>(() => false)
const lastEmptyAt = useRef(0)
const lastStatusNoteRef = useRef('')
const protocolWarnedRef = useRef(false)
const pasteCounterRef = useRef(0)
const colsRef = useRef(cols)
const turnToolsRef = useRef<string[]>([])
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
colsRef.current = cols
reasoningRef.current = reasoning
// ── 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) => {
const cap = (items: Msg[]) =>
items.length <= MAX_HISTORY
? items
: items[0]?.kind === 'intro'
? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))]
: items.slice(-MAX_HISTORY)
setMessages(prev => cap([...prev, msg]))
setHistoryItems(prev => cap([...prev, msg]))
}, [])
const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage])
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => {
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) {
return base
}
activityIdRef.current++
return [...base, { id: activityIdRef.current, text, tone }].slice(-8)
})
}, [])
const rpc = useCallback(
(method: string, params: Record<string, unknown> = {}) =>
gw.request(method, params).catch((e: Error) => {
sys(`error: ${e.message}`)
}),
[gw, sys]
)
const idle = () => {
setThinking(false)
setTools([])
setBusy(false)
setClarify(null)
setApproval(null)
setPasteReview(null)
setSudo(null)
setSecret(null)
setStreaming('')
buf.current = ''
}
const die = () => {
gw.kill()
exit()
}
const clearIn = () => {
setInput('')
setInputBuf([])
setPasteReview(null)
setQueueEdit(null)
setHistoryIdx(null)
historyDraftRef.current = ''
}
const resetSession = () => {
idle()
setReasoning('')
setSid(null as any) // will be set by caller
setHistoryItems([])
setMessages([])
setPastes([])
setActivity([])
setBgTasks(new Set())
setUsage(ZERO)
turnToolsRef.current = []
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)
if (r.info.usage) {
setUsage(prev => ({ ...prev, ...r.info.usage }))
}
setHistoryItems([introMsg(r.info)])
} else {
setInfo(null)
}
if (msg) {
sys(msg)
}
}),
[rpc, sys]
)
// ── Paste pipeline ───────────────────────────────────────────────
const listPasteIds = useCallback((text: string) => {
const ids = new Set<number>()
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<number>()
const usedIds = new Set<number>()
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 paste = useCallback(
(quiet = false) =>
rpc('clipboard.paste', { session_id: sid }).then((r: any) =>
r?.attached
? sys(`📎 Image #${r.count} attached from clipboard`)
: quiet || sys(r?.message || 'No image found in clipboard')
),
[rpc, sid, sys]
)
const handleTextPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent) => {
if (hotkey) {
void paste(false)
return null
}
if (bracketed) {
void paste(true)
}
if (!text) {
return null
}
const lineCount = text.split('\n').length
if (text.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
return { cursor: cursor + text.length, value: value.slice(0, cursor) + text + value.slice(cursor) }
}
pasteCounterRef.current++
const id = pasteCounterRef.current
const mode: PasteMode = 'attach'
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) }
},
[paste, 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')
}
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
statusTimerRef.current = null
}
inflightPasteIdsRef.current = payload.usedIds
setLastUserMsg(text)
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 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 ───────────────────────────────────────────────
const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
useInput((ch, key) => {
if (isBlocked) {
if (pasteReview) {
if (key.return) {
setPasteReview(null)
dispatchSubmission(pasteReview.text, true)
} else if (key.escape || ctrl(key, ch, 'c')) {
setPasteReview(null)
setStatus('ready')
}
return
}
if (ctrl(key, 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 (ctrl(key, ch, 'c')) {
if (busy && sid) {
interruptedRef.current = true
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
const partial = (streaming || buf.current).trimStart()
partial ? appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) : sys('interrupted')
idle()
setReasoning('')
setActivity([])
turnToolsRef.current = []
setStatus('interrupted')
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
setStatus('ready')
}, 1500)
} else if (input || inputBuf.length) {
clearIn()
} else {
return die()
}
return
}
if (ctrl(key, ch, 'd')) {
return die()
}
if (ctrl(key, ch, 'l')) {
setStatus('forging session…')
newSession()
return
}
if (ctrl(key, ch, 'g')) {
return openEditor()
}
})
// ── 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<string, string>,
pairs: r.pairs as [string, string][],
sub: (r.sub ?? {}) as Record<string, string[]>
})
})
.catch(() => {})
if (STARTUP_RESUME_ID) {
setStatus('resuming…')
gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then((r: any) => {
resetSession()
setSid(r.session_id)
setInfo(r.info ?? null)
const resumed = toTranscriptMessages(r.messages)
if (r.info?.usage) {
setUsage(prev => ({ ...prev, ...r.info.usage }))
}
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
setStatus('ready')
})
.catch(() => {
setStatus('forging session…')
newSession('resume failed, started a new session')
})
} else {
setStatus('forging session…')
newSession()
}
break
case 'session.info':
setInfo(p as SessionInfo)
if (p?.usage) {
setUsage(prev => ({ ...prev, ...p.usage }))
}
break
case 'thinking.delta':
break
case 'message.start':
setThinking(true)
setTurnKey(k => k + 1)
setBusy(true)
setReasoning('')
setActivity([])
turnToolsRef.current = []
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 ? '✗' : '✓'
const tone = p.error ? 'error' : 'info'
toolCompleteRibbonRef.current = null
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 || ''
const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}`
toolCompleteRibbonRef.current = { label, line }
turnToolsRef.current = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8)
return prev.filter(t => t.id !== p.tool_id)
})
if (toolCompleteRibbonRef.current) {
const { line, label } = toolCompleteRibbonRef.current
pushActivity(line, tone, label)
}
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: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
const savedReasoning = reasoningRef.current.trim()
const savedTools = [...turnToolsRef.current]
const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart()
idle()
setReasoning('')
setStreaming('')
if (inflightPasteIdsRef.current.length) {
setPastes(prev => prev.filter(paste => !inflightPasteIdsRef.current.includes(paste.id)))
inflightPasteIdsRef.current = []
}
if (!wasInterrupted) {
appendMessage({
role: 'assistant',
text: finalText,
thinking: savedReasoning || undefined,
tools: savedTools.length ? savedTools : undefined
})
}
turnToolsRef.current = []
setActivity([])
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()
setReasoning('')
setActivity([])
turnToolsRef.current = []
setStatus('ready')
break
}
},
[appendMessage, dequeue, newSession, pushActivity, send, sys]
)
onEventRef.current = onEvent
useEffect(() => {
const handler = (ev: GatewayEvent) => onEventRef.current(ev)
const exitHandler = () => {
setStatus('gateway exited')
exit()
}
gw.on('event', handler)
gw.on('exit', exitHandler)
return () => {
gw.off('event', handler)
gw.off('exit', exitHandler)
}
}, [gw, exit])
// ── 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 <id>')
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 <id> <attach|excerpt|inline>')
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 <id> <attach|excerpt|inline>|drop <id>|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 <prompt>')
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 <question>')
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 })
}
if (!r?.calls) {
sys('no API calls yet')
return
}
const f = (v: number) => (v ?? 0).toLocaleString()
const ln = (k: string, v: string) => ` ${k.padEnd(26)}${v.padStart(10)}`
const hr = ` ${'─'.repeat(36)}`
const cost =
r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
sys(
[
hr,
ln('Model:', r.model ?? ''),
ln('Input tokens:', f(r.input)),
ln('Cache read tokens:', f(r.cache_read)),
ln('Cache write tokens:', f(r.cache_write)),
ln('Output tokens:', f(r.output)),
ln('Total tokens:', f(r.total)),
ln('API calls:', f(r.calls)),
cost && ln('Cost:', cost),
hr,
r.context_max && ` Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)`,
r.compressions && ` Compressions: ${r.compressions}`
]
.filter(Boolean)
.join('\n')
)
})
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()
setReasoning('')
setActivity([])
turnToolsRef.current = []
setStatus('interrupted')
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
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 (
<Box flexDirection="column">
{historyItems.map((m, i) => (
<Box flexDirection="column" key={i} paddingX={1}>
{m.kind === 'intro' && m.info ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={theme} />
<SessionPanel info={m.info} sid={sid} t={theme} />
</Box>
) : (
<MessageLine cols={cols} compact={compact} msg={m} t={theme} />
)}
</Box>
))}
<Box flexDirection="column" paddingX={1}>
{streaming && (
<MessageLine cols={cols} compact={compact} msg={{ role: 'assistant', text: streaming }} t={theme} />
)}
{(thinking || tools.length > 0) && (!streaming || tools.length > 0) && (
<Thinking key={turnKey} reasoning={reasoning} t={theme} tools={tools} />
)}
{busy && <ActivityLane items={activity} t={theme} />}
{pasteReview && (
<PromptBox color={theme.color.warn}>
<Text bold color={theme.color.warn}>
Review large paste before send
</Text>
<Text color={theme.color.dim}>pastes: {pasteReview.largeIds.map(id => `#${id}`).join(', ')}</Text>
<Text color={theme.color.dim}>Enter to send · Esc/Ctrl+C to cancel</Text>
</PromptBox>
)}
{clarify && (
<PromptBox color={theme.color.bronze}>
<ClarifyPrompt
onAnswer={answer => {
gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {})
appendMessage({ role: 'user', text: answer })
setClarify(null)
}}
req={clarify}
t={theme}
/>
</PromptBox>
)}
{approval && (
<PromptBox color={theme.color.bronze}>
<ApprovalPrompt
onChoice={choice => {
gw.request('approval.respond', { choice, session_id: sid }).catch(() => {})
setApproval(null)
sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
setStatus('running…')
}}
req={approval}
t={theme}
/>
</PromptBox>
)}
{sudo && (
<PromptBox color={theme.color.bronze}>
<MaskedPrompt
icon="🔐"
label="sudo password required"
onSubmit={pw => {
gw.request('sudo.respond', { request_id: sudo.requestId, password: pw }).catch(() => {})
setSudo(null)
setStatus('running…')
}}
t={theme}
/>
</PromptBox>
)}
{secret && (
<PromptBox color={theme.color.bronze}>
<MaskedPrompt
icon="🔑"
label={secret.prompt}
onSubmit={val => {
gw.request('secret.respond', { request_id: secret.requestId, value: val }).catch(() => {})
setSecret(null)
setStatus('running…')
}}
sub={`for ${secret.envVar}`}
t={theme}
/>
</PromptBox>
)}
{picker && (
<PromptBox color={theme.color.bronze}>
<SessionPicker
gw={gw}
onCancel={() => setPicker(false)}
onSelect={id => {
setPicker(false)
setStatus('resuming…')
gw.request('session.resume', { cols: colsRef.current, session_id: id })
.then((r: any) => {
resetSession()
setSid(r.session_id)
setInfo(r.info ?? null)
const resumed = toTranscriptMessages(r.messages)
if (r.info?.usage) {
setUsage(prev => ({ ...prev, ...r.info.usage }))
}
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
setStatus('ready')
})
.catch((e: Error) => {
sys(`error: ${e.message}`)
setStatus('ready')
})
}}
t={theme}
/>
</PromptBox>
)}
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />
{bgTasks.size > 0 && (
<Text color={theme.color.dim} dimColor>
{bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running · /stop to cancel
</Text>
)}
<Text> </Text>
{statusBar && (
<StatusRule
bgCount={bgTasks.size}
cols={cols}
model={info?.model?.split('/').pop() ?? ''}
status={status}
statusColor={statusColor}
t={theme}
usage={usage}
/>
)}
{!isBlocked && (
<Box flexDirection="column">
{inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={theme.color.dim}>{i === 0 ? `${theme.brand.prompt} ` : ' '}</Text>
</Box>
<Text color={theme.color.cornsilk}>{line || ' '}</Text>
</Box>
))}
<Box>
<Box width={3}>
<Text bold color={theme.color.gold}>
{inputBuf.length ? ' ' : `${theme.brand.prompt} `}
</Text>
</Box>
<TextInput
onChange={setInput}
onPaste={handleTextPaste}
onSubmit={submit}
placeholder={empty ? PLACEHOLDER : busy ? 'Ctrl+C to interrupt…' : ''}
value={input}
/>
</Box>
</Box>
)}
{!!completions.length && (
<Box borderColor={theme.color.bronze} borderStyle="single" flexDirection="column" paddingX={1}>
{completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => {
const active = Math.max(0, compIdx - 8) + i === compIdx
return (
<Text key={item.text}>
<Text bold={active} color={active ? theme.color.amber : theme.color.cornsilk}>
{item.display}
</Text>
{item.meta ? <Text color={theme.color.dim}> {item.meta}</Text> : null}
</Text>
)
})}
</Box>
)}
{!empty && !sid && <Text color={theme.color.dim}> {status}</Text>}
</Box>
</Box>
)
}