diff --git a/cli.py b/cli.py
index d696cdef8..80b5b088d 100644
--- a/cli.py
+++ b/cli.py
@@ -3752,7 +3752,7 @@ class HermesCLI:
skin = get_active_skin()
separator_color = skin.get_color("banner_dim", "#B8860B")
accent_color = skin.get_color("ui_accent", "#FFBF00")
- label_color = skin.get_color("ui_label", "#4dd0e1")
+ label_color = skin.get_color("ui_label", "#DAA520")
except Exception:
separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan"
toolsets_info = ""
diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py
index b992ada06..5b406f1f5 100644
--- a/hermes_cli/skin_engine.py
+++ b/hermes_cli/skin_engine.py
@@ -23,7 +23,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
banner_dim: "#B8860B" # Dim/muted text (separators, labels)
banner_text: "#FFF8DC" # Body text (tool names, skill names)
ui_accent: "#FFBF00" # General UI accent
- ui_label: "#4dd0e1" # UI labels
+ ui_label: "#DAA520" # UI labels (warm gold; teal clashed w/ default banner gold)
ui_ok: "#4caf50" # Success indicators
ui_error: "#ef5350" # Error indicators
ui_warn: "#ffa726" # Warning indicators
@@ -163,7 +163,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_dim": "#B8860B",
"banner_text": "#FFF8DC",
"ui_accent": "#FFBF00",
- "ui_label": "#4dd0e1",
+ "ui_label": "#DAA520",
"ui_ok": "#4caf50",
"ui_error": "#ef5350",
"ui_warn": "#ffa726",
diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json
index 04c276797..0b33e6e33 100644
--- a/ui-tui/package-lock.json
+++ b/ui-tui/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@hermes/ink": "file:./packages/hermes-ink",
+ "@nanostores/react": "^1.1.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",
"react": "^19.2.4",
@@ -1115,6 +1116,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@nanostores/react": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.1.0.tgz",
+ "integrity": "sha512-MbH35fjhcf7LAubYX5vhOChYUfTLzNLqH/mBGLVsHkcvjy0F8crO1WQwdmQ2xKbAmtpalDa2zBt3Hlg5kqr8iw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": "^20.0.0 || >=22.0.0"
+ },
+ "peerDependencies": {
+ "nanostores": "^1.2.0",
+ "react": ">=18.0.0"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
@@ -4761,6 +4781,22 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/nanostores": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz",
+ "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^20.0.0 || >=22.0.0"
+ }
+ },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
diff --git a/ui-tui/package.json b/ui-tui/package.json
index e6e10ec06..4776f0830 100644
--- a/ui-tui/package.json
+++ b/ui-tui/package.json
@@ -17,6 +17,7 @@
},
"dependencies": {
"@hermes/ink": "file:./packages/hermes-ink",
+ "@nanostores/react": "^1.1.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",
"react": "^19.2.4",
diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx
index e9687ce7c..79edcce28 100644
--- a/ui-tui/src/app.tsx
+++ b/ui-tui/src/app.tsx
@@ -1,462 +1,33 @@
-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 { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink'
+import { useStore } from '@nanostores/react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { MAX_HISTORY, MOUSE_TRACKING, PASTE_SNIPPET_RE, STARTUP_RESUME_ID, WHEEL_SCROLL_STEP } from './app/constants.js'
+import { createGatewayEventHandler } from './app/createGatewayEventHandler.js'
+import { createSlashHandler } from './app/createSlashHandler.js'
+import { GatewayProvider } from './app/gatewayContext.js'
import {
- AlternateScreen,
- Box,
- NoSelect,
- ScrollBox,
- type ScrollBoxHandle,
- Text,
- useApp,
- useHasSelection,
- useInput,
- useSelection,
- useStdout
-} from '@hermes/ink'
-import { type RefObject, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
-
-import { Banner, Panel, SessionPanel } from './components/branding.js'
-import { MaskedPrompt } from './components/maskedPrompt.js'
-import { MessageLine } from './components/messageLine.js'
-import { ModelPicker } from './components/modelPicker.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 { ToolTrail } from './components/thinking.js'
-import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ZERO } from './constants.js'
+ fmtDuration,
+ imageTokenMeta,
+ introMsg,
+ looksLikeSlashCommand,
+ resolveDetailsMode,
+ shortCwd,
+ toTranscriptMessages
+} from './app/helpers.js'
+import { type TranscriptRow } from './app/interfaces.js'
+import { $isBlocked, $overlayState, patchOverlayState } from './app/overlayStore.js'
+import { $uiState, getUiState, patchUiState } from './app/uiStore.js'
+import { useComposerState } from './app/useComposerState.js'
+import { useInputHandlers } from './app/useInputHandlers.js'
+import { useTurnState } from './app/useTurnState.js'
+import { AppLayout } from './components/appLayout.js'
+import { INTERPOLATION_RE, 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 { useVirtualHistory } from './hooks/useVirtualHistory.js'
-import { writeOsc52Clipboard } from './lib/osc52.js'
import { asRpcResult, rpcErrorMessage } from './lib/rpc.js'
-import {
- buildToolTrailLine,
- fmtK,
- hasInterpolation,
- isToolTrailResultLine,
- isTransientTrailLine,
- pasteTokenLabel,
- pick,
- sameToolTrailGroup,
- stripTrailingPasteNewlines,
- toolTrailLabel,
- userDisplay
-} from './lib/text.js'
-import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
-import type {
- ActiveTool,
- ActivityItem,
- ApprovalReq,
- ClarifyReq,
- DetailsMode,
- Msg,
- PanelSection,
- SecretReq,
- SessionInfo,
- SlashCatalog,
- SudoReq,
- Usage
-} from './types.js'
-
-// ── Constants ────────────────────────────────────────────────────────
-
-const PLACEHOLDER = pick(PLACEHOLDERS)
-const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
-
-const LARGE_PASTE = { chars: 8000, lines: 80 }
-const MAX_HISTORY = 800
-const REASONING_PULSE_MS = 700
-const STREAM_BATCH_MS = 16
-const WHEEL_SCROLL_STEP = 3
-const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase())
-const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g
-
-const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
-
-const parseDetailsMode = (v: unknown): DetailsMode | null => {
- const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
-
- return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
-}
-
-const resolveDetailsMode = (d: any): DetailsMode =>
- parseDetailsMode(d?.details_mode) ??
- { full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
- String(d?.thinking_mode ?? '')
- .trim()
- .toLowerCase()
- ] ??
- 'collapsed'
-
-const nextDetailsMode = (m: DetailsMode): DetailsMode =>
- DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
-
-// ── Pure helpers ─────────────────────────────────────────────────────
-
-type PasteSnippet = { label: string; text: string }
-
-const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info })
-
-const shortCwd = (cwd: string, max = 28) => {
- const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd
-
- return p.length <= max ? p : `…${p.slice(-(max - 1))}`
-}
-
-const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => {
- const dims = info?.width && info?.height ? `${info.width}x${info.height}` : ''
-
- const tok =
- typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
-
- return [dims, tok].filter(Boolean).join(' · ')
-}
-
-const looksLikeSlashCommand = (text: string) => {
- if (!text.startsWith('/')) {
- return false
- }
-
- const first = text.split(/\s+/, 1)[0] || ''
-
- return !first.slice(1).includes('/')
-}
-
-const toTranscriptMessages = (rows: unknown): Msg[] => {
- if (!Array.isArray(rows)) {
- return []
- }
-
- const result: Msg[] = []
- let pendingTools: string[] = []
-
- for (const row of rows) {
- if (!row || typeof row !== 'object') {
- continue
- }
-
- const role = (row as any).role
- const text = (row as any).text
-
- if (role === 'tool') {
- const name = (row as any).name ?? 'tool'
- const ctx = (row as any).context ?? ''
- pendingTools.push(buildToolTrailLine(name, ctx))
-
- continue
- }
-
- if (typeof text !== 'string' || !text.trim()) {
- continue
- }
-
- if (role === 'assistant') {
- const msg: Msg = { role, text }
-
- if (pendingTools.length) {
- msg.tools = pendingTools
- pendingTools = []
- }
-
- result.push(msg)
-
- continue
- }
-
- if (role === 'user' || role === 'system') {
- pendingTools = []
- result.push({ role, text })
- }
- }
-
- return result
-}
-
-// ── 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 fmtDuration(ms: number) {
- const total = Math.max(0, Math.floor(ms / 1000))
- const hours = Math.floor(total / 3600)
- const mins = Math.floor((total % 3600) / 60)
- const secs = total % 60
-
- if (hours > 0) {
- return `${hours}h ${mins}m`
- }
-
- if (mins > 0) {
- return `${mins}m ${secs}s`
- }
-
- return `${secs}s`
-}
-
-function StatusRule({
- cwdLabel,
- cols,
- status,
- statusColor,
- model,
- usage,
- bgCount,
- durationLabel,
- voiceLabel,
- t
-}: {
- cwdLabel: string
- cols: number
- status: string
- statusColor: string
- model: string
- usage: Usage
- bgCount: number
- durationLabel?: string
- voiceLabel?: string
- 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 leftWidth = Math.max(12, cols - cwdLabel.length - 3)
-
- return (
-
-
-
- {'─ '}
- {status}
- │ {model}
- {ctxLabel ? │ {ctxLabel} : null}
- {bar ? (
-
- {' │ '}
- [{bar}] {pctLabel}
-
- ) : null}
- {durationLabel ? │ {durationLabel} : null}
- {voiceLabel ? │ {voiceLabel} : null}
- {bgCount > 0 ? │ {bgCount} bg : null}
-
-
- ─
- {cwdLabel}
-
- )
-}
-
-// ── PromptBox ────────────────────────────────────────────────────────
-
-function FloatBox({ children, color }: { children: React.ReactNode; color: string }) {
- return (
-
- {children}
-
- )
-}
-
-const upperBound = (arr: ArrayLike, target: number) => {
- let lo = 0
- let hi = arr.length
-
- while (lo < hi) {
- const mid = (lo + hi) >> 1
-
- if (arr[mid]! <= target) {
- lo = mid + 1
- } else {
- hi = mid
- }
- }
-
- return lo
-}
-
-function StickyPromptTracker({
- messages,
- offsets,
- scrollRef,
- onChange
-}: {
- messages: readonly Msg[]
- offsets: ArrayLike
- scrollRef: RefObject
- onChange: (text: string) => void
-}) {
- useSyncExternalStore(
- useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
- () => {
- const s = scrollRef.current
-
- if (!s) {
- return NaN
- }
-
- const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
-
- return s.isSticky() ? -1 - top : top
- },
- () => NaN
- )
-
- const s = scrollRef.current
- const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
-
- let text = ''
-
- if (!(s?.isSticky() ?? true) && messages.length) {
- const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
-
- if (!(messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top)) {
- for (let i = first - 1; i >= 0; i--) {
- if (messages[i]?.role !== 'user') {
- continue
- }
-
- if ((offsets[i] ?? 0) + 1 >= top) {
- continue
- }
-
- text = userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
-
- break
- }
- }
- }
-
- useEffect(() => onChange(text), [onChange, text])
-
- return null
-}
-
-function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) {
- useSyncExternalStore(
- useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
- () => {
- const s = scrollRef.current
-
- if (!s) {
- return NaN
- }
-
- return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}`
- },
- () => ''
- )
-
- const [hover, setHover] = useState(false)
- const [grab, setGrab] = useState(null)
-
- const s = scrollRef.current
- const vp = Math.max(0, s?.getViewportHeight() ?? 0)
-
- if (!vp) {
- return
- }
-
- const total = Math.max(vp, s?.getScrollHeight() ?? vp)
- const scrollable = total > vp
- const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
- const travel = Math.max(1, vp - thumb)
- const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
- const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
-
- const jump = (row: number, offset: number) => {
- if (!s || !scrollable) {
- return
- }
-
- s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
- }
-
- return (
- {
- const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
- const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
- setGrab(off)
- jump(row, off)
- }}
- onMouseDrag={(e: { localRow?: number }) =>
- jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
- }
- onMouseEnter={() => setHover(true)}
- onMouseLeave={() => setHover(false)}
- onMouseUp={() => setGrab(null)}
- width={1}
- >
- {Array.from({ length: vp }, (_, i) => {
- const active = i >= thumbTop && i < thumbTop + thumb
-
- const color = active
- ? grab !== null
- ? t.color.gold
- : hover
- ? t.color.amber
- : t.color.bronze
- : hover
- ? t.color.bronze
- : t.color.dim
-
- return (
-
- {scrollable ? (active ? '┃' : '│') : ' '}
-
- )
- })}
-
- )
-}
+import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js'
+import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js'
// ── App ──────────────────────────────────────────────────────────────
@@ -489,154 +60,55 @@ export function App({ gw }: { gw: GatewayClient }) {
// ── 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 [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 [modelPicker, setModelPicker] = useState(false)
- const [picker, setPicker] = useState(false)
- const [reasoning, setReasoning] = useState('')
- const [reasoningActive, setReasoningActive] = useState(false)
- const [reasoningStreaming, setReasoningStreaming] = useState(false)
- const [statusBar, setStatusBar] = useState(true)
const [lastUserMsg, setLastUserMsg] = useState('')
const [stickyPrompt, setStickyPrompt] = useState('')
- const [pasteSnips, setPasteSnips] = useState([])
- const [streaming, setStreaming] = useState('')
- const [turnTrail, setTurnTrail] = useState([])
- const [bgTasks, setBgTasks] = useState>(new Set())
const [catalog, setCatalog] = useState(null)
- const [pager, setPager] = useState<{ lines: string[]; offset: number; title?: string } | null>(null)
const [voiceEnabled, setVoiceEnabled] = useState(false)
const [voiceRecording, setVoiceRecording] = useState(false)
const [voiceProcessing, setVoiceProcessing] = useState(false)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [bellOnComplete, setBellOnComplete] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
- const [detailsMode, setDetailsMode] = useState('collapsed')
+ const ui = useStore($uiState)
+ const overlay = useStore($overlayState)
+ const isBlocked = useStore($isBlocked)
// ── Refs ─────────────────────────────────────────────────────────
- const activityIdRef = useRef(0)
- const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null)
- const buf = useRef('')
- 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 colsRef = useRef(cols)
- const turnToolsRef = useRef([])
- const persistedToolLabelsRef = useRef>(new Set())
- const streamTimerRef = useRef | null>(null)
- const reasoningTimerRef = useRef | null>(null)
- const reasoningStreamingTimerRef = useRef | null>(null)
- const statusTimerRef = useRef | null>(null)
- const busyRef = useRef(busy)
- const sidRef = useRef(sid)
const scrollRef = useRef(null)
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
+ const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {})
+ const submitRef = useRef<(value: string) => void>(() => {})
const configMtimeRef = useRef(0)
colsRef.current = cols
- busyRef.current = busy
- sidRef.current = sid
- reasoningRef.current = reasoning
// ── Hooks ────────────────────────────────────────────────────────
const hasSelection = useHasSelection()
const selection = useSelection()
+ const turn = useTurnState()
+ const turnActions = turn.actions
+ const turnRefs = turn.refs
+ const turnState = turn.state
- const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } =
- useQueue()
+ const composer = useComposerState({
+ gw,
+ onClipboardPaste: quiet => clipboardPasteRef.current(quiet),
+ submitRef
+ })
- const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
- const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw)
-
- const pulseReasoningStreaming = useCallback(() => {
- if (reasoningStreamingTimerRef.current) {
- clearTimeout(reasoningStreamingTimerRef.current)
- }
-
- setReasoningActive(true)
- setReasoningStreaming(true)
- reasoningStreamingTimerRef.current = setTimeout(() => {
- reasoningStreamingTimerRef.current = null
- setReasoningStreaming(false)
- }, REASONING_PULSE_MS)
- }, [])
-
- const scheduleStreaming = useCallback(() => {
- if (streamTimerRef.current) {
- return
- }
-
- streamTimerRef.current = setTimeout(() => {
- streamTimerRef.current = null
- setStreaming(buf.current.trimStart())
- }, STREAM_BATCH_MS)
- }, [])
-
- const scheduleReasoning = useCallback(() => {
- if (reasoningTimerRef.current) {
- return
- }
-
- reasoningTimerRef.current = setTimeout(() => {
- reasoningTimerRef.current = null
- setReasoning(reasoningRef.current)
- }, STREAM_BATCH_MS)
- }, [])
-
- const endReasoningPhase = useCallback(() => {
- if (reasoningStreamingTimerRef.current) {
- clearTimeout(reasoningStreamingTimerRef.current)
- reasoningStreamingTimerRef.current = null
- }
-
- setReasoningStreaming(false)
- setReasoningActive(false)
- }, [])
-
- useEffect(
- () => () => {
- if (streamTimerRef.current) {
- clearTimeout(streamTimerRef.current)
- }
-
- if (reasoningTimerRef.current) {
- clearTimeout(reasoningTimerRef.current)
- }
-
- if (reasoningStreamingTimerRef.current) {
- clearTimeout(reasoningStreamingTimerRef.current)
- }
- },
- []
- )
-
- function blocked() {
- return !!(clarify || approval || modelPicker || picker || secret || sudo || pager)
- }
+ const composerActions = composer.actions
+ const composerRefs = composer.refs
+ const composerState = composer.state
const empty = !messages.length
- const isBlocked = blocked()
- const virtualRows = useMemo(
+ const virtualRows = useMemo(
() =>
historyItems.map((msg, index) => ({
index,
@@ -704,17 +176,17 @@ export function App({ gw }: { gw: GatewayClient }) {
// ── Resize RPC ───────────────────────────────────────────────────
useEffect(() => {
- if (!sid || !stdout) {
+ if (!ui.sid || !stdout) {
return
}
- const onResize = () => rpc('terminal.resize', { session_id: sid, cols: stdout.columns ?? 80 })
+ const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 })
stdout.on('resize', onResize)
return () => {
stdout.off('resize', onResize)
}
- }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps
+ }, [stdout, ui.sid]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const id = setInterval(() => setClockNow(Date.now()), 1000)
@@ -740,7 +212,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const page = useCallback((text: string, title?: string) => {
const lines = text.split('\n')
- setPager({ lines, offset: 0, title })
+ patchOverlayState({ pager: { lines, offset: 0, title } })
}, [])
const panel = useCallback(
@@ -759,39 +231,9 @@ export function App({ gw }: { gw: GatewayClient }) {
[sys]
)
- 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 setTrail = (next: string[]) => {
- turnToolsRef.current = next
-
- return next
- }
-
- const pruneTransient = useCallback(() => {
- setTurnTrail(prev => {
- const next = prev.filter(l => !isTransientTrailLine(l))
-
- return next.length === prev.length ? prev : setTrail(next)
- })
- }, [])
-
- const pushTrail = useCallback((line: string) => {
- setTurnTrail(prev =>
- prev.at(-1) === line ? prev : setTrail([...prev.filter(l => !isTransientTrailLine(l)), line].slice(-8))
- )
- }, [])
+ const pushActivity = turnActions.pushActivity
+ const pruneTransient = turnActions.pruneTransient
+ const pushTrail = turnActions.pushTrail
const rpc = useCallback(
async (method: string, params: Record = {}) => {
@@ -812,16 +254,21 @@ export function App({ gw }: { gw: GatewayClient }) {
[gw, sys]
)
+ const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc])
+
const answerClarify = useCallback(
(answer: string) => {
+ const clarify = overlay.clarify
+
if (!clarify) {
return
}
const label = toolTrailLabel('clarify')
+ const nextTrail = turnRefs.turnToolsRef.current.filter(line => !sameToolTrailGroup(label, line))
- setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l)))
- setTurnTrail(turnToolsRef.current)
+ turnRefs.turnToolsRef.current = nextTrail
+ turnActions.setTurnTrail(nextTrail)
rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => {
if (!r) {
@@ -829,7 +276,7 @@ export function App({ gw }: { gw: GatewayClient }) {
}
if (answer) {
- persistedToolLabelsRef.current.add(label)
+ turnRefs.persistedToolLabelsRef.current.add(label)
appendMessage({
role: 'system',
text: '',
@@ -837,19 +284,19 @@ export function App({ gw }: { gw: GatewayClient }) {
tools: [buildToolTrailLine('clarify', clarify.question)]
})
appendMessage({ role: 'user', text: answer })
- setStatus('running…')
+ patchUiState({ status: 'running…' })
} else {
sys('prompt cancelled')
}
- setClarify(null)
+ patchOverlayState({ clarify: null })
})
},
- [appendMessage, clarify, rpc, sys]
+ [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs]
)
useEffect(() => {
- if (!sid) {
+ if (!ui.sid) {
return
}
@@ -859,15 +306,18 @@ export function App({ gw }: { gw: GatewayClient }) {
})
rpc('config.get', { key: 'full' }).then((r: any) => {
const display = r?.config?.display ?? {}
+
setBellOnComplete(!!display?.bell_on_complete)
- setCompact(!!display?.tui_compact)
- setStatusBar(display?.tui_statusbar !== false)
- setDetailsMode(resolveDetailsMode(display))
+ patchUiState({
+ compact: !!display?.tui_compact,
+ detailsMode: resolveDetailsMode(display),
+ statusBar: display?.tui_statusbar !== false
+ })
})
- }, [rpc, sid])
+ }, [rpc, ui.sid])
useEffect(() => {
- if (!sid) {
+ if (!ui.sid) {
return
}
@@ -877,7 +327,7 @@ export function App({ gw }: { gw: GatewayClient }) {
if (configMtimeRef.current && next && next !== configMtimeRef.current) {
configMtimeRef.current = next
- rpc('reload.mcp', { session_id: sid }).then(r => {
+ rpc('reload.mcp', { session_id: ui.sid }).then(r => {
if (!r) {
return
}
@@ -886,10 +336,13 @@ export function App({ gw }: { gw: GatewayClient }) {
})
rpc('config.get', { key: 'full' }).then((cfg: any) => {
const display = cfg?.config?.display ?? {}
+
setBellOnComplete(!!display?.bell_on_complete)
- setCompact(!!display?.tui_compact)
- setStatusBar(display?.tui_statusbar !== false)
- setDetailsMode(resolveDetailsMode(display))
+ patchUiState({
+ compact: !!display?.tui_compact,
+ detailsMode: resolveDetailsMode(display),
+ statusBar: display?.tui_statusbar !== false
+ })
})
} else if (!configMtimeRef.current && next) {
configMtimeRef.current = next
@@ -898,83 +351,57 @@ export function App({ gw }: { gw: GatewayClient }) {
}, 5000)
return () => clearInterval(id)
- }, [pushActivity, rpc, sid])
+ }, [pushActivity, rpc, ui.sid])
- const idle = () => {
- endReasoningPhase()
- setTools([])
- setTurnTrail([])
- setBusy(false)
- setClarify(null)
- setApproval(null)
- setSudo(null)
- setSecret(null)
+ const idle = turnActions.idle
+ const clearReasoning = turnActions.clearReasoning
- if (streamTimerRef.current) {
- clearTimeout(streamTimerRef.current)
- streamTimerRef.current = null
- }
-
- setStreaming('')
- buf.current = ''
- }
-
- const clearReasoning = () => {
- if (reasoningTimerRef.current) {
- clearTimeout(reasoningTimerRef.current)
- reasoningTimerRef.current = null
- }
-
- reasoningRef.current = ''
- setReasoning('')
- }
-
- const die = () => {
+ const die = useCallback(() => {
gw.kill()
exit()
- }
+ }, [exit, gw])
- const clearIn = () => {
- setInput('')
- setInputBuf([])
- setQueueEdit(null)
- setHistoryIdx(null)
- historyDraftRef.current = ''
- }
-
- const resetSession = () => {
+ const resetSession = useCallback(() => {
idle()
clearReasoning()
setVoiceRecording(false)
setVoiceProcessing(false)
- setSid(null as any) // will be set by caller
- setInfo(null)
+ patchUiState({
+ bgTasks: new Set(),
+ info: null,
+ sid: null,
+ usage: ZERO
+ })
setHistoryItems([])
setMessages([])
setStickyPrompt('')
- setPasteSnips([])
- setActivity([])
- setBgTasks(new Set())
- setUsage(ZERO)
- turnToolsRef.current = []
- lastStatusNoteRef.current = ''
- protocolWarnedRef.current = false
- }
+ composerActions.setPasteSnips([])
+ turnActions.setActivity([])
+ turnRefs.turnToolsRef.current = []
+ turnRefs.lastStatusNoteRef.current = ''
+ turnRefs.protocolWarnedRef.current = false
+ turnRefs.persistedToolLabelsRef.current.clear()
+ }, [clearReasoning, composerActions, idle, turnActions, turnRefs])
- const resetVisibleHistory = (info: SessionInfo | null = null) => {
- idle()
- clearReasoning()
- setMessages([])
- setHistoryItems(info ? [introMsg(info)] : [])
- setInfo(info)
- setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO)
- setStickyPrompt('')
- setPasteSnips([])
- setActivity([])
- setLastUserMsg('')
- turnToolsRef.current = []
- persistedToolLabelsRef.current.clear()
- }
+ const resetVisibleHistory = useCallback(
+ (info: SessionInfo | null = null) => {
+ idle()
+ clearReasoning()
+ setMessages([])
+ setHistoryItems(info ? [introMsg(info)] : [])
+ patchUiState({
+ info,
+ usage: info?.usage ? { ...ZERO, ...info.usage } : ZERO
+ })
+ setStickyPrompt('')
+ composerActions.setPasteSnips([])
+ turnActions.setActivity([])
+ setLastUserMsg('')
+ turnRefs.turnToolsRef.current = []
+ turnRefs.persistedToolLabelsRef.current.clear()
+ },
+ [clearReasoning, composerActions, idle, turnActions, turnRefs]
+ )
const trimLastExchange = (items: Msg[]) => {
const q = [...items]
@@ -992,7 +419,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const guardBusySessionSwitch = useCallback(
(what = 'switch sessions') => {
- if (!busyRef.current) {
+ if (!getUiState().busy) {
return false
}
@@ -1018,30 +445,26 @@ export function App({ gw }: { gw: GatewayClient }) {
const newSession = useCallback(
async (msg?: string) => {
- await closeSession(sidRef.current)
+ await closeSession(getUiState().sid)
return rpc('session.create', { cols: colsRef.current }).then((r: any) => {
if (!r) {
- setStatus('ready')
+ patchUiState({ status: 'ready' })
return
}
resetSession()
- setSid(r.session_id)
setSessionStartedAt(Date.now())
- setStatus('ready')
+ patchUiState({
+ info: r.info ?? null,
+ sid: r.session_id,
+ status: 'ready',
+ usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO
+ })
if (r.info) {
- setInfo(r.info)
-
- if (r.info.usage) {
- setUsage(prev => ({ ...prev, ...r.info.usage }))
- }
-
setHistoryItems([introMsg(r.info)])
- } else {
- setInfo(null)
}
if (r.info?.credential_warning) {
@@ -1053,14 +476,14 @@ export function App({ gw }: { gw: GatewayClient }) {
}
})
},
- [closeSession, rpc, sys]
+ [closeSession, resetSession, rpc, sys]
)
const resumeById = useCallback(
(id: string) => {
- setPicker(false)
- setStatus('resuming…')
- closeSession(sidRef.current === id ? null : sidRef.current).then(() =>
+ patchOverlayState({ picker: false })
+ patchUiState({ status: 'resuming…' })
+ closeSession(getUiState().sid === id ? null : getUiState().sid).then(() =>
gw
.request('session.resume', { cols: colsRef.current, session_id: id })
.then((raw: any) => {
@@ -1068,39 +491,38 @@ export function App({ gw }: { gw: GatewayClient }) {
if (!r) {
sys('error: invalid response: session.resume')
- setStatus('ready')
+ patchUiState({ status: 'ready' })
return
}
resetSession()
- setSid(r.session_id)
setSessionStartedAt(Date.now())
- 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')
+ patchUiState({
+ info: r.info ?? null,
+ sid: r.session_id,
+ status: 'ready',
+ usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO
+ })
})
.catch((e: Error) => {
sys(`error: ${e.message}`)
- setStatus('ready')
+ patchUiState({ status: 'ready' })
})
)
},
- [closeSession, gw, sys]
+ [closeSession, gw, resetSession, sys]
)
// ── Paste pipeline ───────────────────────────────────────────────
const paste = useCallback(
(quiet = false) =>
- rpc('clipboard.paste', { session_id: sid }).then((r: any) => {
+ rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => {
if (!r) {
return
}
@@ -1114,213 +536,54 @@ export function App({ gw }: { gw: GatewayClient }) {
quiet || sys(r.message || 'No image found in clipboard')
}),
- [rpc, sid, sys]
+ [rpc, sys]
)
- const handleTextPaste = useCallback(
- ({ bracketed, cursor, hotkey, text, value }: PasteEvent) => {
- if (hotkey) {
- void paste(false)
-
- return null
- }
-
- const cleanedText = stripTrailingPasteNewlines(text)
-
- if (!cleanedText || !/[^\n]/.test(cleanedText)) {
- if (bracketed) {
- void paste(true)
- }
-
- return null
- }
-
- const lineCount = cleanedText.split('\n').length
-
- if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
- return {
- cursor: cursor + cleanedText.length,
- value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
- }
- }
-
- const label = pasteTokenLabel(cleanedText, lineCount)
- const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
- const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
- const insert = `${lead}${label}${tail}`
-
- setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))
-
- return {
- cursor: cursor + insert.length,
- value: value.slice(0, cursor) + insert + value.slice(cursor)
- }
- },
- [paste]
- )
+ clipboardPasteRef.current = paste
+ const handleTextPaste = composerActions.handleTextPaste
// ── Send ─────────────────────────────────────────────────────────
- const send = (text: string) => {
- const expandPasteSnips = (value: string) => {
- const byLabel = new Map()
+ const send = useCallback(
+ (text: string) => {
+ const expandPasteSnips = (value: string) => {
+ const byLabel = new Map()
- for (const item of pasteSnips) {
- const list = byLabel.get(item.label)
- list ? list.push(item.text) : byLabel.set(item.label, [item.text])
+ for (const item of composerState.pasteSnips) {
+ const list = byLabel.get(item.label)
+ list ? list.push(item.text) : byLabel.set(item.label, [item.text])
+ }
+
+ return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token)
}
- return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token)
- }
+ const startSubmit = (displayText: string, submitText: string) => {
+ const sid = getUiState().sid
- const startSubmit = (displayText: string, submitText: string) => {
- if (statusTimerRef.current) {
- clearTimeout(statusTimerRef.current)
- statusTimerRef.current = null
- }
-
- setLastUserMsg(text)
- appendMessage({ role: 'user', text: displayText })
- setBusy(true)
- setStatus('running…')
- buf.current = ''
- interruptedRef.current = false
-
- gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => {
- sys(`error: ${e.message}`)
- setStatus('ready')
- setBusy(false)
- })
- }
-
- gw.request('input.detect_drop', { session_id: sid, text })
- .then((r: any) => {
- if (r?.matched) {
- if (r.is_image) {
- const meta = imageTokenMeta(r)
- pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
- } else {
- pushActivity(`detected file: ${r.name}`)
- }
-
- startSubmit(r.text || text, expandPasteSnips(r.text || text))
+ if (!sid) {
+ sys('session not ready yet')
return
}
- startSubmit(text, expandPasteSnips(text))
- })
- .catch(() => startSubmit(text, expandPasteSnips(text)))
- }
-
- const shellExec = (cmd: string) => {
- appendMessage({ role: 'user', text: `!${cmd}` })
- setBusy(true)
- setStatus('running…')
-
- gw.request('shell.exec', { command: cmd })
- .then((raw: any) => {
- const r = asRpcResult(raw)
-
- if (!r) {
- sys('error: invalid response: shell.exec')
-
- return
+ if (turnRefs.statusTimerRef.current) {
+ clearTimeout(turnRefs.statusTimerRef.current)
+ turnRefs.statusTimerRef.current = null
}
- const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim()
+ setLastUserMsg(text)
+ appendMessage({ role: 'user', text: displayText })
+ patchUiState({ busy: true, status: 'running…' })
+ turnRefs.bufRef.current = ''
+ turnRefs.interruptedRef.current = false
- 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((raw: any) => {
- const r = asRpcResult(raw)
-
- return [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)
+ gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => {
+ sys(`error: ${e.message}`)
+ patchUiState({ busy: false, status: 'ready' })
+ })
}
- then(out)
- })
- }
-
- const sendQueued = (text: string) => {
- if (text.startsWith('!')) {
- shellExec(text.slice(1).trim())
-
- return
- }
-
- if (hasInterpolation(text)) {
- setBusy(true)
- interpolate(text, send)
-
- return
- }
-
- send(text)
- }
-
- // ── Dispatch ─────────────────────────────────────────────────────
-
- const dispatchSubmission = useCallback(
- (full: string) => {
- if (!full.trim()) {
- return
- }
+ const sid = getUiState().sid
if (!sid) {
sys('session not ready yet')
@@ -1328,63 +591,177 @@ export function App({ gw }: { gw: GatewayClient }) {
return
}
- const clearInput = () => {
- setInputBuf([])
- setInput('')
- setHistoryIdx(null)
- historyDraftRef.current = ''
+ gw.request('input.detect_drop', { session_id: sid, text })
+ .then((r: any) => {
+ if (r?.matched) {
+ if (r.is_image) {
+ const meta = imageTokenMeta(r)
+ pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
+ } else {
+ pushActivity(`detected file: ${r.name}`)
+ }
+
+ startSubmit(r.text || text, expandPasteSnips(r.text || text))
+
+ return
+ }
+
+ startSubmit(text, expandPasteSnips(text))
+ })
+ .catch(() => startSubmit(text, expandPasteSnips(text)))
+ },
+ [appendMessage, composerState.pasteSnips, gw, pushActivity, sys, turnRefs]
+ )
+
+ const shellExec = useCallback(
+ (cmd: string) => {
+ appendMessage({ role: 'user', text: `!${cmd}` })
+ patchUiState({ busy: true, status: 'running…' })
+
+ gw.request('shell.exec', { command: cmd })
+ .then((raw: any) => {
+ const r = asRpcResult(raw)
+
+ if (!r) {
+ sys('error: invalid response: shell.exec')
+
+ return
+ }
+
+ 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(() => {
+ patchUiState({ busy: false, status: 'ready' })
+ })
+ },
+ [appendMessage, gw, sys]
+ )
+
+ const openEditor = composerActions.openEditor
+
+ const interpolate = useCallback(
+ (text: string, then: (result: string) => void) => {
+ patchUiState({ status: 'interpolating…' })
+ const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))]
+
+ Promise.all(
+ matches.map(m =>
+ gw
+ .request('shell.exec', { command: m[1]! })
+ .then((raw: any) => {
+ const r = asRpcResult(raw)
+
+ return [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)
+ })
+ },
+ [gw]
+ )
+
+ const sendQueued = useCallback(
+ (text: string) => {
+ if (text.startsWith('!')) {
+ shellExec(text.slice(1).trim())
+
+ return
+ }
+
+ if (hasInterpolation(text)) {
+ patchUiState({ busy: true })
+ interpolate(text, send)
+
+ return
+ }
+
+ send(text)
+ },
+ [interpolate, send, shellExec]
+ )
+
+ // ── Dispatch ─────────────────────────────────────────────────────
+
+ const dispatchSubmission = useCallback(
+ (full: string) => {
+ const live = getUiState()
+
+ if (!full.trim()) {
+ return
+ }
+
+ if (!live.sid) {
+ sys('session not ready yet')
+
+ return
}
if (looksLikeSlashCommand(full)) {
appendMessage({ role: 'system', text: full, kind: 'slash' })
- pushHistory(full)
+ composerActions.pushHistory(full)
slashRef.current(full)
- clearInput()
+ composerActions.clearIn()
return
}
if (full.startsWith('!')) {
- clearInput()
+ composerActions.clearIn()
shellExec(full.slice(1).trim())
return
}
- clearInput()
-
- const editIdx = queueEditRef.current
+ composerActions.clearIn()
+ const editIdx = composerRefs.queueEditRef.current
if (editIdx !== null) {
- replaceQ(editIdx, full)
- const picked = queueRef.current.splice(editIdx, 1)[0]
- syncQueue()
- setQueueEdit(null)
+ composerActions.replaceQueue(editIdx, full)
+ const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0]
+ composerActions.syncQueue()
+ composerActions.setQueueEdit(null)
- if (picked && busy && sid) {
- queueRef.current.unshift(picked)
- syncQueue()
+ if (picked && getUiState().busy && live.sid) {
+ composerRefs.queueRef.current.unshift(picked)
+ composerActions.syncQueue()
return
}
- if (picked && sid) {
+ if (picked && live.sid) {
sendQueued(picked)
}
return
}
- pushHistory(full)
+ composerActions.pushHistory(full)
- if (busy) {
- enqueue(full)
+ if (getUiState().busy) {
+ composerActions.enqueue(full)
return
}
if (hasInterpolation(full)) {
- setBusy(true)
+ patchUiState({ busy: true })
interpolate(full, send)
return
@@ -1393,644 +770,119 @@ export function App({ gw }: { gw: GatewayClient }) {
send(full)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [appendMessage, busy, enqueue, gw, pushHistory, sid]
+ [appendMessage, composerActions, composerRefs]
)
// ── Input handling ───────────────────────────────────────────────
-
- const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
-
- const pagerPageSize = Math.max(5, (stdout?.rows ?? 24) - 6)
-
- useInput((ch, key) => {
- if (isBlocked) {
- if (pager) {
- if (key.return || ch === ' ') {
- const next = pager.offset + pagerPageSize
-
- if (next >= pager.lines.length) {
- setPager(null)
- } else {
- setPager({ ...pager, offset: next })
- }
- } else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') {
- setPager(null)
- }
-
- return
- }
-
- if (ctrl(key, ch, 'c')) {
- if (clarify) {
- answerClarify('')
- } else if (approval) {
- rpc('approval.respond', { choice: 'deny', session_id: sid }).then(r => {
- if (!r) {
- return
- }
-
- setApproval(null)
- sys('denied')
- })
- } else if (sudo) {
- rpc('sudo.respond', { request_id: sudo.requestId, password: '' }).then(r => {
- if (!r) {
- return
- }
-
- setSudo(null)
- sys('sudo cancelled')
- })
- } else if (secret) {
- rpc('secret.respond', { request_id: secret.requestId, value: '' }).then(r => {
- if (!r) {
- return
- }
-
- setSecret(null)
- sys('secret entry cancelled')
- })
- } else if (modelPicker) {
- setModelPicker(false)
- } else if (picker) {
- setPicker(false)
- }
- } else if (key.escape && picker) {
- setPicker(false)
- }
-
- return
- }
-
- if (completions.length && input && historyIdx === null && (key.upArrow || key.downArrow)) {
- setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length))
-
- return
- }
-
- if (key.wheelUp) {
- scrollWithSelection(-WHEEL_SCROLL_STEP)
-
- return
- }
-
- if (key.wheelDown) {
- scrollWithSelection(WHEEL_SCROLL_STEP)
-
- return
- }
-
- if (key.pageUp || key.pageDown) {
- const viewport = scrollRef.current?.getViewportHeight() ?? Math.max(6, (stdout?.rows ?? 24) - 8)
- const step = Math.max(4, viewport - 2)
- scrollWithSelection(key.pageUp ? -step : step)
-
- return
- }
-
- if (key.tab && completions.length) {
- const row = completions[compIdx]
-
- if (row?.text) {
- const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text
- setInput(input.slice(0, compReplace) + 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 (hasSelection) {
- const copied = selection.copySelection()
-
- if (copied) {
- sys('copied selection')
- }
- } else 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()
- clearReasoning()
- 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')) {
- if (guardBusySessionSwitch()) {
- return
- }
-
- setStatus('forging session…')
- newSession()
-
- return
- }
-
- if (ctrl(key, ch, 'b')) {
- if (voiceRecording) {
- setVoiceRecording(false)
- setVoiceProcessing(true)
- rpc('voice.record', { action: 'stop' })
- .then((r: any) => {
- if (!r) {
- return
- }
-
- const transcript = String(r?.text || '').trim()
-
- if (transcript) {
- setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript))
- } else {
- sys('voice: no speech detected')
- }
- })
- .catch((e: Error) => sys(`voice error: ${e.message}`))
- .finally(() => {
- setVoiceProcessing(false)
- setStatus('ready')
- })
- } else {
- rpc('voice.record', { action: 'start' })
- .then(r => {
- if (!r) {
- return
- }
-
- setVoiceRecording(true)
- setStatus('recording…')
- })
- .catch((e: Error) => sys(`voice error: ${e.message}`))
- }
-
- return
- }
-
- if (ctrl(key, ch, 'g')) {
- return openEditor()
- }
+ const { pagerPageSize } = useInputHandlers({
+ actions: {
+ answerClarify,
+ appendMessage,
+ die,
+ dispatchSubmission,
+ guardBusySessionSwitch,
+ newSession,
+ sys
+ },
+ composer: {
+ actions: composerActions,
+ refs: composerRefs,
+ state: composerState
+ },
+ gateway,
+ terminal: {
+ hasSelection,
+ scrollRef,
+ scrollWithSelection,
+ selection,
+ stdout
+ },
+ turn: {
+ actions: turnActions,
+ refs: turnRefs
+ },
+ voice: {
+ recording: voiceRecording,
+ setProcessing: setVoiceProcessing,
+ setRecording: setVoiceRecording
+ },
+ wheelStep: WHEEL_SCROLL_STEP
})
// ── Gateway events ───────────────────────────────────────────────
- const onEvent = useCallback(
- (ev: GatewayEvent) => {
- if (ev.session_id && sidRef.current && ev.session_id !== sidRef.current && !ev.type.startsWith('gateway.')) {
- return
- }
-
- 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 ?? '')
- )
+ const onEvent = useMemo(
+ () =>
+ createGatewayEventHandler({
+ composer: {
+ dequeue: composerActions.dequeue,
+ queueEditRef: composerRefs.queueEditRef,
+ sendQueued
+ },
+ gateway,
+ session: {
+ STARTUP_RESUME_ID,
+ colsRef,
+ newSession,
+ resetSession,
+ setCatalog
+ },
+ system: {
+ bellOnComplete,
+ stdout,
+ sys
+ },
+ transcript: {
+ appendMessage,
+ setHistoryItems,
+ setMessages
+ },
+ turn: {
+ actions: {
+ clearReasoning,
+ endReasoningPhase: turnActions.endReasoningPhase,
+ idle,
+ pruneTransient,
+ pulseReasoningStreaming: turnActions.pulseReasoningStreaming,
+ pushActivity,
+ pushTrail,
+ scheduleReasoning: turnActions.scheduleReasoning,
+ scheduleStreaming: turnActions.scheduleStreaming,
+ setActivity: turnActions.setActivity,
+ setStreaming: turnActions.setStreaming,
+ setTools: turnActions.setTools,
+ setTurnTrail: turnActions.setTurnTrail
+ },
+ refs: {
+ bufRef: turnRefs.bufRef,
+ interruptedRef: turnRefs.interruptedRef,
+ lastStatusNoteRef: turnRefs.lastStatusNoteRef,
+ persistedToolLabelsRef: turnRefs.persistedToolLabelsRef,
+ protocolWarnedRef: turnRefs.protocolWarnedRef,
+ reasoningRef: turnRefs.reasoningRef,
+ statusTimerRef: turnRefs.statusTimerRef,
+ toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef,
+ turnToolsRef: turnRefs.turnToolsRef
}
-
- rpc('commands.catalog', {})
- .then((r: any) => {
- if (!r?.pairs) {
- return
- }
-
- setCatalog({
- canon: (r.canon ?? {}) as Record,
- categories: (r.categories ?? []) as SlashCatalog['categories'],
- pairs: r.pairs as [string, string][],
- skillCount: (r.skill_count ?? 0) as number,
- sub: (r.sub ?? {}) as Record
- })
-
- if (r.warning) {
- pushActivity(String(r.warning), 'warn')
- }
- })
- .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn'))
-
- if (STARTUP_RESUME_ID) {
- setStatus('resuming…')
- gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
- .then((raw: any) => {
- const r = asRpcResult(raw)
-
- if (!r) {
- throw new Error('invalid response: session.resume')
- }
-
- 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: unknown) => {
- sys(`resume failed: ${rpcErrorMessage(e)}`)
- setStatus('forging session…')
- newSession('started a new session')
- })
- } else {
- setStatus('forging session…')
- newSession()
- }
-
- break
-
- case 'skin.changed':
- if (p) {
- setTheme(fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? ''))
- }
-
- break
-
- case 'session.info':
- setInfo(p as SessionInfo)
-
- if (p?.usage) {
- setUsage(prev => ({ ...prev, ...p.usage }))
- }
-
- break
-
- case 'thinking.delta':
- if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
- setStatus(p.text ? String(p.text) : busyRef.current ? 'running…' : 'ready')
- }
-
- break
-
- case 'message.start':
- setBusy(true)
- endReasoningPhase()
- clearReasoning()
- setActivity([])
- setTurnTrail([])
- turnToolsRef.current = []
- persistedToolLabelsRef.current.clear()
-
- break
-
- case 'status.update':
- if (p?.text) {
- setStatus(p.text)
-
- if (p.kind && p.kind !== 'status') {
- if (lastStatusNoteRef.current !== p.text) {
- lastStatusNoteRef.current = p.text
- pushActivity(
- p.text,
- p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
- )
- }
-
- if (statusTimerRef.current) {
- clearTimeout(statusTimerRef.current)
- }
-
- statusTimerRef.current = setTimeout(() => {
- statusTimerRef.current = null
- setStatus(busyRef.current ? 'running…' : 'ready')
- }, 4000)
- }
- }
-
- break
-
- case 'gateway.stderr':
- if (p?.line) {
- const line = String(p.line).slice(0, 120)
- const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn'
- pushActivity(line, tone)
- }
-
- break
-
- case 'gateway.start_timeout':
- setStatus('gateway startup timeout')
- pushActivity(
- `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`,
- 'error'
- )
-
- break
-
- case 'gateway.protocol_error':
- setStatus('protocol warning')
-
- if (statusTimerRef.current) {
- clearTimeout(statusTimerRef.current)
- }
-
- statusTimerRef.current = setTimeout(() => {
- statusTimerRef.current = null
- setStatus(busyRef.current ? 'running…' : 'ready')
- }, 4000)
-
- if (!protocolWarnedRef.current) {
- protocolWarnedRef.current = true
- pushActivity('protocol noise detected · /logs to inspect', 'warn')
- }
-
- if (p?.preview) {
- pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn')
- }
-
- break
-
- case 'reasoning.delta':
- if (p?.text) {
- reasoningRef.current += p.text
- scheduleReasoning()
- pulseReasoningStreaming()
- }
-
- 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.generating':
- if (p?.name) {
- pushTrail(`drafting ${p.name}…`)
- }
-
- break
-
- case 'tool.start':
- pruneTransient()
- endReasoningPhase()
- setTools(prev => [
- ...prev,
- { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() }
- ])
-
- break
- case 'tool.complete': {
- toolCompleteRibbonRef.current = null
- setTools(prev => {
- const done = prev.find(t => t.id === p.tool_id)
- const name = done?.name ?? p.name
- const label = toolTrailLabel(name)
-
- const line = buildToolTrailLine(
- name,
- done?.context || '',
- !!p.error,
- (p.error as string) || (p.summary as string) || ''
- )
-
- toolCompleteRibbonRef.current = { label, line }
- const remaining = prev.filter(t => t.id !== p.tool_id)
- const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line]
-
- if (!remaining.length) {
- next.push('analyzing tool output…')
- }
-
- const pruned = next.slice(-8)
- turnToolsRef.current = pruned
- setTurnTrail(pruned)
-
- return remaining
- })
-
- if (p?.inline_diff) {
- sys(p.inline_diff as string)
- }
-
- 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':
- pruneTransient()
- endReasoningPhase()
-
- if (p?.text && !interruptedRef.current) {
- buf.current = p.rendered ?? buf.current + p.text
- scheduleStreaming()
- }
-
- break
- case 'message.complete': {
- const wasInterrupted = interruptedRef.current
- const savedReasoning = reasoningRef.current.trim()
- const persisted = persistedToolLabelsRef.current
-
- const savedTools = turnToolsRef.current.filter(
- l => isToolTrailResultLine(l) && ![...persisted].some(p => sameToolTrailGroup(p, l))
- )
-
- const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart()
-
- idle()
- clearReasoning()
- setStreaming('')
-
- if (!wasInterrupted) {
- appendMessage({
- role: 'assistant',
- text: finalText,
- thinking: savedReasoning || undefined,
- tools: savedTools.length ? savedTools : undefined
- })
-
- if (bellOnComplete && stdout?.isTTY) {
- stdout.write('\x07')
- }
- }
-
- turnToolsRef.current = []
- persistedToolLabelsRef.current.clear()
- setActivity([])
-
- buf.current = ''
- setStatus('ready')
-
- if (p?.usage) {
- setUsage(p.usage)
- }
-
- if (queueEditRef.current !== null) {
- break
- }
-
- const next = dequeue()
-
- if (next) {
- sendQueued(next)
- }
-
- break
- }
-
- case 'error':
- idle()
- clearReasoning()
- turnToolsRef.current = []
- persistedToolLabelsRef.current.clear()
-
- if (statusTimerRef.current) {
- clearTimeout(statusTimerRef.current)
- statusTimerRef.current = null
- }
-
- pushActivity(String(p?.message || 'unknown error'), 'error')
- sys(`error: ${p?.message}`)
- setStatus('ready')
-
- break
- }
- },
+ }),
[
appendMessage,
bellOnComplete,
clearReasoning,
- dequeue,
- endReasoningPhase,
- gw,
+ composerActions,
+ composerRefs,
+ gateway,
+ idle,
newSession,
pruneTransient,
- pulseReasoningStreaming,
pushActivity,
pushTrail,
- rpc,
- scheduleReasoning,
- scheduleStreaming,
+ resetSession,
sendQueued,
sys,
+ turnActions,
+ turnRefs,
stdout
]
)
@@ -2041,9 +893,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const handler = (ev: GatewayEvent) => onEventRef.current(ev)
const exitHandler = () => {
- setStatus('gateway exited')
- setSid(null)
- setBusy(false)
+ patchUiState({ busy: false, sid: null, status: 'gateway exited' })
pushActivity('gateway exited · /logs to inspect', 'error')
sys('error: gateway exited')
}
@@ -2060,1073 +910,92 @@ export function App({ gw }: { gw: GatewayClient }) {
}, [gw, pushActivity, sys])
// ── Slash commands ───────────────────────────────────────────────
+ // Always current via ref — no useMemo deps duplication needed.
- const slash = useCallback(
- (cmd: string): boolean => {
- const [rawName, ...rest] = cmd.slice(1).split(/\s+/)
- const name = rawName.toLowerCase()
- const arg = rest.join(' ')
-
- switch (name) {
- case 'help': {
- const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }) => ({
- title: catName,
- rows: pairs
- }))
-
- if (catalog?.skillCount) {
- sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` })
- }
-
- sections.push({
- title: 'TUI',
- rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']]
- })
-
- sections.push({ title: 'Hotkeys', rows: HOTKEYS })
-
- panel('Commands', sections)
-
- return true
- }
-
- case 'quit':
-
- case 'exit':
-
- case 'q':
- die()
-
- return true
-
- case 'clear':
- if (guardBusySessionSwitch('switch sessions')) {
- return true
- }
-
- setStatus('forging session…')
- newSession()
-
- return true
-
- case 'new':
- if (guardBusySessionSwitch('switch sessions')) {
- return true
- }
-
- setStatus('forging session…')
- newSession('new session started')
-
- return true
-
- case 'resume':
- if (guardBusySessionSwitch('switch sessions')) {
- return true
- }
-
- if (arg) {
- resumeById(arg)
- } else {
- setPicker(true)
- }
-
- return true
-
- case 'compact':
- if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) {
- sys('usage: /compact [on|off|toggle]')
-
- return true
- }
-
- {
- const mode = arg.trim().toLowerCase()
- setCompact(current => {
- const next = mode === 'on' ? true : mode === 'off' ? false : !current
- rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
- queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`))
-
- return next
- })
- }
-
- return true
-
- case 'details':
-
- case 'detail':
- if (!arg) {
- rpc('config.get', { key: 'details_mode' })
- .then((r: any) => {
- const mode = parseDetailsMode(r?.value) ?? detailsMode
- setDetailsMode(mode)
- sys(`details: ${mode}`)
- })
- .catch(() => sys(`details: ${detailsMode}`))
-
- return true
- }
-
- {
- const mode = arg.trim().toLowerCase()
-
- if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) {
- sys('usage: /details [hidden|collapsed|expanded|cycle]')
-
- return true
- }
-
- const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(detailsMode) : (mode as DetailsMode)
- setDetailsMode(next)
- rpc('config.set', { key: 'details_mode', value: next }).catch(() => {})
- sys(`details: ${next}`)
- }
-
- return true
- case 'copy': {
- if (!arg && hasSelection) {
- const copied = selection.copySelection()
-
- if (copied) {
- sys('copied selection')
-
- return true
- }
- }
-
- const all = messages.filter(m => m.role === 'assistant')
-
- if (arg && Number.isNaN(parseInt(arg, 10))) {
- sys('usage: /copy [number]')
-
- return true
- }
-
- const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
-
- if (!target) {
- sys('nothing to copy')
-
- return true
- }
-
- writeOsc52Clipboard(target.text)
- sys('sent OSC52 copy sequence (terminal support required)')
-
- return true
- }
-
- case 'paste':
- if (!arg) {
- paste()
-
- return true
- }
-
- sys('usage: /paste')
-
- return true
- case 'logs': {
- const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
- logText ? page(logText, 'Logs') : sys('no gateway logs')
-
- return true
- }
-
- case 'statusbar':
-
- case 'sb':
- if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) {
- sys('usage: /statusbar [on|off|toggle]')
-
- return true
- }
-
- setStatusBar(current => {
- const mode = arg.trim().toLowerCase()
- const next = mode === 'on' ? true : mode === 'off' ? false : !current
- rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {})
- queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`))
-
- return next
- })
-
- 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) {
- sys('nothing to undo')
-
- return true
- }
-
- rpc('session.undo', { session_id: sid }).then((r: any) => {
- if (!r) {
- return
- }
-
- if (r.removed > 0) {
- setMessages(prev => trimLastExchange(prev))
- setHistoryItems(prev => trimLastExchange(prev))
- sys(`undid ${r.removed} messages`)
- } else {
- sys('nothing to undo')
- }
- })
-
- return true
-
- case 'retry':
- if (!lastUserMsg) {
- sys('nothing to retry')
-
- return true
- }
-
- if (sid) {
- rpc('session.undo', { session_id: sid }).then((r: any) => {
- if (!r) {
- return
- }
-
- if (r.removed <= 0) {
- sys('nothing to retry')
-
- return
- }
-
- setMessages(prev => trimLastExchange(prev))
- setHistoryItems(prev => trimLastExchange(prev))
- send(lastUserMsg)
- })
-
- return true
- }
-
- 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) => {
- if (!r?.task_id) {
- return
- }
-
- 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(r => {
- if (!r) {
- return
- }
-
- setBgTasks(prev => new Set(prev).add('btw:x'))
- sys('btw running…')
- })
-
- return true
-
- case 'model':
- if (guardBusySessionSwitch('change models')) {
- return true
- }
-
- if (!arg) {
- setModelPicker(true)
- } else {
- rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => {
- if (!r) {
- return
- }
-
- if (!r.value) {
- sys('error: invalid response: model switch')
-
- return
- }
-
- sys(`model → ${r.value}`)
- maybeWarn(r)
- setInfo(prev => (prev ? { ...prev, model: r.value } : { model: r.value, skills: {}, tools: {} }))
- })
- }
-
- return true
-
- case 'image':
- rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => {
- if (!r) {
- return
- }
-
- const meta = imageTokenMeta(r)
- sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
-
- if (r?.remainder) {
- setInput(r.remainder)
- }
- })
-
- return true
-
- case 'provider':
- gw.request('slash.exec', { command: 'provider', session_id: sid })
- .then((r: any) => {
- page(
- r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)',
- 'Provider'
- )
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
-
- return true
-
- case 'skin':
- if (arg) {
- rpc('config.set', { key: 'skin', value: arg }).then((r: any) => {
- if (!r?.value) {
- return
- }
-
- sys(`skin → ${r.value}`)
- })
- } else {
- rpc('config.get', { key: 'skin' }).then((r: any) => {
- if (!r) {
- return
- }
-
- sys(`skin: ${r.value || 'default'}`)
- })
- }
-
- return true
-
- case 'yolo':
- rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => {
- if (!r) {
- return
- }
-
- sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
- })
-
- return true
-
- case 'reasoning':
- if (!arg) {
- rpc('config.get', { key: 'reasoning' }).then((r: any) => {
- if (!r?.value) {
- return
- }
-
- sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
- })
- } else {
- rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => {
- if (!r?.value) {
- return
- }
-
- sys(`reasoning: ${r.value}`)
- })
- }
-
- return true
-
- case 'verbose':
- rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => {
- if (!r?.value) {
- return
- }
-
- sys(`verbose: ${r.value}`)
- })
-
- return true
-
- case 'personality':
- if (arg) {
- rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => {
- if (!r) {
- return
- }
-
- if (r.history_reset) {
- resetVisibleHistory(r.info ?? null)
- }
-
- sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`)
- maybeWarn(r)
- })
- } else {
- gw.request('slash.exec', { command: 'personality', session_id: sid })
- .then((r: any) => {
- panel('Personality', [
- {
- text: r?.warning
- ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}`
- : r?.output || '(no output)'
- }
- ])
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
- }
-
- return true
-
- case 'compress':
- rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => {
- if (!r) {
- return
- }
-
- if (Array.isArray(r.messages)) {
- const resumed = toTranscriptMessages(r.messages)
- setMessages(resumed)
- setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
- }
-
- if (r.info) {
- setInfo(r.info)
- }
-
- if (r.usage) {
- setUsage(prev => ({ ...prev, ...r.usage }))
- }
-
- if ((r.removed ?? 0) <= 0) {
- sys('nothing to compress')
-
- return
- }
-
- sys(`compressed ${r.removed} messages${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`)
- })
-
- return true
-
- case 'stop':
- rpc('process.stop', {}).then((r: any) => {
- if (!r) {
- return
- }
-
- sys(`killed ${r.killed ?? 0} registered process(es)`)
- })
-
- return true
-
- case 'branch':
-
- case 'fork':
- {
- const prevSid = sid
- rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => {
- if (r?.session_id) {
- void closeSession(prevSid)
- setSid(r.session_id)
- setSessionStartedAt(Date.now())
- setHistoryItems([])
- setMessages([])
- sys(`branched → ${r.title}`)
- }
- })
- }
-
- return true
-
- case 'reload-mcp':
-
- case 'reload_mcp':
- rpc('reload.mcp', { session_id: sid }).then(r => {
- if (!r) {
- return
- }
-
- sys('MCP reloaded')
- })
-
- return true
-
- case 'title':
- rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
- if (!r) {
- return
- }
-
- 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 cost =
- r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
-
- const rows: [string, string][] = [
- ['Model', r.model ?? ''],
- ['Input tokens', f(r.input)],
- ['Cache read tokens', f(r.cache_read)],
- ['Cache write tokens', f(r.cache_write)],
- ['Output tokens', f(r.output)],
- ['Total tokens', f(r.total)],
- ['API calls', f(r.calls)]
- ]
-
- if (cost) {
- rows.push(['Cost', cost])
- }
-
- const sections: PanelSection[] = [{ rows }]
-
- if (r.context_max) {
- sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` })
- }
-
- if (r.compressions) {
- sections.push({ text: `Compressions: ${r.compressions}` })
- }
-
- panel('Usage', sections)
- })
-
- return true
-
- case 'save':
- rpc('session.save', { session_id: sid }).then((r: any) => {
- if (!r?.file) {
- return
- }
-
- sys(`saved: ${r.file}`)
- })
-
- return true
-
- case 'history':
- rpc('session.history', { session_id: sid }).then((r: any) => {
- if (typeof r?.count !== 'number') {
- return
- }
-
- sys(`${r.count} messages`)
- })
-
- return true
-
- case 'profile':
- rpc('config.get', { key: 'profile' }).then((r: any) => {
- if (!r) {
- return
- }
-
- const text = r.display || r.home || '(unknown profile)'
- const lines = text.split('\n').filter(Boolean)
-
- if (lines.length <= 2) {
- panel('Profile', [{ text }])
- } else {
- page(text, 'Profile')
- }
- })
-
- return true
-
- case 'voice':
- rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => {
- if (!r) {
- return
- }
-
- setVoiceEnabled(!!r?.enabled)
- sys(`voice: ${r.enabled ? 'on' : 'off'}`)
- })
-
- return true
-
- case 'insights':
- rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => {
- if (!r) {
- return
- }
-
- panel('Insights', [
- {
- rows: [
- ['Period', `${r.days} days`],
- ['Sessions', `${r.sessions}`],
- ['Messages', `${r.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) {
- return
- }
-
- if (!r.checkpoints?.length) {
- return sys('no checkpoints')
- }
-
- panel('Checkpoints', [
- {
- rows: r.checkpoints.map(
- (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string]
- )
- }
- ])
- })
- } else {
- const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub
-
- const filePath =
- sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim()
-
- rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', {
- session_id: sid,
- hash,
- ...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
- }).then((r: any) => {
- if (!r) {
- return
- }
-
- 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) => {
- if (!r) {
- return
- }
-
- sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
- })
-
- return true
- }
-
- case 'plugins':
- rpc('plugins.list', {}).then((r: any) => {
- if (!r) {
- return
- }
-
- if (!r.plugins?.length) {
- return sys('no plugins')
- }
-
- panel('Plugins', [
- {
- items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`)
- }
- ])
- })
-
- return true
- case 'skills': {
- const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean)
-
- if (!sub || sub === 'list') {
- rpc('skills.manage', { action: 'list' }).then((r: any) => {
- if (!r) {
- return
- }
-
- const sk = r.skills as Record | undefined
-
- if (!sk || !Object.keys(sk).length) {
- return sys('no skills installed')
- }
-
- panel(
- 'Installed Skills',
- Object.entries(sk).map(([cat, names]) => ({
- title: cat,
- items: names as string[]
- }))
- )
- })
-
- return true
- }
-
- if (sub === 'browse') {
- const pg = parseInt(sArgs[0] ?? '1', 10) || 1
- rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => {
- if (!r) {
- return
- }
-
- if (!r.items?.length) {
- return sys('no skills found in the hub')
- }
-
- const sections: PanelSection[] = [
- {
- rows: r.items.map(
- (s: any) =>
- [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [
- string,
- string
- ]
- )
- }
- ]
-
- if (r.page < r.total_pages) {
- sections.push({ text: `/skills browse ${r.page + 1} → next page` })
- }
-
- if (r.page > 1) {
- sections.push({ text: `/skills browse ${r.page - 1} → prev page` })
- }
-
- panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections)
- })
-
- return true
- }
-
- gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
- .then((r: any) => {
- sys(
- r?.warning
- ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}`
- : r?.output || '/skills: no output'
- )
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
-
- return true
- }
-
- case 'agents':
-
- case 'tasks':
- rpc('agents.list', {})
- .then((r: any) => {
- if (!r) {
- return
- }
-
- const procs = r.processes ?? []
- const running = procs.filter((p: any) => p.status === 'running')
- const finished = procs.filter((p: any) => p.status !== 'running')
- const sections: PanelSection[] = []
-
- if (running.length) {
- sections.push({
- title: `Running (${running.length})`,
- rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command])
- })
- }
-
- if (finished.length) {
- sections.push({
- title: `Finished (${finished.length})`,
- rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command])
- })
- }
-
- if (!sections.length) {
- sections.push({ text: 'No active processes' })
- }
-
- panel('Agents', sections)
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
-
- return true
-
- case 'cron':
- if (!arg || arg === 'list') {
- rpc('cron.manage', { action: 'list' })
- .then((r: any) => {
- if (!r) {
- return
- }
-
- const jobs = r.jobs ?? []
-
- if (!jobs.length) {
- return sys('no scheduled jobs')
- }
-
- panel('Cron', [
- {
- rows: jobs.map(
- (j: any) =>
- [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string]
- )
- }
- ])
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
- } else {
- gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
- .then((r: any) => {
- sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)')
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
- }
-
- return true
-
- case 'config':
- rpc('config.show', {})
- .then((r: any) => {
- if (!r) {
- return
- }
-
- panel(
- 'Config',
- (r.sections ?? []).map((s: any) => ({
- title: s.title,
- rows: s.rows
- }))
- )
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
-
- return true
-
- case 'tools':
- rpc('tools.list', { session_id: sid })
- .then((r: any) => {
- if (!r) {
- return
- }
-
- if (!r.toolsets?.length) {
- return sys('no tools')
- }
-
- panel(
- 'Tools',
- r.toolsets.map((ts: any) => ({
- title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
- items: ts.tools
- }))
- )
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
-
- return true
-
- case 'toolsets':
- rpc('toolsets.list', { session_id: sid })
- .then((r: any) => {
- if (!r) {
- return
- }
-
- if (!r.toolsets?.length) {
- return sys('no toolsets')
- }
-
- panel('Toolsets', [
- {
- rows: r.toolsets.map(
- (ts: any) =>
- [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [
- string,
- string
- ]
- )
- }
- ])
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
-
- return true
-
- default:
- gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
- .then((r: any) => {
- sys(
- r?.warning
- ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}`
- : r?.output || `/${name}: no output`
- )
- })
- .catch(() => {
- gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid })
- .then((raw: any) => {
- const d = asRpcResult(raw)
-
- if (!d?.type) {
- sys('error: invalid response: command.dispatch')
-
- return
- }
-
- 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}`)
-
- if (typeof d.message === 'string' && d.message.trim()) {
- send(d.message)
- } else {
- sys(`/${name}: skill payload missing message`)
- }
- }
- })
- .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
- })
-
- return true
- }
- },
- [
- catalog,
- compact,
- detailsMode,
- guardBusySessionSwitch,
- gw,
+ slashRef.current = createSlashHandler({
+ composer: {
+ enqueue: composerActions.enqueue,
hasSelection,
+ paste,
+ queueRef: composerRefs.queueRef,
+ selection,
+ setInput: composerActions.setInput
+ },
+ gateway,
+ local: {
+ catalog,
lastUserMsg,
maybeWarn,
- messages,
+ messages
+ },
+ session: {
+ closeSession,
+ die,
+ guardBusySessionSwitch,
newSession,
+ resetVisibleHistory,
+ resumeById,
+ setSessionStartedAt
+ },
+ transcript: {
page,
panel,
- pushActivity,
- rpc,
- resetVisibleHistory,
- selection,
send,
- sid,
- statusBar,
- sys
- ]
- )
-
- slashRef.current = slash
+ setHistoryItems,
+ setMessages,
+ sys,
+ trimLastExchange
+ },
+ voice: {
+ setVoiceEnabled
+ }
+ })
// ── Submit ───────────────────────────────────────────────────────
const submit = useCallback(
(value: string) => {
- if (value.startsWith('/') && completions.length) {
- const row = completions[compIdx]
+ if (value.startsWith('/') && composerState.completions.length) {
+ const row = composerState.completions[composerState.compIdx]
if (row?.text) {
const text =
- value.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text
+ value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0
+ ? row.text.slice(1)
+ : row.text
- const next = value.slice(0, compReplace) + text
+ const next = value.slice(0, composerState.compReplace) + text
if (next !== value) {
- setInput(next)
+ composerActions.setInput(next)
return
}
}
}
- if (!value.trim() && !inputBuf.length) {
+ if (!value.trim() && !composerState.inputBuf.length) {
+ const live = getUiState()
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()
- clearReasoning()
- setActivity([])
- turnToolsRef.current = []
- setStatus('interrupted')
-
- if (statusTimerRef.current) {
- clearTimeout(statusTimerRef.current)
- }
-
- statusTimerRef.current = setTimeout(() => {
- statusTimerRef.current = null
- setStatus('ready')
- }, 1500)
+ if (dbl && live.busy && live.sid) {
+ turnActions.interruptTurn({
+ appendMessage,
+ gw,
+ sid: live.sid,
+ sys
+ })
return
}
- if (dbl && queueRef.current.length) {
- const next = dequeue()
+ if (dbl && composerRefs.queueRef.current.length) {
+ const next = composerActions.dequeue()
- if (next && sid) {
- setQueueEdit(null)
+ if (next && live.sid) {
+ composerActions.setQueueEdit(null)
dispatchSubmission(next)
}
}
@@ -3137,332 +1006,165 @@ export function App({ gw }: { gw: GatewayClient }) {
lastEmptyAt.current = 0
if (value.endsWith('\\')) {
- setInputBuf(prev => [...prev, value.slice(0, -1)])
- setInput('')
+ composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)])
+ composerActions.setInput('')
return
}
- dispatchSubmission([...inputBuf, value].join('\n'))
+ dispatchSubmission([...composerState.inputBuf, value].join('\n'))
},
- [compIdx, compReplace, completions, dequeue, dispatchSubmission, inputBuf, sid]
+ [
+ appendMessage,
+ composerActions,
+ composerRefs,
+ composerState,
+ dispatchSubmission,
+ gw,
+ sys,
+ turnActions
+ ]
)
+ submitRef.current = submit
+
// ── Derived ──────────────────────────────────────────────────────
const statusColor =
- status === 'ready'
- ? theme.color.ok
- : status.startsWith('error')
- ? theme.color.error
- : status === 'interrupted'
- ? theme.color.warn
- : theme.color.dim
+ ui.status === 'ready'
+ ? ui.theme.color.ok
+ : ui.status.startsWith('error')
+ ? ui.theme.color.error
+ : ui.status === 'interrupted'
+ ? ui.theme.color.warn
+ : ui.theme.color.dim
- const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : ''
+ const durationLabel = ui.sid ? fmtDuration(clockNow - sessionStartedAt) : ''
const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
- const cwdLabel = shortCwd(info?.cwd || process.env.HERMES_CWD || process.cwd())
- const showStreamingArea = Boolean(streaming)
- const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end)
+ const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd())
+ const showStreamingArea = Boolean(turnState.streaming)
const showStickyPrompt = !!stickyPrompt
- const hasReasoning = Boolean(reasoning.trim())
+ const hasReasoning = Boolean(turnState.reasoning.trim())
const showProgressArea =
- detailsMode === 'hidden'
- ? activity.some(i => i.tone !== 'info')
- : Boolean(busy || tools.length || turnTrail.length || hasReasoning || activity.length)
+ ui.detailsMode === 'hidden'
+ ? turnState.activity.some(item => item.tone !== 'info')
+ : Boolean(
+ ui.busy || turnState.tools.length || turnState.turnTrail.length || hasReasoning || turnState.activity.length
+ )
+
+ const answerApproval = useCallback(
+ (choice: string) => {
+ rpc('approval.respond', { choice, session_id: ui.sid }).then(r => {
+ if (!r) {
+ return
+ }
+
+ patchOverlayState({ approval: null })
+ sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
+ patchUiState({ status: 'running…' })
+ })
+ },
+ [rpc, sys, ui.sid]
+ )
+
+ const answerSudo = useCallback(
+ (pw: string) => {
+ if (!overlay.sudo) {
+ return
+ }
+
+ rpc('sudo.respond', { request_id: overlay.sudo.requestId, password: pw }).then(r => {
+ if (!r) {
+ return
+ }
+
+ patchOverlayState({ sudo: null })
+ patchUiState({ status: 'running…' })
+ })
+ },
+ [overlay.sudo, rpc]
+ )
+
+ const answerSecret = useCallback(
+ (value: string) => {
+ if (!overlay.secret) {
+ return
+ }
+
+ rpc('secret.respond', { request_id: overlay.secret.requestId, value }).then(r => {
+ if (!r) {
+ return
+ }
+
+ patchOverlayState({ secret: null })
+ patchUiState({ status: 'running…' })
+ })
+ },
+ [overlay.secret, rpc]
+ )
+
+ const onModelSelect = useCallback((value: string) => {
+ patchOverlayState({ modelPicker: false })
+ slashRef.current(`/model ${value}`)
+ }, [])
// ── Render ───────────────────────────────────────────────────────
return (
-
-
-
-
-
- {virtualHistory.topSpacer > 0 ? : null}
-
- {visibleHistory.map(row => (
-
- {row.msg.kind === 'intro' && row.msg.info ? (
-
-
-
-
- ) : row.msg.kind === 'panel' && row.msg.panelData ? (
-
- ) : (
-
- )}
-
- ))}
-
- {virtualHistory.bottomSpacer > 0 ? : null}
-
- {showProgressArea && (
-
- )}
-
- {showStreamingArea && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {bgTasks.size > 0 && (
-
- {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running
-
- )}
-
- {showStickyPrompt ? (
-
- ↳
- {stickyPrompt}
-
- ) : (
-
- )}
-
-
- {statusBar && (
-
- )}
-
- {(clarify || approval || sudo || secret || picker || modelPicker || pager || completions.length > 0) && (
-
- {clarify && (
-
- answerClarify('')}
- req={clarify}
- t={theme}
- />
-
- )}
-
- {approval && (
-
- {
- rpc('approval.respond', { choice, session_id: sid }).then(r => {
- if (!r) {
- return
- }
-
- setApproval(null)
- sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
- setStatus('running…')
- })
- }}
- req={approval}
- t={theme}
- />
-
- )}
-
- {sudo && (
-
- {
- rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => {
- if (!r) {
- return
- }
-
- setSudo(null)
- setStatus('running…')
- })
- }}
- t={theme}
- />
-
- )}
-
- {secret && (
-
- {
- rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => {
- if (!r) {
- return
- }
-
- setSecret(null)
- setStatus('running…')
- })
- }}
- sub={`for ${secret.envVar}`}
- t={theme}
- />
-
- )}
-
- {picker && (
-
- setPicker(false)} onSelect={resumeById} t={theme} />
-
- )}
-
- {modelPicker && (
-
- setModelPicker(false)}
- onSelect={value => {
- setModelPicker(false)
- slash(`/model ${value}`)
- }}
- sessionId={sid}
- t={theme}
- />
-
- )}
-
- {pager && (
-
-
- {pager.title && (
-
-
- {pager.title}
-
-
- )}
-
- {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
- {line}
- ))}
-
-
-
- {pager.offset + pagerPageSize < pager.lines.length
- ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})`
- : `end · q to close (${pager.lines.length} lines)`}
-
-
-
-
- )}
-
- {!!completions.length && (
-
-
- {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => {
- const active = Math.max(0, compIdx - 8) + i === compIdx
-
- const bg = active ? theme.color.dim : undefined
- const fg = theme.color.cornsilk
-
- return (
-
-
- {' '}
- {item.display}
-
-
- {item.meta ? (
-
- {' '}
- {item.meta}
-
- ) : null}
-
- )
- })}
-
-
- )}
-
- )}
-
-
- {!isBlocked && (
-
- {inputBuf.map((line, i) => (
-
-
- {i === 0 ? `${theme.brand.prompt} ` : ' '}
-
-
- {line || ' '}
-
- ))}
-
-
-
-
- {inputBuf.length ? ' ' : `${theme.brand.prompt} `}
-
-
-
-
-
-
- )}
-
- {!empty && !sid && ⚕ {status}}
-
-
-
+
+
+
)
}
diff --git a/ui-tui/src/app/constants.ts b/ui-tui/src/app/constants.ts
new file mode 100644
index 000000000..335e58d82
--- /dev/null
+++ b/ui-tui/src/app/constants.ts
@@ -0,0 +1,15 @@
+import { PLACEHOLDERS } from '../constants.js'
+import { pick } from '../lib/text.js'
+
+export const PLACEHOLDER = pick(PLACEHOLDERS)
+export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
+
+export const LARGE_PASTE = { chars: 8000, lines: 80 }
+export const MAX_HISTORY = 800
+export const REASONING_PULSE_MS = 700
+export const STREAM_BATCH_MS = 16
+export const WHEEL_SCROLL_STEP = 3
+export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test(
+ (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()
+)
+export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g
diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts
new file mode 100644
index 000000000..8c3158017
--- /dev/null
+++ b/ui-tui/src/app/createGatewayEventHandler.ts
@@ -0,0 +1,487 @@
+import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
+
+import type { GatewayEvent } from '../gatewayClient.js'
+import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
+import { buildToolTrailLine, isToolTrailResultLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
+import { fromSkin } from '../theme.js'
+import type { Msg, SlashCatalog } from '../types.js'
+
+import { introMsg, toTranscriptMessages } from './helpers.js'
+import type { GatewayServices } from './interfaces.js'
+import { patchOverlayState } from './overlayStore.js'
+import { getUiState, patchUiState } from './uiStore.js'
+import type { TurnActions, TurnRefs } from './useTurnState.js'
+
+export interface GatewayEventHandlerContext {
+ composer: {
+ dequeue: () => string | undefined
+ queueEditRef: MutableRefObject
+ sendQueued: (text: string) => void
+ }
+ gateway: GatewayServices
+ session: {
+ STARTUP_RESUME_ID: string
+ colsRef: MutableRefObject
+ newSession: (msg?: string) => void
+ resetSession: () => void
+ setCatalog: Dispatch>
+ }
+ system: {
+ bellOnComplete: boolean
+ stdout?: NodeJS.WriteStream
+ sys: (text: string) => void
+ }
+ transcript: {
+ appendMessage: (msg: Msg) => void
+ setHistoryItems: Dispatch>
+ setMessages: Dispatch>
+ }
+ turn: {
+ actions: Pick<
+ TurnActions,
+ | 'clearReasoning'
+ | 'endReasoningPhase'
+ | 'idle'
+ | 'pruneTransient'
+ | 'pulseReasoningStreaming'
+ | 'pushActivity'
+ | 'pushTrail'
+ | 'scheduleReasoning'
+ | 'scheduleStreaming'
+ | 'setActivity'
+ | 'setStreaming'
+ | 'setTools'
+ | 'setTurnTrail'
+ >
+ refs: Pick<
+ TurnRefs,
+ | 'bufRef'
+ | 'interruptedRef'
+ | 'lastStatusNoteRef'
+ | 'persistedToolLabelsRef'
+ | 'protocolWarnedRef'
+ | 'reasoningRef'
+ | 'statusTimerRef'
+ | 'toolCompleteRibbonRef'
+ | 'turnToolsRef'
+ >
+ }
+}
+
+export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
+ const { dequeue, queueEditRef, sendQueued } = ctx.composer
+ const { gw, rpc } = ctx.gateway
+ const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session
+ const { bellOnComplete, stdout, sys } = ctx.system
+ const { appendMessage, setHistoryItems, setMessages } = ctx.transcript
+
+ const {
+ clearReasoning,
+ endReasoningPhase,
+ idle,
+ pruneTransient,
+ pulseReasoningStreaming,
+ pushActivity,
+ pushTrail,
+ scheduleReasoning,
+ scheduleStreaming,
+ setActivity,
+ setStreaming,
+ setTools,
+ setTurnTrail
+ } = ctx.turn.actions
+
+ const {
+ bufRef,
+ interruptedRef,
+ lastStatusNoteRef,
+ persistedToolLabelsRef,
+ protocolWarnedRef,
+ reasoningRef,
+ statusTimerRef,
+ toolCompleteRibbonRef,
+ turnToolsRef
+ } = ctx.turn.refs
+
+ return (ev: GatewayEvent) => {
+ const sid = getUiState().sid
+
+ if (ev.session_id && sid && ev.session_id !== sid && !ev.type.startsWith('gateway.')) {
+ return
+ }
+
+ const p = ev.payload as any
+
+ switch (ev.type) {
+ case 'gateway.ready':
+ if (p?.skin) {
+ patchUiState({
+ theme: 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,
+ categories: (r.categories ?? []) as any,
+ pairs: r.pairs as [string, string][],
+ skillCount: (r.skill_count ?? 0) as number,
+ sub: (r.sub ?? {}) as Record
+ })
+
+ if (r.warning) {
+ pushActivity(String(r.warning), 'warn')
+ }
+ })
+ .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn'))
+
+ if (STARTUP_RESUME_ID) {
+ patchUiState({ status: 'resuming…' })
+ gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
+ .then((raw: any) => {
+ const r = asRpcResult(raw)
+
+ if (!r) {
+ throw new Error('invalid response: session.resume')
+ }
+
+ resetSession()
+ const resumed = toTranscriptMessages(r.messages)
+
+ patchUiState({
+ info: r.info ?? null,
+ sid: r.session_id,
+ status: 'ready',
+ usage: r.info?.usage ?? getUiState().usage
+ })
+ setMessages(resumed)
+ setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
+ })
+ .catch((e: unknown) => {
+ sys(`resume failed: ${rpcErrorMessage(e)}`)
+ patchUiState({ status: 'forging session…' })
+ newSession('started a new session')
+ })
+ } else {
+ patchUiState({ status: 'forging session…' })
+ newSession()
+ }
+
+ break
+
+ case 'skin.changed':
+ if (p) {
+ patchUiState({
+ theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')
+ })
+ }
+
+ break
+
+ case 'session.info':
+ patchUiState(state => ({
+ ...state,
+ info: p as any,
+ usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage
+ }))
+
+ break
+
+ case 'thinking.delta':
+ if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
+ patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' })
+ }
+
+ break
+
+ case 'message.start':
+ patchUiState({ busy: true })
+ endReasoningPhase()
+ clearReasoning()
+ setActivity([])
+ setTurnTrail([])
+ turnToolsRef.current = []
+ persistedToolLabelsRef.current.clear()
+
+ break
+
+ case 'status.update':
+ if (p?.text) {
+ patchUiState({ status: p.text })
+
+ if (p.kind && p.kind !== 'status') {
+ if (lastStatusNoteRef.current !== p.text) {
+ lastStatusNoteRef.current = p.text
+ pushActivity(
+ p.text,
+ p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
+ )
+ }
+
+ if (statusTimerRef.current) {
+ clearTimeout(statusTimerRef.current)
+ }
+
+ statusTimerRef.current = setTimeout(() => {
+ statusTimerRef.current = null
+ patchUiState({ status: getUiState().busy ? 'running…' : 'ready' })
+ }, 4000)
+ }
+ }
+
+ break
+
+ case 'gateway.stderr':
+ if (p?.line) {
+ const line = String(p.line).slice(0, 120)
+ const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn'
+
+ pushActivity(line, tone)
+ }
+
+ break
+
+ case 'gateway.start_timeout':
+ patchUiState({ status: 'gateway startup timeout' })
+ pushActivity(
+ `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`,
+ 'error'
+ )
+
+ break
+
+ case 'gateway.protocol_error':
+ patchUiState({ status: 'protocol warning' })
+
+ if (statusTimerRef.current) {
+ clearTimeout(statusTimerRef.current)
+ }
+
+ statusTimerRef.current = setTimeout(() => {
+ statusTimerRef.current = null
+ patchUiState({ status: getUiState().busy ? 'running…' : 'ready' })
+ }, 4000)
+
+ if (!protocolWarnedRef.current) {
+ protocolWarnedRef.current = true
+ pushActivity('protocol noise detected · /logs to inspect', 'warn')
+ }
+
+ if (p?.preview) {
+ pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn')
+ }
+
+ break
+
+ case 'reasoning.delta':
+ if (p?.text) {
+ reasoningRef.current += p.text
+ scheduleReasoning()
+ pulseReasoningStreaming()
+ }
+
+ break
+
+ case 'tool.progress':
+ if (p?.preview) {
+ setTools(prev => {
+ const index = prev.findIndex(tool => tool.name === p.name)
+
+ return index >= 0
+ ? [...prev.slice(0, index), { ...prev[index]!, context: p.preview as string }, ...prev.slice(index + 1)]
+ : prev
+ })
+ }
+
+ break
+
+ case 'tool.generating':
+ if (p?.name) {
+ pushTrail(`drafting ${p.name}…`)
+ }
+
+ break
+
+ case 'tool.start':
+ pruneTransient()
+ endReasoningPhase()
+ setTools(prev => [
+ ...prev,
+ { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() }
+ ])
+
+ break
+ case 'tool.complete': {
+ toolCompleteRibbonRef.current = null
+ setTools(prev => {
+ const done = prev.find(tool => tool.id === p.tool_id)
+ const name = done?.name ?? p.name
+ const label = toolTrailLabel(name)
+
+ const line = buildToolTrailLine(
+ name,
+ done?.context || '',
+ !!p.error,
+ (p.error as string) || (p.summary as string) || ''
+ )
+
+ const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
+ const remaining = prev.filter(tool => tool.id !== p.tool_id)
+
+ toolCompleteRibbonRef.current = { label, line }
+
+ if (!remaining.length) {
+ next.push('analyzing tool output…')
+ }
+
+ turnToolsRef.current = next.slice(-8)
+ setTurnTrail(turnToolsRef.current)
+
+ return remaining
+ })
+
+ if (p?.inline_diff) {
+ sys(p.inline_diff as string)
+ }
+
+ break
+ }
+
+ case 'clarify.request':
+ patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } })
+ patchUiState({ status: 'waiting for input…' })
+
+ break
+
+ case 'approval.request':
+ patchOverlayState({ approval: { command: p.command, description: p.description } })
+ patchUiState({ status: 'approval needed' })
+
+ break
+
+ case 'sudo.request':
+ patchOverlayState({ sudo: { requestId: p.request_id } })
+ patchUiState({ status: 'sudo password needed' })
+
+ break
+
+ case 'secret.request':
+ patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } })
+ patchUiState({ status: 'secret input needed' })
+
+ break
+
+ case 'background.complete':
+ patchUiState(state => {
+ const next = new Set(state.bgTasks)
+
+ next.delete(p.task_id)
+
+ return { ...state, bgTasks: next }
+ })
+ sys(`[bg ${p.task_id}] ${p.text}`)
+
+ break
+
+ case 'btw.complete':
+ patchUiState(state => {
+ const next = new Set(state.bgTasks)
+
+ next.delete('btw:x')
+
+ return { ...state, bgTasks: next }
+ })
+ sys(`[btw] ${p.text}`)
+
+ break
+
+ case 'message.delta':
+ pruneTransient()
+ endReasoningPhase()
+
+ if (p?.text && !interruptedRef.current) {
+ bufRef.current = p.rendered ?? bufRef.current + p.text
+ scheduleStreaming()
+ }
+
+ break
+ case 'message.complete': {
+ const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart()
+ const persisted = persistedToolLabelsRef.current
+ const savedReasoning = reasoningRef.current.trim()
+
+ const savedTools = turnToolsRef.current.filter(
+ line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line))
+ )
+
+ const wasInterrupted = interruptedRef.current
+
+ idle()
+ clearReasoning()
+ setStreaming('')
+
+ if (!wasInterrupted) {
+ appendMessage({
+ role: 'assistant',
+ text: finalText,
+ thinking: savedReasoning || undefined,
+ tools: savedTools.length ? savedTools : undefined
+ })
+
+ if (bellOnComplete && stdout?.isTTY) {
+ stdout.write('\x07')
+ }
+ }
+
+ turnToolsRef.current = []
+ persistedToolLabelsRef.current.clear()
+ setActivity([])
+ bufRef.current = ''
+ patchUiState({ status: 'ready' })
+
+ if (p?.usage) {
+ patchUiState({ usage: p.usage })
+ }
+
+ if (queueEditRef.current !== null) {
+ break
+ }
+
+ const next = dequeue()
+
+ if (next) {
+ sendQueued(next)
+ }
+
+ break
+ }
+
+ case 'error':
+ idle()
+ clearReasoning()
+ turnToolsRef.current = []
+ persistedToolLabelsRef.current.clear()
+
+ if (statusTimerRef.current) {
+ clearTimeout(statusTimerRef.current)
+ statusTimerRef.current = null
+ }
+
+ pushActivity(String(p?.message || 'unknown error'), 'error')
+ sys(`error: ${p?.message}`)
+ patchUiState({ status: 'ready' })
+
+ break
+ }
+ }
+}
diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts
new file mode 100644
index 000000000..9f5df4ca9
--- /dev/null
+++ b/ui-tui/src/app/createSlashHandler.ts
@@ -0,0 +1,1058 @@
+import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
+
+import { HOTKEYS } from '../constants.js'
+import { writeOsc52Clipboard } from '../lib/osc52.js'
+import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
+import { fmtK } from '../lib/text.js'
+import type { DetailsMode, Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js'
+
+import { imageTokenMeta, introMsg, nextDetailsMode, parseDetailsMode, toTranscriptMessages } from './helpers.js'
+import type { GatewayServices } from './interfaces.js'
+import { patchOverlayState } from './overlayStore.js'
+import { getUiState, patchUiState } from './uiStore.js'
+
+export interface SlashHandlerContext {
+ composer: {
+ enqueue: (text: string) => void
+ hasSelection: boolean
+ paste: (quiet?: boolean) => void
+ queueRef: MutableRefObject
+ selection: {
+ copySelection: () => string
+ }
+ setInput: Dispatch>
+ }
+ gateway: GatewayServices
+ local: {
+ catalog: SlashCatalog | null
+ lastUserMsg: string
+ maybeWarn: (value: any) => void
+ messages: Msg[]
+ }
+ session: {
+ closeSession: (targetSid?: string | null) => Promise
+ die: () => void
+ guardBusySessionSwitch: (what?: string) => boolean
+ newSession: (msg?: string) => void
+ resetVisibleHistory: (info?: SessionInfo | null) => void
+ resumeById: (id: string) => void
+ setSessionStartedAt: Dispatch>
+ }
+ transcript: {
+ page: (text: string, title?: string) => void
+ panel: (title: string, sections: PanelSection[]) => void
+ send: (text: string) => void
+ setHistoryItems: Dispatch>
+ setMessages: Dispatch>
+ sys: (text: string) => void
+ trimLastExchange: (items: Msg[]) => Msg[]
+ }
+ voice: {
+ setVoiceEnabled: Dispatch>
+ }
+}
+
+export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
+ const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
+ const { gw, rpc } = ctx.gateway
+ const { catalog, lastUserMsg, maybeWarn, messages } = ctx.local
+
+ const {
+ closeSession,
+ die,
+ guardBusySessionSwitch,
+ newSession,
+ resetVisibleHistory,
+ resumeById,
+ setSessionStartedAt
+ } = ctx.session
+
+ const { page, panel, send, setHistoryItems, setMessages, sys, trimLastExchange } = ctx.transcript
+ const { setVoiceEnabled } = ctx.voice
+
+ const handler = (cmd: string): boolean => {
+ const ui = getUiState()
+ const detailsMode = ui.detailsMode
+ const sid = ui.sid
+ const [rawName, ...rest] = cmd.slice(1).split(/\s+/)
+ const name = rawName.toLowerCase()
+ const arg = rest.join(' ')
+
+ switch (name) {
+ case 'help': {
+ const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({
+ title: catName,
+ rows: pairs
+ }))
+
+ if (catalog?.skillCount) {
+ sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` })
+ }
+
+ sections.push({
+ title: 'TUI',
+ rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']]
+ })
+
+ sections.push({ title: 'Hotkeys', rows: HOTKEYS })
+
+ panel('Commands', sections)
+
+ return true
+ }
+
+ case 'quit':
+
+ case 'exit':
+
+ case 'q':
+ die()
+
+ return true
+
+ case 'clear':
+ if (guardBusySessionSwitch('switch sessions')) {
+ return true
+ }
+
+ patchUiState({ status: 'forging session…' })
+ newSession()
+
+ return true
+
+ case 'new':
+ if (guardBusySessionSwitch('switch sessions')) {
+ return true
+ }
+
+ patchUiState({ status: 'forging session…' })
+ newSession('new session started')
+
+ return true
+
+ case 'resume':
+ if (guardBusySessionSwitch('switch sessions')) {
+ return true
+ }
+
+ if (arg) {
+ resumeById(arg)
+ } else {
+ patchOverlayState({ picker: true })
+ }
+
+ return true
+
+ case 'compact':
+ if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) {
+ sys('usage: /compact [on|off|toggle]')
+
+ return true
+ }
+
+ {
+ const mode = arg.trim().toLowerCase()
+ const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact
+
+ patchUiState({ compact: next })
+ rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
+ queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`))
+ }
+
+ return true
+
+ case 'details':
+
+ case 'detail':
+ if (!arg) {
+ rpc('config.get', { key: 'details_mode' })
+ .then((r: any) => {
+ const mode = parseDetailsMode(r?.value) ?? detailsMode
+ patchUiState({ detailsMode: mode })
+ sys(`details: ${mode}`)
+ })
+ .catch(() => sys(`details: ${detailsMode}`))
+
+ return true
+ }
+
+ {
+ const mode = arg.trim().toLowerCase()
+
+ if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) {
+ sys('usage: /details [hidden|collapsed|expanded|cycle]')
+
+ return true
+ }
+
+ const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(detailsMode) : (mode as DetailsMode)
+ patchUiState({ detailsMode: next })
+ rpc('config.set', { key: 'details_mode', value: next }).catch(() => {})
+ sys(`details: ${next}`)
+ }
+
+ return true
+ case 'copy': {
+ if (!arg && hasSelection) {
+ const copied = selection.copySelection()
+
+ if (copied) {
+ sys('copied selection')
+
+ return true
+ }
+ }
+
+ const all = messages.filter((m: any) => m.role === 'assistant')
+
+ if (arg && Number.isNaN(parseInt(arg, 10))) {
+ sys('usage: /copy [number]')
+
+ return true
+ }
+
+ const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
+
+ if (!target) {
+ sys('nothing to copy')
+
+ return true
+ }
+
+ writeOsc52Clipboard(target.text)
+ sys('sent OSC52 copy sequence (terminal support required)')
+
+ return true
+ }
+
+ case 'paste':
+ if (!arg) {
+ paste()
+
+ return true
+ }
+
+ sys('usage: /paste')
+
+ return true
+ case 'logs': {
+ const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
+ logText ? page(logText, 'Logs') : sys('no gateway logs')
+
+ return true
+ }
+
+ case 'statusbar':
+
+ case 'sb':
+ if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) {
+ sys('usage: /statusbar [on|off|toggle]')
+
+ return true
+ }
+
+ {
+ const mode = arg.trim().toLowerCase()
+ const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar
+
+ patchUiState({ statusBar: next })
+ rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {})
+ queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`))
+ }
+
+ 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) {
+ sys('nothing to undo')
+
+ return true
+ }
+
+ rpc('session.undo', { session_id: sid }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (r.removed > 0) {
+ setMessages((prev: any[]) => trimLastExchange(prev))
+ setHistoryItems((prev: any[]) => trimLastExchange(prev))
+ sys(`undid ${r.removed} messages`)
+ } else {
+ sys('nothing to undo')
+ }
+ })
+
+ return true
+
+ case 'retry':
+ if (!lastUserMsg) {
+ sys('nothing to retry')
+
+ return true
+ }
+
+ if (sid) {
+ rpc('session.undo', { session_id: sid }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (r.removed <= 0) {
+ sys('nothing to retry')
+
+ return
+ }
+
+ setMessages((prev: any[]) => trimLastExchange(prev))
+ setHistoryItems((prev: any[]) => trimLastExchange(prev))
+ send(lastUserMsg)
+ })
+
+ return true
+ }
+
+ 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) => {
+ if (!r?.task_id) {
+ return
+ }
+
+ patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).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((r: any) => {
+ if (!r) {
+ return
+ }
+
+ patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') }))
+ sys('btw running…')
+ })
+
+ return true
+
+ case 'model':
+ if (guardBusySessionSwitch('change models')) {
+ return true
+ }
+
+ if (!arg) {
+ patchOverlayState({ modelPicker: true })
+ } else {
+ rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (!r.value) {
+ sys('error: invalid response: model switch')
+
+ return
+ }
+
+ sys(`model → ${r.value}`)
+ maybeWarn(r)
+ patchUiState(state => ({
+ ...state,
+ info: state.info ? { ...state.info, model: r.value } : { model: r.value, skills: {}, tools: {} }
+ }))
+ })
+ }
+
+ return true
+
+ case 'image':
+ rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ const meta = imageTokenMeta(r)
+ sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
+
+ if (r?.remainder) {
+ setInput(r.remainder)
+ }
+ })
+
+ return true
+
+ case 'provider':
+ gw.request('slash.exec', { command: 'provider', session_id: sid })
+ .then((r: any) => {
+ page(
+ r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)',
+ 'Provider'
+ )
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+
+ return true
+
+ case 'skin':
+ if (arg) {
+ rpc('config.set', { key: 'skin', value: arg }).then((r: any) => {
+ if (!r?.value) {
+ return
+ }
+
+ sys(`skin → ${r.value}`)
+ })
+ } else {
+ rpc('config.get', { key: 'skin' }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ sys(`skin: ${r.value || 'default'}`)
+ })
+ }
+
+ return true
+
+ case 'yolo':
+ rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
+ })
+
+ return true
+
+ case 'reasoning':
+ if (!arg) {
+ rpc('config.get', { key: 'reasoning' }).then((r: any) => {
+ if (!r?.value) {
+ return
+ }
+
+ sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
+ })
+ } else {
+ rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => {
+ if (!r?.value) {
+ return
+ }
+
+ sys(`reasoning: ${r.value}`)
+ })
+ }
+
+ return true
+
+ case 'verbose':
+ rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => {
+ if (!r?.value) {
+ return
+ }
+
+ sys(`verbose: ${r.value}`)
+ })
+
+ return true
+
+ case 'personality':
+ if (arg) {
+ rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (r.history_reset) {
+ resetVisibleHistory(r.info ?? null)
+ }
+
+ sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`)
+ maybeWarn(r)
+ })
+ } else {
+ gw.request('slash.exec', { command: 'personality', session_id: sid })
+ .then((r: any) => {
+ panel('Personality', [
+ {
+ text: r?.warning
+ ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}`
+ : r?.output || '(no output)'
+ }
+ ])
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+ }
+
+ return true
+
+ case 'compress':
+ rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (Array.isArray(r.messages)) {
+ const resumed = toTranscriptMessages(r.messages)
+ setMessages(resumed)
+ setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
+ }
+
+ if (r.info) {
+ patchUiState({ info: r.info })
+ }
+
+ if (r.usage) {
+ patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } }))
+ }
+
+ if ((r.removed ?? 0) <= 0) {
+ sys('nothing to compress')
+
+ return
+ }
+
+ sys(`compressed ${r.removed} messages${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`)
+ })
+
+ return true
+
+ case 'stop':
+ rpc('process.stop', {}).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ sys(`killed ${r.killed ?? 0} registered process(es)`)
+ })
+
+ return true
+
+ case 'branch':
+ case 'fork': {
+ const prevSid = sid
+ rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => {
+ if (r?.session_id) {
+ void closeSession(prevSid)
+ patchUiState({ sid: r.session_id })
+ setSessionStartedAt(Date.now())
+ setHistoryItems([])
+ setMessages([])
+ sys(`branched → ${r.title}`)
+ }
+ })
+
+ return true
+ }
+
+ case 'reload-mcp':
+
+ case 'reload_mcp':
+ rpc('reload.mcp', { session_id: sid }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ sys('MCP reloaded')
+ })
+
+ return true
+
+ case 'title':
+ rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ sys(`title: ${r.title || '(none)'}`)
+ })
+
+ return true
+
+ case 'usage':
+ rpc('session.usage', { session_id: sid }).then((r: any) => {
+ if (r) {
+ patchUiState({
+ usage: { 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 cost =
+ r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
+
+ const rows: [string, string][] = [
+ ['Model', r.model ?? ''],
+ ['Input tokens', f(r.input)],
+ ['Cache read tokens', f(r.cache_read)],
+ ['Cache write tokens', f(r.cache_write)],
+ ['Output tokens', f(r.output)],
+ ['Total tokens', f(r.total)],
+ ['API calls', f(r.calls)]
+ ]
+
+ if (cost) {
+ rows.push(['Cost', cost])
+ }
+
+ const sections: PanelSection[] = [{ rows }]
+
+ if (r.context_max) {
+ sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` })
+ }
+
+ if (r.compressions) {
+ sections.push({ text: `Compressions: ${r.compressions}` })
+ }
+
+ panel('Usage', sections)
+ })
+
+ return true
+
+ case 'save':
+ rpc('session.save', { session_id: sid }).then((r: any) => {
+ if (!r?.file) {
+ return
+ }
+
+ sys(`saved: ${r.file}`)
+ })
+
+ return true
+
+ case 'history':
+ rpc('session.history', { session_id: sid }).then((r: any) => {
+ if (typeof r?.count !== 'number') {
+ return
+ }
+
+ sys(`${r.count} messages`)
+ })
+
+ return true
+
+ case 'profile':
+ rpc('config.get', { key: 'profile' }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ const text = r.display || r.home || '(unknown profile)'
+ const lines = text.split('\n').filter(Boolean)
+
+ if (lines.length <= 2) {
+ panel('Profile', [{ text }])
+ } else {
+ page(text, 'Profile')
+ }
+ })
+
+ return true
+
+ case 'voice':
+ rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ setVoiceEnabled(!!r?.enabled)
+ sys(`voice: ${r.enabled ? 'on' : 'off'}`)
+ })
+
+ return true
+
+ case 'insights':
+ rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ panel('Insights', [
+ {
+ rows: [
+ ['Period', `${r.days} days`],
+ ['Sessions', `${r.sessions}`],
+ ['Messages', `${r.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) {
+ return
+ }
+
+ if (!r.checkpoints?.length) {
+ return sys('no checkpoints')
+ }
+
+ panel('Checkpoints', [
+ {
+ rows: r.checkpoints.map(
+ (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string]
+ )
+ }
+ ])
+ })
+ } else {
+ const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub
+
+ const filePath =
+ sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim()
+
+ rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', {
+ session_id: sid,
+ hash,
+ ...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
+ }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ 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) => {
+ if (!r) {
+ return
+ }
+
+ sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
+ })
+
+ return true
+ }
+
+ case 'plugins':
+ rpc('plugins.list', {}).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (!r.plugins?.length) {
+ return sys('no plugins')
+ }
+
+ panel('Plugins', [
+ {
+ items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`)
+ }
+ ])
+ })
+
+ return true
+ case 'skills': {
+ const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean)
+
+ if (!sub || sub === 'list') {
+ rpc('skills.manage', { action: 'list' }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ const sk = r.skills as Record | undefined
+
+ if (!sk || !Object.keys(sk).length) {
+ return sys('no skills installed')
+ }
+
+ panel(
+ 'Installed Skills',
+ Object.entries(sk).map(([cat, names]) => ({
+ title: cat,
+ items: names as string[]
+ }))
+ )
+ })
+
+ return true
+ }
+
+ if (sub === 'browse') {
+ const pg = parseInt(sArgs[0] ?? '1', 10) || 1
+ rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (!r.items?.length) {
+ return sys('no skills found in the hub')
+ }
+
+ const sections: PanelSection[] = [
+ {
+ rows: r.items.map(
+ (s: any) =>
+ [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [
+ string,
+ string
+ ]
+ )
+ }
+ ]
+
+ if (r.page < r.total_pages) {
+ sections.push({ text: `/skills browse ${r.page + 1} → next page` })
+ }
+
+ if (r.page > 1) {
+ sections.push({ text: `/skills browse ${r.page - 1} → prev page` })
+ }
+
+ panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections)
+ })
+
+ return true
+ }
+
+ gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
+ .then((r: any) => {
+ sys(
+ r?.warning
+ ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}`
+ : r?.output || '/skills: no output'
+ )
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+
+ return true
+ }
+
+ case 'agents':
+
+ case 'tasks':
+ rpc('agents.list', {})
+ .then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ const procs = r.processes ?? []
+ const running = procs.filter((p: any) => p.status === 'running')
+ const finished = procs.filter((p: any) => p.status !== 'running')
+ const sections: PanelSection[] = []
+
+ if (running.length) {
+ sections.push({
+ title: `Running (${running.length})`,
+ rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command])
+ })
+ }
+
+ if (finished.length) {
+ sections.push({
+ title: `Finished (${finished.length})`,
+ rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command])
+ })
+ }
+
+ if (!sections.length) {
+ sections.push({ text: 'No active processes' })
+ }
+
+ panel('Agents', sections)
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+
+ return true
+
+ case 'cron':
+ if (!arg || arg === 'list') {
+ rpc('cron.manage', { action: 'list' })
+ .then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ const jobs = r.jobs ?? []
+
+ if (!jobs.length) {
+ return sys('no scheduled jobs')
+ }
+
+ panel('Cron', [
+ {
+ rows: jobs.map(
+ (j: any) =>
+ [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string]
+ )
+ }
+ ])
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+ } else {
+ gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
+ .then((r: any) => {
+ sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)')
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+ }
+
+ return true
+
+ case 'config':
+ rpc('config.show', {})
+ .then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ panel(
+ 'Config',
+ (r.sections ?? []).map((s: any) => ({
+ title: s.title,
+ rows: s.rows
+ }))
+ )
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+
+ return true
+
+ case 'tools':
+ rpc('tools.list', { session_id: sid })
+ .then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (!r.toolsets?.length) {
+ return sys('no tools')
+ }
+
+ panel(
+ 'Tools',
+ r.toolsets.map((ts: any) => ({
+ title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
+ items: ts.tools
+ }))
+ )
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+
+ return true
+
+ case 'toolsets':
+ rpc('toolsets.list', { session_id: sid })
+ .then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ if (!r.toolsets?.length) {
+ return sys('no toolsets')
+ }
+
+ panel('Toolsets', [
+ {
+ rows: r.toolsets.map(
+ (ts: any) =>
+ [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [
+ string,
+ string
+ ]
+ )
+ }
+ ])
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+
+ return true
+
+ default:
+ gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
+ .then((r: any) => {
+ sys(
+ r?.warning
+ ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}`
+ : r?.output || `/${name}: no output`
+ )
+ })
+ .catch(() => {
+ gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid })
+ .then((raw: any) => {
+ const d = asRpcResult(raw)
+
+ if (!d?.type) {
+ sys('error: invalid response: command.dispatch')
+
+ return
+ }
+
+ if (d.type === 'exec') {
+ sys(d.output || '(no output)')
+ } else if (d.type === 'alias') {
+ handler(`/${d.target}${arg ? ' ' + arg : ''}`)
+ } else if (d.type === 'plugin') {
+ sys(d.output || '(no output)')
+ } else if (d.type === 'skill') {
+ sys(`⚡ loading skill: ${d.name}`)
+
+ if (typeof d.message === 'string' && d.message.trim()) {
+ send(d.message)
+ } else {
+ sys(`/${name}: skill payload missing message`)
+ }
+ }
+ })
+ .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
+ })
+
+ return true
+ }
+ }
+
+ return handler
+}
diff --git a/ui-tui/src/app/gatewayContext.tsx b/ui-tui/src/app/gatewayContext.tsx
new file mode 100644
index 000000000..cdd9347fb
--- /dev/null
+++ b/ui-tui/src/app/gatewayContext.tsx
@@ -0,0 +1,24 @@
+import { createContext, type ReactNode, useContext } from 'react'
+
+import type { GatewayServices } from './interfaces.js'
+
+const GatewayContext = createContext(null)
+
+export interface GatewayProviderProps {
+ children: ReactNode
+ value: GatewayServices
+}
+
+export function GatewayProvider({ children, value }: GatewayProviderProps) {
+ return {children}
+}
+
+export function useGateway() {
+ const value = useContext(GatewayContext)
+
+ if (!value) {
+ throw new Error('GatewayContext missing')
+ }
+
+ return value
+}
diff --git a/ui-tui/src/app/helpers.ts b/ui-tui/src/app/helpers.ts
new file mode 100644
index 000000000..350687d74
--- /dev/null
+++ b/ui-tui/src/app/helpers.ts
@@ -0,0 +1,167 @@
+import { buildToolTrailLine, fmtK, userDisplay } from '../lib/text.js'
+import type { DetailsMode, Msg, SessionInfo } from '../types.js'
+
+const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
+
+export interface PasteSnippet {
+ label: string
+ text: string
+}
+
+export const parseDetailsMode = (v: unknown): DetailsMode | null => {
+ const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
+
+ return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
+}
+
+export const resolveDetailsMode = (d: any): DetailsMode =>
+ parseDetailsMode(d?.details_mode) ??
+ { full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
+ String(d?.thinking_mode ?? '')
+ .trim()
+ .toLowerCase()
+ ] ??
+ 'collapsed'
+
+export const nextDetailsMode = (m: DetailsMode): DetailsMode =>
+ DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
+
+export const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info })
+
+export const shortCwd = (cwd: string, max = 28) => {
+ const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd
+
+ return p.length <= max ? p : `…${p.slice(-(max - 1))}`
+}
+
+export const imageTokenMeta = (
+ info: { height?: number; token_estimate?: number; width?: number } | null | undefined
+) => {
+ const dims = info?.width && info?.height ? `${info.width}x${info.height}` : ''
+
+ const tok =
+ typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
+
+ return [dims, tok].filter(Boolean).join(' · ')
+}
+
+export const looksLikeSlashCommand = (text: string) => {
+ if (!text.startsWith('/')) {
+ return false
+ }
+
+ const first = text.split(/\s+/, 1)[0] || ''
+
+ return !first.slice(1).includes('/')
+}
+
+export const toTranscriptMessages = (rows: unknown): Msg[] => {
+ if (!Array.isArray(rows)) {
+ return []
+ }
+
+ const result: Msg[] = []
+ let pendingTools: string[] = []
+
+ for (const row of rows) {
+ if (!row || typeof row !== 'object') {
+ continue
+ }
+
+ const role = (row as any).role
+ const text = (row as any).text
+
+ if (role === 'tool') {
+ const name = (row as any).name ?? 'tool'
+ const ctx = (row as any).context ?? ''
+ pendingTools.push(buildToolTrailLine(name, ctx))
+
+ continue
+ }
+
+ if (typeof text !== 'string' || !text.trim()) {
+ continue
+ }
+
+ if (role === 'assistant') {
+ const msg: Msg = { role, text }
+
+ if (pendingTools.length) {
+ msg.tools = pendingTools
+ pendingTools = []
+ }
+
+ result.push(msg)
+
+ continue
+ }
+
+ if (role === 'user' || role === 'system') {
+ pendingTools = []
+ result.push({ role, text })
+ }
+ }
+
+ return result
+}
+
+export function fmtDuration(ms: number) {
+ const total = Math.max(0, Math.floor(ms / 1000))
+ const hours = Math.floor(total / 3600)
+ const mins = Math.floor((total % 3600) / 60)
+ const secs = total % 60
+
+ if (hours > 0) {
+ return `${hours}h ${mins}m`
+ }
+
+ if (mins > 0) {
+ return `${mins}m ${secs}s`
+ }
+
+ return `${secs}s`
+}
+
+export const stickyPromptFromViewport = (
+ messages: readonly Msg[],
+ offsets: ArrayLike,
+ top: number,
+ sticky: boolean
+) => {
+ if (sticky || !messages.length) {
+ return ''
+ }
+
+ let lo = 0
+ let hi = offsets.length
+
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1
+
+ if (offsets[mid]! <= top) {
+ lo = mid + 1
+ } else {
+ hi = mid
+ }
+ }
+
+ const first = Math.max(0, Math.min(messages.length - 1, lo - 1))
+
+ if (messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top) {
+ return ''
+ }
+
+ for (let i = first - 1; i >= 0; i--) {
+ if (messages[i]?.role !== 'user') {
+ continue
+ }
+
+ if ((offsets[i] ?? 0) + 1 >= top) {
+ continue
+ }
+
+ return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
+ }
+
+ return ''
+}
diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts
new file mode 100644
index 000000000..c4611f9dc
--- /dev/null
+++ b/ui-tui/src/app/interfaces.ts
@@ -0,0 +1,67 @@
+import type { GatewayClient } from '../gatewayClient.js'
+import type { Theme } from '../theme.js'
+import type { ApprovalReq, ClarifyReq, DetailsMode, Msg, SecretReq, SessionInfo, SudoReq, Usage } from '../types.js'
+
+export interface CompletionItem {
+ display: string
+ meta?: string
+ text: string
+}
+
+export interface GatewayRpc {
+ (method: string, params?: Record): Promise
+}
+
+export interface GatewayServices {
+ gw: GatewayClient
+ rpc: GatewayRpc
+}
+
+export interface OverlayState {
+ approval: ApprovalReq | null
+ clarify: ClarifyReq | null
+ modelPicker: boolean
+ pager: PagerState | null
+ picker: boolean
+ secret: SecretReq | null
+ sudo: SudoReq | null
+}
+
+export interface PagerState {
+ lines: string[]
+ offset: number
+ title?: string
+}
+
+export interface ToolCompleteRibbon {
+ label: string
+ line: string
+}
+
+export interface TranscriptRow {
+ index: number
+ key: string
+ msg: Msg
+}
+
+export interface UiState {
+ bgTasks: Set
+ busy: boolean
+ compact: boolean
+ detailsMode: DetailsMode
+ info: SessionInfo | null
+ sid: string | null
+ status: string
+ statusBar: boolean
+ theme: Theme
+ usage: Usage
+}
+
+export interface VirtualHistoryState {
+ bottomSpacer: number
+ end: number
+ measureRef: (key: string) => (el: unknown) => void
+ offsets: ArrayLike
+ start: number
+ topSpacer: number
+}
diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts
new file mode 100644
index 000000000..de4adad62
--- /dev/null
+++ b/ui-tui/src/app/overlayStore.ts
@@ -0,0 +1,41 @@
+import { atom, computed } from 'nanostores'
+
+import type { OverlayState } from './interfaces.js'
+
+function buildOverlayState(): OverlayState {
+ return {
+ approval: null,
+ clarify: null,
+ modelPicker: false,
+ pager: null,
+ picker: false,
+ secret: null,
+ sudo: null
+ }
+}
+
+export const $overlayState = atom(buildOverlayState())
+
+export const $isBlocked = computed($overlayState, state =>
+ Boolean(
+ state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo
+ )
+)
+
+export function getOverlayState() {
+ return $overlayState.get()
+}
+
+export function patchOverlayState(next: Partial | ((state: OverlayState) => OverlayState)) {
+ if (typeof next === 'function') {
+ $overlayState.set(next($overlayState.get()))
+
+ return
+ }
+
+ $overlayState.set({ ...$overlayState.get(), ...next })
+}
+
+export function resetOverlayState() {
+ $overlayState.set(buildOverlayState())
+}
diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts
new file mode 100644
index 000000000..501db36c9
--- /dev/null
+++ b/ui-tui/src/app/uiStore.ts
@@ -0,0 +1,41 @@
+import { atom } from 'nanostores'
+
+import { ZERO } from '../constants.js'
+import { DEFAULT_THEME } from '../theme.js'
+
+import type { UiState } from './interfaces.js'
+
+function buildUiState(): UiState {
+ return {
+ bgTasks: new Set(),
+ busy: false,
+ compact: false,
+ detailsMode: 'collapsed',
+ info: null,
+ sid: null,
+ status: 'summoning hermes…',
+ statusBar: true,
+ theme: DEFAULT_THEME,
+ usage: ZERO
+ }
+}
+
+export const $uiState = atom(buildUiState())
+
+export function getUiState() {
+ return $uiState.get()
+}
+
+export function patchUiState(next: Partial | ((state: UiState) => UiState)) {
+ if (typeof next === 'function') {
+ $uiState.set(next($uiState.get()))
+
+ return
+ }
+
+ $uiState.set({ ...$uiState.get(), ...next })
+}
+
+export function resetUiState() {
+ $uiState.set(buildUiState())
+}
diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts
new file mode 100644
index 000000000..7e8b31753
--- /dev/null
+++ b/ui-tui/src/app/useComposerState.ts
@@ -0,0 +1,199 @@
+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 { useStore } from '@nanostores/react'
+import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useState } from 'react'
+
+import type { PasteEvent } from '../components/textInput.js'
+import type { GatewayClient } from '../gatewayClient.js'
+import { useCompletion } from '../hooks/useCompletion.js'
+import { useInputHistory } from '../hooks/useInputHistory.js'
+import { useQueue } from '../hooks/useQueue.js'
+import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
+
+import { LARGE_PASTE } from './constants.js'
+import type { PasteSnippet } from './helpers.js'
+import type { CompletionItem } from './interfaces.js'
+import { $isBlocked } from './overlayStore.js'
+
+export interface ComposerPasteResult {
+ cursor: number
+ value: string
+}
+
+export interface ComposerActions {
+ clearIn: () => void
+ dequeue: () => string | undefined
+ enqueue: (text: string) => void
+ handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
+ openEditor: () => void
+ pushHistory: (text: string) => void
+ replaceQueue: (index: number, text: string) => void
+ setCompIdx: Dispatch>
+ setHistoryIdx: Dispatch>
+ setInput: Dispatch>
+ setInputBuf: Dispatch>
+ setPasteSnips: Dispatch>
+ setQueueEdit: (index: number | null) => void
+ syncQueue: () => void
+}
+
+export interface ComposerRefs {
+ historyDraftRef: MutableRefObject
+ historyRef: MutableRefObject
+ queueEditRef: MutableRefObject
+ queueRef: MutableRefObject
+ submitRef: MutableRefObject<(value: string) => void>
+}
+
+export interface ComposerState {
+ compIdx: number
+ compReplace: number
+ completions: CompletionItem[]
+ historyIdx: number | null
+ input: string
+ inputBuf: string[]
+ pasteSnips: PasteSnippet[]
+ queueEditIdx: number | null
+ queuedDisplay: string[]
+}
+
+export interface UseComposerStateOptions {
+ gw: GatewayClient
+ onClipboardPaste: (quiet?: boolean) => Promise | void
+ submitRef: MutableRefObject<(value: string) => void>
+}
+
+export interface UseComposerStateResult {
+ actions: ComposerActions
+ refs: ComposerRefs
+ state: ComposerState
+}
+
+export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {
+ const [input, setInput] = useState('')
+ const [inputBuf, setInputBuf] = useState([])
+ const [pasteSnips, setPasteSnips] = useState([])
+ const isBlocked = useStore($isBlocked)
+
+ 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, isBlocked, gw)
+
+ const clearIn = useCallback(() => {
+ setInput('')
+ setInputBuf([])
+ setQueueEdit(null)
+ setHistoryIdx(null)
+ historyDraftRef.current = ''
+ }, [historyDraftRef, setQueueEdit, setHistoryIdx])
+
+ const handleTextPaste = useCallback(
+ ({ bracketed, cursor, hotkey, text, value }: PasteEvent) => {
+ if (hotkey) {
+ void onClipboardPaste(false)
+
+ return null
+ }
+
+ const cleanedText = stripTrailingPasteNewlines(text)
+
+ if (!cleanedText || !/[^\n]/.test(cleanedText)) {
+ if (bracketed) {
+ void onClipboardPaste(true)
+ }
+
+ return null
+ }
+
+ const lineCount = cleanedText.split('\n').length
+
+ if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
+ return {
+ cursor: cursor + cleanedText.length,
+ value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
+ }
+ }
+
+ const label = pasteTokenLabel(cleanedText, lineCount)
+ const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
+ const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
+ const insert = `${lead}${label}${tail}`
+
+ setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))
+
+ return {
+ cursor: cursor + insert.length,
+ value: value.slice(0, cursor) + insert + value.slice(cursor)
+ }
+ },
+ [onClipboardPaste]
+ )
+
+ const openEditor = useCallback(() => {
+ 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([])
+ submitRef.current(text)
+ }
+ }
+
+ try {
+ unlinkSync(file)
+ } catch {
+ /* noop */
+ }
+ }, [input, inputBuf, submitRef])
+
+ return {
+ actions: {
+ clearIn,
+ dequeue,
+ enqueue,
+ handleTextPaste,
+ openEditor,
+ pushHistory,
+ replaceQueue: replaceQ,
+ setCompIdx,
+ setHistoryIdx,
+ setInput,
+ setInputBuf,
+ setPasteSnips,
+ setQueueEdit,
+ syncQueue
+ },
+ refs: {
+ historyDraftRef,
+ historyRef,
+ queueEditRef,
+ queueRef,
+ submitRef
+ },
+ state: {
+ compIdx,
+ compReplace,
+ completions,
+ historyIdx,
+ input,
+ inputBuf,
+ pasteSnips,
+ queueEditIdx,
+ queuedDisplay
+ }
+ }
+}
diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts
new file mode 100644
index 000000000..1db4594b9
--- /dev/null
+++ b/ui-tui/src/app/useInputHandlers.ts
@@ -0,0 +1,345 @@
+import { type ScrollBoxHandle, useInput } from '@hermes/ink'
+import { useStore } from '@nanostores/react'
+import type { Dispatch, RefObject, SetStateAction } from 'react'
+
+import type { Msg } from '../types.js'
+
+import type { GatewayServices } from './interfaces.js'
+import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
+import { getUiState, patchUiState } from './uiStore.js'
+import type { ComposerActions, ComposerRefs, ComposerState } from './useComposerState.js'
+import type { TurnActions, TurnRefs } from './useTurnState.js'
+
+export interface InputHandlerActions {
+ answerClarify: (answer: string) => void
+ appendMessage: (msg: Msg) => void
+ die: () => void
+ dispatchSubmission: (full: string) => void
+ guardBusySessionSwitch: (what?: string) => boolean
+ newSession: (msg?: string) => void
+ sys: (text: string) => void
+}
+
+export interface InputHandlerContext {
+ actions: InputHandlerActions
+ composer: {
+ actions: ComposerActions
+ refs: ComposerRefs
+ state: ComposerState
+ }
+ gateway: GatewayServices
+ terminal: {
+ hasSelection: boolean
+ scrollRef: RefObject
+ scrollWithSelection: (delta: number) => void
+ selection: {
+ copySelection: () => string
+ }
+ stdout?: NodeJS.WriteStream
+ }
+ turn: {
+ actions: TurnActions
+ refs: TurnRefs
+ }
+ voice: {
+ recording: boolean
+ setProcessing: Dispatch>
+ setRecording: Dispatch>
+ }
+ wheelStep: number
+}
+
+export interface InputHandlerResult {
+ pagerPageSize: number
+}
+
+export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
+ const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx
+ const overlay = useStore($overlayState)
+ const isBlocked = useStore($isBlocked)
+ const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
+
+ const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
+
+ const copySelection = () => {
+ if (terminal.selection.copySelection()) {
+ actions.sys('copied selection')
+ }
+ }
+
+ useInput((ch, key) => {
+ const live = getUiState()
+
+ if (isBlocked) {
+ if (overlay.pager) {
+ if (key.return || ch === ' ') {
+ const next = overlay.pager.offset + pagerPageSize
+
+ patchOverlayState({
+ pager: next >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: next }
+ })
+ } else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') {
+ patchOverlayState({ pager: null })
+ }
+
+ return
+ }
+
+ if (ctrl(key, ch, 'c')) {
+ if (overlay.clarify) {
+ actions.answerClarify('')
+ } else if (overlay.approval) {
+ gateway.rpc('approval.respond', { choice: 'deny', session_id: live.sid }).then(r => {
+ if (!r) {
+ return
+ }
+
+ patchOverlayState({ approval: null })
+ actions.sys('denied')
+ })
+ } else if (overlay.sudo) {
+ gateway.rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }).then(r => {
+ if (!r) {
+ return
+ }
+
+ patchOverlayState({ sudo: null })
+ actions.sys('sudo cancelled')
+ })
+ } else if (overlay.secret) {
+ gateway.rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }).then(r => {
+ if (!r) {
+ return
+ }
+
+ patchOverlayState({ secret: null })
+ actions.sys('secret entry cancelled')
+ })
+ } else if (overlay.modelPicker) {
+ patchOverlayState({ modelPicker: false })
+ } else if (overlay.picker) {
+ patchOverlayState({ picker: false })
+ }
+ } else if (key.escape && overlay.picker) {
+ patchOverlayState({ picker: false })
+ }
+
+ return
+ }
+
+ if (
+ composer.state.completions.length &&
+ composer.state.input &&
+ composer.state.historyIdx === null &&
+ (key.upArrow || key.downArrow)
+ ) {
+ composer.actions.setCompIdx(index =>
+ key.upArrow
+ ? (index - 1 + composer.state.completions.length) % composer.state.completions.length
+ : (index + 1) % composer.state.completions.length
+ )
+
+ return
+ }
+
+ if (key.wheelUp) {
+ terminal.scrollWithSelection(-wheelStep)
+
+ return
+ }
+
+ if (key.wheelDown) {
+ terminal.scrollWithSelection(wheelStep)
+
+ return
+ }
+
+ if (key.shift && key.upArrow) {
+ terminal.scrollWithSelection(-1)
+
+ return
+ }
+
+ if (key.shift && key.downArrow) {
+ terminal.scrollWithSelection(1)
+
+ return
+ }
+
+ if (key.pageUp || key.pageDown) {
+ const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
+ const step = Math.max(4, viewport - 2)
+
+ terminal.scrollWithSelection(key.pageUp ? -step : step)
+
+ return
+ }
+
+ if (key.ctrl && key.shift && ch.toLowerCase() === 'c') {
+ copySelection()
+
+ return
+ }
+
+ if (key.upArrow && !composer.state.inputBuf.length) {
+ if (composer.refs.queueRef.current.length) {
+ const index =
+ composer.state.queueEditIdx === null
+ ? 0
+ : (composer.state.queueEditIdx + 1) % composer.refs.queueRef.current.length
+
+ composer.actions.setQueueEdit(index)
+ composer.actions.setHistoryIdx(null)
+ composer.actions.setInput(composer.refs.queueRef.current[index] ?? '')
+ } else if (composer.refs.historyRef.current.length) {
+ const index =
+ composer.state.historyIdx === null
+ ? composer.refs.historyRef.current.length - 1
+ : Math.max(0, composer.state.historyIdx - 1)
+
+ if (composer.state.historyIdx === null) {
+ composer.refs.historyDraftRef.current = composer.state.input
+ }
+
+ composer.actions.setHistoryIdx(index)
+ composer.actions.setQueueEdit(null)
+ composer.actions.setInput(composer.refs.historyRef.current[index] ?? '')
+ }
+
+ return
+ }
+
+ if (key.downArrow && !composer.state.inputBuf.length) {
+ if (composer.refs.queueRef.current.length) {
+ const index =
+ composer.state.queueEditIdx === null
+ ? composer.refs.queueRef.current.length - 1
+ : (composer.state.queueEditIdx - 1 + composer.refs.queueRef.current.length) %
+ composer.refs.queueRef.current.length
+
+ composer.actions.setQueueEdit(index)
+ composer.actions.setHistoryIdx(null)
+ composer.actions.setInput(composer.refs.queueRef.current[index] ?? '')
+ } else if (composer.state.historyIdx !== null) {
+ const next = composer.state.historyIdx + 1
+
+ if (next >= composer.refs.historyRef.current.length) {
+ composer.actions.setHistoryIdx(null)
+ composer.actions.setInput(composer.refs.historyDraftRef.current)
+ } else {
+ composer.actions.setHistoryIdx(next)
+ composer.actions.setInput(composer.refs.historyRef.current[next] ?? '')
+ }
+ }
+
+ return
+ }
+
+ if (ctrl(key, ch, 'c')) {
+ if (terminal.hasSelection) {
+ copySelection()
+ } else if (live.busy && live.sid) {
+ turn.actions.interruptTurn({
+ appendMessage: actions.appendMessage,
+ gw: gateway.gw,
+ sid: live.sid,
+ sys: actions.sys
+ })
+ } else if (composer.state.input || composer.state.inputBuf.length) {
+ composer.actions.clearIn()
+ } else {
+ return actions.die()
+ }
+
+ return
+ }
+
+ if (ctrl(key, ch, 'd')) {
+ return actions.die()
+ }
+
+ if (ctrl(key, ch, 'l')) {
+ if (actions.guardBusySessionSwitch()) {
+ return
+ }
+
+ patchUiState({ status: 'forging session…' })
+ actions.newSession()
+
+ return
+ }
+
+ if (ctrl(key, ch, 'b')) {
+ if (voice.recording) {
+ voice.setRecording(false)
+ voice.setProcessing(true)
+ gateway
+ .rpc('voice.record', { action: 'stop' })
+ .then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ const transcript = String(r?.text || '').trim()
+
+ if (transcript) {
+ composer.actions.setInput(prev =>
+ prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript
+ )
+ } else {
+ actions.sys('voice: no speech detected')
+ }
+ })
+ .catch((e: Error) => actions.sys(`voice error: ${e.message}`))
+ .finally(() => {
+ voice.setProcessing(false)
+ patchUiState({ status: 'ready' })
+ })
+ } else {
+ gateway
+ .rpc('voice.record', { action: 'start' })
+ .then((r: any) => {
+ if (!r) {
+ return
+ }
+
+ voice.setRecording(true)
+ patchUiState({ status: 'recording…' })
+ })
+ .catch((e: Error) => actions.sys(`voice error: ${e.message}`))
+ }
+
+ return
+ }
+
+ if (ctrl(key, ch, 'g')) {
+ return composer.actions.openEditor()
+ }
+
+ if (key.tab && composer.state.completions.length) {
+ const row = composer.state.completions[composer.state.compIdx]
+
+ if (row?.text) {
+ const text =
+ composer.state.input.startsWith('/') && row.text.startsWith('/') && composer.state.compReplace > 0
+ ? row.text.slice(1)
+ : row.text
+
+ composer.actions.setInput(composer.state.input.slice(0, composer.state.compReplace) + text)
+ }
+
+ return
+ }
+
+ if (ctrl(key, ch, 'k') && composer.refs.queueRef.current.length && live.sid) {
+ const next = composer.actions.dequeue()
+
+ if (next) {
+ composer.actions.setQueueEdit(null)
+ actions.dispatchSubmission(next)
+ }
+ }
+ })
+
+ return { pagerPageSize }
+}
diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts
new file mode 100644
index 000000000..a6a611bc6
--- /dev/null
+++ b/ui-tui/src/app/useTurnState.ts
@@ -0,0 +1,296 @@
+import {
+ type Dispatch,
+ type MutableRefObject,
+ type SetStateAction,
+ useCallback,
+ useEffect,
+ useRef,
+ useState
+} from 'react'
+
+import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
+import type { ActiveTool, ActivityItem, Msg } from '../types.js'
+
+import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js'
+import type { ToolCompleteRibbon } from './interfaces.js'
+import { resetOverlayState } from './overlayStore.js'
+import { patchUiState } from './uiStore.js'
+
+export interface InterruptTurnOptions {
+ appendMessage: (msg: Msg) => void
+ gw: { request: (method: string, params?: Record) => Promise }
+ sid: string
+ sys: (text: string) => void
+}
+
+export interface TurnActions {
+ clearReasoning: () => void
+ endReasoningPhase: () => void
+ idle: () => void
+ interruptTurn: (options: InterruptTurnOptions) => void
+ pruneTransient: () => void
+ pulseReasoningStreaming: () => void
+ pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
+ pushTrail: (line: string) => void
+ scheduleReasoning: () => void
+ scheduleStreaming: () => void
+ setActivity: Dispatch>
+ setReasoning: Dispatch>
+ setReasoningActive: Dispatch>
+ setReasoningStreaming: Dispatch>
+ setStreaming: Dispatch>
+ setTools: Dispatch>
+ setTurnTrail: Dispatch>
+}
+
+export interface TurnRefs {
+ bufRef: MutableRefObject
+ interruptedRef: MutableRefObject
+ lastStatusNoteRef: MutableRefObject
+ persistedToolLabelsRef: MutableRefObject>
+ protocolWarnedRef: MutableRefObject
+ reasoningRef: MutableRefObject
+ reasoningStreamingTimerRef: MutableRefObject | null>
+ reasoningTimerRef: MutableRefObject | null>
+ statusTimerRef: MutableRefObject | null>
+ streamTimerRef: MutableRefObject | null>
+ toolCompleteRibbonRef: MutableRefObject
+ turnToolsRef: MutableRefObject
+}
+
+export interface TurnState {
+ activity: ActivityItem[]
+ reasoning: string
+ reasoningActive: boolean
+ reasoningStreaming: boolean
+ streaming: string
+ tools: ActiveTool[]
+ turnTrail: string[]
+}
+
+export interface UseTurnStateResult {
+ actions: TurnActions
+ refs: TurnRefs
+ state: TurnState
+}
+
+export function useTurnState(): UseTurnStateResult {
+ const [activity, setActivity] = useState([])
+ const [reasoning, setReasoning] = useState('')
+ const [reasoningActive, setReasoningActive] = useState(false)
+ const [reasoningStreaming, setReasoningStreaming] = useState(false)
+ const [streaming, setStreaming] = useState('')
+ const [tools, setTools] = useState([])
+ const [turnTrail, setTurnTrail] = useState([])
+
+ const activityIdRef = useRef(0)
+ const bufRef = useRef('')
+ const interruptedRef = useRef(false)
+ const lastStatusNoteRef = useRef('')
+ const persistedToolLabelsRef = useRef>(new Set())
+ const protocolWarnedRef = useRef(false)
+ const reasoningRef = useRef('')
+ const reasoningStreamingTimerRef = useRef | null>(null)
+ const reasoningTimerRef = useRef | null>(null)
+ const statusTimerRef = useRef | null>(null)
+ const streamTimerRef = useRef | null>(null)
+ const toolCompleteRibbonRef = useRef(null)
+ const turnToolsRef = useRef([])
+
+ const setTrail = (next: string[]) => {
+ turnToolsRef.current = next
+
+ return next
+ }
+
+ const pulseReasoningStreaming = useCallback(() => {
+ if (reasoningStreamingTimerRef.current) {
+ clearTimeout(reasoningStreamingTimerRef.current)
+ }
+
+ setReasoningActive(true)
+ setReasoningStreaming(true)
+ reasoningStreamingTimerRef.current = setTimeout(() => {
+ reasoningStreamingTimerRef.current = null
+ setReasoningStreaming(false)
+ }, REASONING_PULSE_MS)
+ }, [])
+
+ const scheduleStreaming = useCallback(() => {
+ if (streamTimerRef.current) {
+ return
+ }
+
+ streamTimerRef.current = setTimeout(() => {
+ streamTimerRef.current = null
+ setStreaming(bufRef.current.trimStart())
+ }, STREAM_BATCH_MS)
+ }, [])
+
+ const scheduleReasoning = useCallback(() => {
+ if (reasoningTimerRef.current) {
+ return
+ }
+
+ reasoningTimerRef.current = setTimeout(() => {
+ reasoningTimerRef.current = null
+ setReasoning(reasoningRef.current)
+ }, STREAM_BATCH_MS)
+ }, [])
+
+ const endReasoningPhase = useCallback(() => {
+ if (reasoningStreamingTimerRef.current) {
+ clearTimeout(reasoningStreamingTimerRef.current)
+ reasoningStreamingTimerRef.current = null
+ }
+
+ setReasoningStreaming(false)
+ setReasoningActive(false)
+ }, [])
+
+ useEffect(
+ () => () => {
+ if (streamTimerRef.current) {
+ clearTimeout(streamTimerRef.current)
+ }
+
+ if (reasoningTimerRef.current) {
+ clearTimeout(reasoningTimerRef.current)
+ }
+
+ if (reasoningStreamingTimerRef.current) {
+ clearTimeout(reasoningStreamingTimerRef.current)
+ }
+ },
+ []
+ )
+
+ const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
+ setActivity(prev => {
+ const base = replaceLabel ? prev.filter(item => !sameToolTrailGroup(replaceLabel, item.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 pruneTransient = useCallback(() => {
+ setTurnTrail(prev => {
+ const next = prev.filter(line => !isTransientTrailLine(line))
+
+ return next.length === prev.length ? prev : setTrail(next)
+ })
+ }, [])
+
+ const pushTrail = useCallback((line: string) => {
+ setTurnTrail(prev =>
+ prev.at(-1) === line ? prev : setTrail([...prev.filter(item => !isTransientTrailLine(item)), line].slice(-8))
+ )
+ }, [])
+
+ const clearReasoning = useCallback(() => {
+ if (reasoningTimerRef.current) {
+ clearTimeout(reasoningTimerRef.current)
+ reasoningTimerRef.current = null
+ }
+
+ reasoningRef.current = ''
+ setReasoning('')
+ }, [])
+
+ const idle = useCallback(() => {
+ endReasoningPhase()
+ setTools([])
+ setTurnTrail([])
+ patchUiState({ busy: false })
+ resetOverlayState()
+
+ if (streamTimerRef.current) {
+ clearTimeout(streamTimerRef.current)
+ streamTimerRef.current = null
+ }
+
+ setStreaming('')
+ bufRef.current = ''
+ }, [endReasoningPhase])
+
+ const interruptTurn = useCallback(
+ ({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => {
+ interruptedRef.current = true
+ gw.request('session.interrupt', { session_id: sid }).catch(() => {})
+ const partial = (streaming || bufRef.current).trimStart()
+
+ if (partial) {
+ appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' })
+ } else {
+ sys('interrupted')
+ }
+
+ idle()
+ clearReasoning()
+ setActivity([])
+ turnToolsRef.current = []
+ patchUiState({ status: 'interrupted' })
+
+ if (statusTimerRef.current) {
+ clearTimeout(statusTimerRef.current)
+ }
+
+ statusTimerRef.current = setTimeout(() => {
+ statusTimerRef.current = null
+ patchUiState({ status: 'ready' })
+ }, 1500)
+ },
+ [clearReasoning, idle, streaming]
+ )
+
+ return {
+ actions: {
+ clearReasoning,
+ endReasoningPhase,
+ idle,
+ interruptTurn,
+ pruneTransient,
+ pulseReasoningStreaming,
+ pushActivity,
+ pushTrail,
+ scheduleReasoning,
+ scheduleStreaming,
+ setActivity,
+ setReasoning,
+ setReasoningActive,
+ setReasoningStreaming,
+ setStreaming,
+ setTools,
+ setTurnTrail
+ },
+ refs: {
+ bufRef,
+ interruptedRef,
+ lastStatusNoteRef,
+ persistedToolLabelsRef,
+ protocolWarnedRef,
+ reasoningRef,
+ reasoningStreamingTimerRef,
+ reasoningTimerRef,
+ statusTimerRef,
+ streamTimerRef,
+ toolCompleteRibbonRef,
+ turnToolsRef
+ },
+ state: {
+ activity,
+ reasoning,
+ reasoningActive,
+ reasoningStreaming,
+ streaming,
+ tools,
+ turnTrail
+ }
+ }
+}
diff --git a/ui-tui/src/components/activityLane.tsx b/ui-tui/src/components/activityLane.tsx
deleted file mode 100644
index 053c62c59..000000000
--- a/ui-tui/src/components/activityLane.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Box, Text } from '@hermes/ink'
-
-import type { Theme } from '../theme.js'
-import type { ActivityItem } from '../types.js'
-
-const toneColor = (item: ActivityItem, t: Theme) =>
- item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
-
-export function ActivityLane({ items, t }: { items: ActivityItem[]; t: Theme }) {
- if (!items.length) {
- return null
- }
-
- return (
-
- {items.slice(-4).map(item => (
-
- {t.brand.tool} {item.text}
-
- ))}
-
- )
-}
diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx
new file mode 100644
index 000000000..bb5769f3a
--- /dev/null
+++ b/ui-tui/src/components/appChrome.tsx
@@ -0,0 +1,227 @@
+import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
+import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
+
+import { stickyPromptFromViewport } from '../app/helpers.js'
+import { fmtK } from '../lib/text.js'
+import type { Theme } from '../theme.js'
+import type { Msg, Usage } from '../types.js'
+
+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)
+}
+
+export function StatusRule({
+ cwdLabel,
+ cols,
+ status,
+ statusColor,
+ model,
+ usage,
+ bgCount,
+ durationLabel,
+ voiceLabel,
+ t
+}: {
+ cwdLabel: string
+ cols: number
+ status: string
+ statusColor: string
+ model: string
+ usage: Usage
+ bgCount: number
+ durationLabel?: string
+ voiceLabel?: string
+ 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 leftWidth = Math.max(12, cols - cwdLabel.length - 3)
+
+ return (
+
+
+
+ {'─ '}
+ {status}
+ │ {model}
+ {ctxLabel ? │ {ctxLabel} : null}
+ {bar ? (
+
+ {' │ '}
+ [{bar}] {pctLabel}
+
+ ) : null}
+ {durationLabel ? │ {durationLabel} : null}
+ {voiceLabel ? │ {voiceLabel} : null}
+ {bgCount > 0 ? │ {bgCount} bg : null}
+
+
+ ─
+ {cwdLabel}
+
+ )
+}
+
+export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function StickyPromptTracker({
+ messages,
+ offsets,
+ scrollRef,
+ onChange
+}: {
+ messages: readonly Msg[]
+ offsets: ArrayLike
+ scrollRef: RefObject
+ onChange: (text: string) => void
+}) {
+ useSyncExternalStore(
+ useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
+ () => {
+ const s = scrollRef.current
+
+ if (!s) {
+ return NaN
+ }
+
+ const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
+
+ return s.isSticky() ? -1 - top : top
+ },
+ () => NaN
+ )
+
+ const s = scrollRef.current
+ const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
+ const text = stickyPromptFromViewport(messages, offsets, top, s?.isSticky() ?? true)
+
+ useEffect(() => onChange(text), [onChange, text])
+
+ return null
+}
+
+export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) {
+ useSyncExternalStore(
+ useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
+ () => {
+ const s = scrollRef.current
+
+ if (!s) {
+ return NaN
+ }
+
+ return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}`
+ },
+ () => ''
+ )
+
+ const [hover, setHover] = useState(false)
+ const [grab, setGrab] = useState(null)
+
+ const s = scrollRef.current
+ const vp = Math.max(0, s?.getViewportHeight() ?? 0)
+
+ if (!vp) {
+ return
+ }
+
+ const total = Math.max(vp, s?.getScrollHeight() ?? vp)
+ const scrollable = total > vp
+ const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
+ const travel = Math.max(1, vp - thumb)
+ const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
+ const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
+
+ const jump = (row: number, offset: number) => {
+ if (!s || !scrollable) {
+ return
+ }
+
+ s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
+ }
+
+ return (
+ {
+ const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
+ const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
+ setGrab(off)
+ jump(row, off)
+ }}
+ onMouseDrag={(e: { localRow?: number }) =>
+ jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
+ }
+ onMouseEnter={() => setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ onMouseUp={() => setGrab(null)}
+ width={1}
+ >
+ {Array.from({ length: vp }, (_, i) => {
+ const active = i >= thumbTop && i < thumbTop + thumb
+
+ const color = active
+ ? grab !== null
+ ? t.color.gold
+ : hover
+ ? t.color.amber
+ : t.color.bronze
+ : hover
+ ? t.color.bronze
+ : t.color.dim
+
+ return (
+
+ {scrollable ? (active ? '┃' : '│') : ' '}
+
+ )
+ })}
+
+ )
+}
diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx
new file mode 100644
index 000000000..be33502ee
--- /dev/null
+++ b/ui-tui/src/components/appLayout.tsx
@@ -0,0 +1,248 @@
+import { AlternateScreen, Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
+import { useStore } from '@nanostores/react'
+import type { RefObject } from 'react'
+
+import { PLACEHOLDER } from '../app/constants.js'
+import type { CompletionItem, TranscriptRow, VirtualHistoryState } from '../app/interfaces.js'
+import { $isBlocked } from '../app/overlayStore.js'
+import { $uiState } from '../app/uiStore.js'
+import type { ActiveTool, ActivityItem, Msg } from '../types.js'
+
+import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
+import { AppOverlays } from './appOverlays.js'
+import { Banner, Panel, SessionPanel } from './branding.js'
+import { MessageLine } from './messageLine.js'
+import { QueuedMessages } from './queuedMessages.js'
+import type { PasteEvent } from './textInput.js'
+import { TextInput } from './textInput.js'
+import { ToolTrail } from './thinking.js'
+
+export interface AppLayoutActions {
+ answerApproval: (choice: string) => void
+ answerClarify: (answer: string) => void
+ answerSecret: (value: string) => void
+ answerSudo: (pw: string) => void
+ onModelSelect: (value: string) => void
+ resumeById: (id: string) => void
+ setStickyPrompt: (value: string) => void
+}
+
+export interface AppLayoutComposerProps {
+ cols: number
+ compIdx: number
+ completions: CompletionItem[]
+ empty: boolean
+ handleTextPaste: (event: PasteEvent) => { cursor: number; value: string } | null
+ input: string
+ inputBuf: string[]
+ pagerPageSize: number
+ queueEditIdx: number | null
+ queuedDisplay: string[]
+ submit: (value: string) => void
+ updateInput: (next: string) => void
+}
+
+export interface AppLayoutProgressProps {
+ activity: ActivityItem[]
+ reasoning: string
+ reasoningActive: boolean
+ reasoningStreaming: boolean
+ showProgressArea: boolean
+ showStreamingArea: boolean
+ streaming: string
+ tools: ActiveTool[]
+ turnTrail: string[]
+}
+
+export interface AppLayoutStatusProps {
+ cwdLabel: string
+ durationLabel: string
+ showStickyPrompt: boolean
+ statusColor: string
+ stickyPrompt: string
+ voiceLabel: string
+}
+
+export interface AppLayoutTranscriptProps {
+ historyItems: Msg[]
+ scrollRef: RefObject
+ virtualHistory: VirtualHistoryState
+ virtualRows: TranscriptRow[]
+}
+
+export interface AppLayoutProps {
+ actions: AppLayoutActions
+ composer: AppLayoutComposerProps
+ mouseTracking: boolean
+ progress: AppLayoutProgressProps
+ status: AppLayoutStatusProps
+ transcript: AppLayoutTranscriptProps
+}
+
+export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
+ const ui = useStore($uiState)
+ const isBlocked = useStore($isBlocked)
+ const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
+
+ return (
+
+
+
+
+
+ {transcript.virtualHistory.topSpacer > 0 ? : null}
+
+ {visibleHistory.map(row => (
+
+ {row.msg.kind === 'intro' && row.msg.info ? (
+
+
+
+
+ ) : row.msg.kind === 'panel' && row.msg.panelData ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ {transcript.virtualHistory.bottomSpacer > 0 ? (
+
+ ) : null}
+
+ {progress.showProgressArea && (
+
+ )}
+
+ {progress.showStreamingArea && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {ui.bgTasks.size > 0 && (
+
+ {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
+
+ )}
+
+ {status.showStickyPrompt ? (
+
+ ↳
+ {status.stickyPrompt}
+
+ ) : (
+
+ )}
+
+
+ {ui.statusBar && (
+
+ )}
+
+
+
+
+ {!isBlocked && (
+
+ {composer.inputBuf.map((line, i) => (
+
+
+ {i === 0 ? `${ui.theme.brand.prompt} ` : ' '}
+
+
+ {line || ' '}
+
+ ))}
+
+
+
+
+ {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
+
+
+
+
+
+
+ )}
+
+ {!composer.empty && !ui.sid && ⚕ {ui.status}}
+
+
+
+ )
+}
diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx
new file mode 100644
index 000000000..e3b646edd
--- /dev/null
+++ b/ui-tui/src/components/appOverlays.tsx
@@ -0,0 +1,175 @@
+import { Box, Text } from '@hermes/ink'
+import { useStore } from '@nanostores/react'
+
+import { useGateway } from '../app/gatewayContext.js'
+import type { CompletionItem } from '../app/interfaces.js'
+import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
+import { $uiState } from '../app/uiStore.js'
+
+import { FloatBox } from './appChrome.js'
+import { MaskedPrompt } from './maskedPrompt.js'
+import { ModelPicker } from './modelPicker.js'
+import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
+import { SessionPicker } from './sessionPicker.js'
+
+export interface AppOverlaysProps {
+ cols: number
+ compIdx: number
+ completions: CompletionItem[]
+ onApprovalChoice: (choice: string) => void
+ onClarifyAnswer: (value: string) => void
+ onModelSelect: (value: string) => void
+ onPickerSelect: (sessionId: string) => void
+ onSecretSubmit: (value: string) => void
+ onSudoSubmit: (pw: string) => void
+ pagerPageSize: number
+}
+
+export function AppOverlays({
+ cols,
+ compIdx,
+ completions,
+ onApprovalChoice,
+ onClarifyAnswer,
+ onModelSelect,
+ onPickerSelect,
+ onSecretSubmit,
+ onSudoSubmit,
+ pagerPageSize
+}: AppOverlaysProps) {
+ const { gw } = useGateway()
+ const overlay = useStore($overlayState)
+ const ui = useStore($uiState)
+
+ if (
+ !(
+ overlay.approval ||
+ overlay.clarify ||
+ overlay.modelPicker ||
+ overlay.pager ||
+ overlay.picker ||
+ overlay.secret ||
+ overlay.sudo ||
+ completions.length
+ )
+ ) {
+ return null
+ }
+
+ const start = Math.max(0, compIdx - 8)
+
+ return (
+
+ {overlay.clarify && (
+
+ onClarifyAnswer('')}
+ req={overlay.clarify}
+ t={ui.theme}
+ />
+
+ )}
+
+ {overlay.approval && (
+
+
+
+ )}
+
+ {overlay.sudo && (
+
+
+
+ )}
+
+ {overlay.secret && (
+
+
+
+ )}
+
+ {overlay.picker && (
+
+ patchOverlayState({ picker: false })}
+ onSelect={onPickerSelect}
+ t={ui.theme}
+ />
+
+ )}
+
+ {overlay.modelPicker && (
+
+ patchOverlayState({ modelPicker: false })}
+ onSelect={onModelSelect}
+ sessionId={ui.sid}
+ t={ui.theme}
+ />
+
+ )}
+
+ {overlay.pager && (
+
+
+ {overlay.pager.title && (
+
+
+ {overlay.pager.title}
+
+
+ )}
+
+ {overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
+ {line}
+ ))}
+
+
+
+ {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
+ ? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
+ : `end · q to close (${overlay.pager.lines.length} lines)`}
+
+
+
+
+ )}
+
+ {!!completions.length && (
+
+
+ {completions.slice(start, compIdx + 8).map((item, i) => {
+ const active = start + i === compIdx
+
+ return (
+
+
+ {' '}
+ {item.display}
+
+ {item.meta ? {item.meta} : null}
+
+ )
+ })}
+
+
+ )}
+
+ )
+}
diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx
index b4c8e11ad..dbcfeb607 100644
--- a/ui-tui/src/components/messageLine.tsx
+++ b/ui-tui/src/components/messageLine.tsx
@@ -44,7 +44,7 @@ export const MessageLine = memo(function MessageLine({
}
const { body, glyph, prefix } = ROLE[msg.role](t)
- const thinking = msg.thinking?.replace(/\n/g, ' ').trim() ?? ''
+ const thinking = msg.thinking?.trim() ?? ''
const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking))
const content = (() => {
diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx
index c7ae29e24..7688e6148 100644
--- a/ui-tui/src/components/queuedMessages.tsx
+++ b/ui-tui/src/components/queuedMessages.tsx
@@ -14,16 +14,6 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
return { end, showLead: start > 0, showTail: end < queueLen, start }
}
-export function estimateQueuedRows(queueLen: number, queueEditIdx: number | null): number {
- if (!queueLen) {
- return 0
- }
-
- const win = getQueueWindow(queueLen, queueEditIdx)
-
- return 1 + 1 + (win.showLead ? 1 : 0) + (win.end - win.start) + (win.showTail ? 1 : 0)
-}
-
export function QueuedMessages({
cols,
queueEditIdx,
diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx
index 005d8cc4c..04f42ec16 100644
--- a/ui-tui/src/components/thinking.tsx
+++ b/ui-tui/src/components/thinking.tsx
@@ -43,11 +43,16 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
return {spin.frames[frame]}
}
-type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string }
+interface DetailRow {
+ color: string
+ content: ReactNode
+ dimColor?: boolean
+ key: string
+}
function Detail({ color, content, dimColor }: DetailRow) {
return (
-
+
└
{content}
@@ -141,7 +146,7 @@ export const Thinking = memo(function Thinking({
{preview ? (
-
+
└
{preview}
@@ -158,7 +163,12 @@ export const Thinking = memo(function Thinking({
// ── ToolTrail ────────────────────────────────────────────────────────
-type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string }
+interface Group {
+ color: string
+ content: ReactNode
+ details: DetailRow[]
+ key: string
+}
export const ToolTrail = memo(function ToolTrail({
busy = false,
diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx
index ca52ec91c..3ab4be96b 100644
--- a/ui-tui/src/entry.tsx
+++ b/ui-tui/src/entry.tsx
@@ -1,6 +1,5 @@
#!/usr/bin/env node
import { render } from '@hermes/ink'
-import React from 'react'
import { App } from './app.js'
import { GatewayClient } from './gatewayClient.js'
diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts
index 50125d3b5..87adb2eb5 100644
--- a/ui-tui/src/lib/history.ts
+++ b/ui-tui/src/lib/history.ts
@@ -81,7 +81,3 @@ export function append(line: string): void {
/* ignore */
}
}
-
-export function all(): string[] {
- return load()
-}
diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts
index ba1880ed3..b17eff3ee 100644
--- a/ui-tui/src/lib/text.ts
+++ b/ui-tui/src/lib/text.ts
@@ -74,9 +74,17 @@ export const pasteTokenLabel = (text: string, lineCount: number) => {
}
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => {
- const text = reasoning.replace(/\n/g, ' ').trim()
+ const raw = reasoning.trim()
- return !text || mode === 'collapsed' ? '' : mode === 'full' ? text : compactPreview(text, max)
+ if (!raw || mode === 'collapsed') {
+ return ''
+ }
+
+ if (mode === 'full') {
+ return raw
+ }
+
+ return compactPreview(raw.replace(/\s+/g, ' '), max)
}
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts
index 3ae7ada19..3ecb989ba 100644
--- a/ui-tui/src/theme.ts
+++ b/ui-tui/src/theme.ts
@@ -4,6 +4,8 @@ export interface ThemeColors {
bronze: string
cornsilk: string
dim: string
+ completionBg: string
+ completionCurrentBg: string
label: string
ok: string
@@ -39,6 +41,35 @@ export interface Theme {
bannerHero: string
}
+// ── Color math ───────────────────────────────────────────────────────
+
+function parseHex(h: string): [number, number, number] | null {
+ const m = /^#?([0-9a-f]{6})$/i.exec(h)
+
+ if (!m) {
+ return null
+ }
+
+ const n = parseInt(m[1]!, 16)
+
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
+}
+
+function mix(a: string, b: string, t: number) {
+ const pa = parseHex(a)
+ const pb = parseHex(b)
+
+ if (!pa || !pb) {
+ return a
+ }
+
+ const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t)
+
+ return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1)
+}
+
+// ── Defaults ─────────────────────────────────────────────────────────
+
export const DEFAULT_THEME: Theme = {
color: {
gold: '#FFD700',
@@ -46,8 +77,10 @@ export const DEFAULT_THEME: Theme = {
bronze: '#CD7F32',
cornsilk: '#FFF8DC',
dim: '#B8860B',
+ completionBg: '#FFFFFF',
+ completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
- label: '#4dd0e1',
+ label: '#DAA520',
ok: '#4caf50',
error: '#ef5350',
warn: '#ffa726',
@@ -78,6 +111,8 @@ export const DEFAULT_THEME: Theme = {
bannerHero: ''
}
+// ── Skin → Theme ─────────────────────────────────────────────────────
+
export function fromSkin(
colors: Record,
branding: Record,
@@ -87,6 +122,8 @@ export function fromSkin(
const d = DEFAULT_THEME
const c = (k: string) => colors[k]
+ const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
+
return {
color: {
gold: c('banner_title') ?? d.color.gold,
@@ -94,6 +131,8 @@ export function fromSkin(
bronze: c('banner_border') ?? d.color.bronze,
cornsilk: c('banner_text') ?? d.color.cornsilk,
dim: c('banner_dim') ?? d.color.dim,
+ completionBg: c('completion_menu_bg') ?? '#FFFFFF',
+ completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok,