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,