diff --git a/ui-tui/src/__tests__/asCommandDispatch.test.ts b/ui-tui/src/__tests__/asCommandDispatch.test.ts new file mode 100644 index 0000000000..49ea56936c --- /dev/null +++ b/ui-tui/src/__tests__/asCommandDispatch.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { asCommandDispatch } from '../lib/rpc.js' + +describe('asCommandDispatch', () => { + it('parses exec, alias, and skill', () => { + expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' }) + expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' }) + expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({ + type: 'skill', + name: 'x', + message: 'do' + }) + }) + + it('rejects malformed payloads', () => { + expect(asCommandDispatch(null)).toBeNull() + expect(asCommandDispatch({ type: 'alias' })).toBeNull() + expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull() + }) +}) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b7f8955398..6a48bc1be8 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -39,6 +39,73 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue') }) + it('drops stale slash.exec output after a newer slash', async () => { + let resolveLate: (v: { output?: string }) => void + let slashExecCalls = 0 + + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + slashExecCalls += 1 + + if (slashExecCalls === 1) { + return new Promise<{ output?: string }>(res => { + resolveLate = res + }) + } + + return Promise.resolve({ output: 'fresh' }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/slow')).toBe(true) + expect(h('/fast')).toBe(true) + resolveLate!({ output: 'too late' }) + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalled() + }) + + expect(ctx.transcript.sys).not.toHaveBeenCalledWith('too late') + }) + + it('dispatches command.dispatch with typed alias', async () => { + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + return Promise.reject(new Error('no')) + } + + if (method === 'command.dispatch') { + return Promise.resolve({ type: 'alias', target: 'help' }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/zzz')).toBe(true) + await vi.waitFor(() => { + expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array)) + }) + }) + it('resolves unique local aliases through the catalog', () => { const ctx = buildCtx({ local: { @@ -58,6 +125,7 @@ describe('createSlashHandler', () => { const buildCtx = (overrides: Partial = {}): Ctx => ({ ...overrides, + slashFlightRef: overrides.slashFlightRef ?? { current: 0 }, composer: { ...buildComposer(), ...overrides.composer }, gateway: { ...buildGateway(), ...overrides.gateway }, local: { ...buildLocal(), ...overrides.local }, @@ -114,6 +182,7 @@ const buildVoice = () => ({ }) interface Ctx { + slashFlightRef: { current: number } composer: ReturnType gateway: ReturnType local: ReturnType diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index ee1a709780..4968d74c29 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,1286 +1,11 @@ -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 { MOUSE_TRACKING } from './app/constants.js' import { GatewayProvider } from './app/gatewayContext.js' -import { - imageTokenMeta, - introMsg, - looksLikeSlashCommand, - resolveDetailsMode, - shortCwd, - toTranscriptMessages -} from './app/helpers.js' -import { type GatewayRpc, 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 { useMainApp } from './app/useMainApp.js' import { AppLayout } from './components/appLayout.js' -import { INTERPOLATION_RE, ZERO } from './constants.js' -import { type GatewayClient } from './gatewayClient.js' -import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from './gatewayTypes.js' -import { useVirtualHistory } from './hooks/useVirtualHistory.js' -import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' -import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js' -import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js' - -const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i -const LONG_RUN_CHARM_DELAY_MS = 8_000 -const LONG_RUN_CHARM_INTERVAL_MS = 10_000 -const LONG_RUN_CHARM_MAX = 2 - -const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] - -// ── App ────────────────────────────────────────────────────────────── +import type { GatewayClient } from './gatewayClient.js' export function App({ gw }: { gw: GatewayClient }) { - const { exit } = useApp() - const { stdout } = useStdout() - const [cols, setCols] = useState(stdout?.columns ?? 80) - - useEffect(() => { - if (!stdout) { - return - } - - const sync = () => setCols(stdout.columns ?? 80) - stdout.on('resize', sync) - - // Enable bracketed paste so image-only clipboard paste reaches the app - if (stdout.isTTY) { - stdout.write('\x1b[?2004h') - } - - return () => { - stdout.off('resize', sync) - - if (stdout.isTTY) { - stdout.write('\x1b[?2004l') - } - } - }, [stdout]) - - // ── State ──────────────────────────────────────────────────────── - - const [historyItems, setHistoryItems] = useState([]) - const [lastUserMsg, setLastUserMsg] = useState('') - const [stickyPrompt, setStickyPrompt] = useState('') - const [catalog, setCatalog] = useState(null) - const [voiceEnabled, setVoiceEnabled] = useState(false) - const [voiceRecording, setVoiceRecording] = useState(false) - const [voiceProcessing, setVoiceProcessing] = useState(false) - const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) - const [goodVibesTick, setGoodVibesTick] = useState(0) - const [bellOnComplete, setBellOnComplete] = useState(false) - const ui = useStore($uiState) - const overlay = useStore($overlayState) - const isBlocked = useStore($isBlocked) - - // ── Refs ───────────────────────────────────────────────────────── - - const slashRef = useRef<(cmd: string) => boolean>(() => false) - const lastEmptyAt = useRef(0) - const colsRef = useRef(cols) - 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) - const historyItemsRef = useRef(historyItems) - const lastUserMsgRef = useRef(lastUserMsg) - const longRunCharmRef = useRef(new Map()) - const msgIdsRef = useRef(new WeakMap()) - const nextMsgIdRef = useRef(0) - colsRef.current = cols - historyItemsRef.current = historyItems - lastUserMsgRef.current = lastUserMsg - - // ── Hooks ──────────────────────────────────────────────────────── - - const hasSelection = useHasSelection() - const selection = useSelection() - const turn = useTurnState() - const turnActions = turn.actions - const turnRefs = turn.refs - const turnState = turn.state - - const composer = useComposerState({ - gw, - onClipboardPaste: quiet => clipboardPasteRef.current(quiet), - submitRef - }) - - const composerActions = composer.actions - const composerRefs = composer.refs - const composerState = composer.state - - const empty = !historyItems.some(msg => msg.kind !== 'intro') - - const messageId = useCallback((msg: Msg) => { - const hit = msgIdsRef.current.get(msg) - - if (hit) { - return hit - } - - const next = `m${++nextMsgIdRef.current}` - msgIdsRef.current.set(msg, next) - - return next - }, []) - - const virtualRows = useMemo( - () => - historyItems.map((msg, index) => ({ - index, - key: messageId(msg), - msg - })), - [historyItems, messageId] - ) - - const virtualHistory = useVirtualHistory(scrollRef, virtualRows) - - const scrollWithSelection = useCallback( - (delta: number) => { - const s = scrollRef.current - - const sel = selection.getState() as { - anchor?: { row: number } - focus?: { row: number } - isDragging?: boolean - } | null - - if (!s || !sel?.anchor || !sel.focus) { - s?.scrollBy(delta) - - return - } - - const top = s.getViewportTop() - const bottom = top + s.getViewportHeight() - 1 - - if (sel.anchor.row < top || sel.anchor.row > bottom) { - s.scrollBy(delta) - - return - } - - if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { - s.scrollBy(delta) - - return - } - - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) - const cur = s.getScrollTop() + s.getPendingDelta() - const actual = Math.max(0, Math.min(max, cur + delta)) - cur - - if (actual === 0) { - return - } - - if (actual > 0) { - selection.captureScrolledRows(top, top + actual - 1, 'above') - sel.isDragging ? selection.shiftAnchor(-actual, top, bottom) : selection.shiftSelection(-actual, top, bottom) - } else { - const amount = -actual - selection.captureScrolledRows(bottom - amount + 1, bottom, 'below') - sel.isDragging ? selection.shiftAnchor(amount, top, bottom) : selection.shiftSelection(amount, top, bottom) - } - - s.scrollBy(delta) - }, - [selection] - ) - - // ── Core actions ───────────────────────────────────────────────── - - const appendMessage = useCallback((msg: Msg) => { - const cap = (items: Msg[]) => - items.length <= MAX_HISTORY - ? items - : items[0]?.kind === 'intro' - ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] - : items.slice(-MAX_HISTORY) - - setHistoryItems(prev => cap([...prev, msg])) - }, []) - - const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - - const page = useCallback((text: string, title?: string) => { - const lines = text.split('\n') - patchOverlayState({ pager: { lines, offset: 0, title } }) - }, []) - - const panel = useCallback( - (title: string, sections: PanelSection[]) => { - appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) - }, - [appendMessage] - ) - - const maybeWarn = useCallback( - (value: any) => { - if (value?.warning) { - sys(`warning: ${value.warning}`) - } - }, - [sys] - ) - - const maybeGoodVibes = useCallback((text: string) => { - if (!GOOD_VIBES_RE.test(text)) { - return - } - - setGoodVibesTick(v => v + 1) - }, []) - - const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { - const display = cfg?.config?.display ?? {} - - setBellOnComplete(!!display?.bell_on_complete) - patchUiState({ - compact: !!display?.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display?.tui_statusbar !== false - }) - }, []) - - const rpc: GatewayRpc = useCallback( - async = Record>( - method: string, - params: Record = {} - ) => { - try { - const result = asRpcResult(await gw.request(method, params)) - - if (result) { - return result - } - - sys(`error: invalid response: ${method}`) - } catch (e) { - sys(`error: ${rpcErrorMessage(e)}`) - } - - return null - }, - [gw, sys] - ) - - const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) - - // ── Resize RPC ─────────────────────────────────────────────────── - - useEffect(() => { - if (!ui.sid || !stdout) { - return - } - - const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) - stdout.on('resize', onResize) - - return () => { - stdout.off('resize', onResize) - } - }, [rpc, stdout, ui.sid]) - - 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)) - - turnRefs.turnToolsRef.current = nextTrail - turnActions.setTurnTrail(nextTrail) - - rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { - if (!r) { - return - } - - if (answer) { - turnRefs.persistedToolLabelsRef.current.add(label) - appendMessage({ - role: 'system', - text: '', - kind: 'trail', - tools: [buildToolTrailLine('clarify', clarify.question)] - }) - appendMessage({ role: 'user', text: answer }) - patchUiState({ status: 'running…' }) - } else { - sys('prompt cancelled') - } - - patchOverlayState({ clarify: null }) - }) - }, - [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs] - ) - - useEffect(() => { - if (!ui.sid) { - return - } - - rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) - rpc('config.get', { key: 'mtime' }).then(r => { - configMtimeRef.current = Number(r?.mtime ?? 0) - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - }, [applyDisplayConfig, rpc, ui.sid]) - - useEffect(() => { - if (!ui.sid) { - return - } - - const id = setInterval(() => { - rpc('config.get', { key: 'mtime' }).then(r => { - const next = Number(r?.mtime ?? 0) - - if (configMtimeRef.current && next && next !== configMtimeRef.current) { - configMtimeRef.current = next - rpc('reload.mcp', { session_id: ui.sid }).then(r => { - if (!r) { - return - } - - turnActions.pushActivity('MCP reloaded after config change') - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - } else if (!configMtimeRef.current && next) { - configMtimeRef.current = next - } - }) - }, 5000) - - return () => clearInterval(id) - }, [applyDisplayConfig, turnActions, rpc, ui.sid]) - - const idle = turnActions.idle - const clearReasoning = turnActions.clearReasoning - - const die = useCallback(() => { - gw.kill() - exit() - }, [exit, gw]) - - const resetSession = useCallback(() => { - idle() - clearReasoning() - setVoiceRecording(false) - setVoiceProcessing(false) - patchUiState({ - bgTasks: new Set(), - info: null, - sid: null, - usage: ZERO - }) - setHistoryItems([]) - setLastUserMsg('') - setStickyPrompt('') - 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 = useCallback( - (info: SessionInfo | null = null) => { - idle() - clearReasoning() - 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 = useCallback((items: Msg[]) => { - const q = [...items] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - if (q.at(-1)?.role === 'user') { - q.pop() - } - - return q - }, []) - - const guardBusySessionSwitch = useCallback( - (what = 'switch sessions') => { - if (!getUiState().busy) { - return false - } - - sys(`interrupt the current turn before trying to ${what}`) - - return true - }, - [sys] - ) - - const closeSession = useCallback( - (targetSid?: string | null) => { - if (!targetSid) { - return Promise.resolve(null) - } - - return rpc('session.close', { session_id: targetSid }) - }, - [rpc] - ) - - // ── Session management ─────────────────────────────────────────── - - const newSession = useCallback( - async (msg?: string) => { - await closeSession(getUiState().sid) - - return rpc('session.create', { cols: colsRef.current }).then(r => { - if (!r) { - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO - }) - - if (r.info) { - setHistoryItems([introMsg(r.info)]) - } - - if (r.info?.credential_warning) { - sys(`warning: ${r.info.credential_warning}`) - } - - if (msg) { - sys(msg) - } - }) - }, - [closeSession, resetSession, rpc, sys] - ) - - const resumeById = useCallback( - (id: string) => { - 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) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: session.resume') - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - const resumed = toTranscriptMessages(r.messages) - - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - 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}`) - patchUiState({ status: 'ready' }) - }) - ) - }, - [closeSession, gw, resetSession, sys] - ) - - // ── Paste pipeline ─────────────────────────────────────────────── - - const paste = useCallback( - (quiet = false) => - rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => { - if (!r) { - return - } - - if (r.attached) { - const meta = imageTokenMeta(r) - sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) - - return - } - - quiet || sys(r.message || 'No image found in clipboard') - }), - [rpc, sys] - ) - - clipboardPasteRef.current = paste - const handleTextPaste = composerActions.handleTextPaste - - // ── Send ───────────────────────────────────────────────────────── - - const send = useCallback( - (text: string) => { - const expandPasteSnips = (value: string) => { - const byLabel = new Map() - - 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) - } - - const startSubmit = (displayText: string, submitText: string) => { - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - if (turnRefs.statusTimerRef.current) { - clearTimeout(turnRefs.statusTimerRef.current) - turnRefs.statusTimerRef.current = null - } - - maybeGoodVibes(submitText) - setLastUserMsg(text) - appendMessage({ role: 'user', text: displayText }) - patchUiState({ busy: true, status: 'running…' }) - turnRefs.bufRef.current = '' - turnRefs.interruptedRef.current = false - - gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ busy: false, status: 'ready' }) - }) - } - - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - gw.request('input.detect_drop', { session_id: sid, text }) - .then((r: any) => { - if (r?.matched) { - if (r.is_image) { - const meta = imageTokenMeta(r) - turnActions.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - } else { - turnActions.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, maybeGoodVibes, turnActions, 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' }) - composerActions.pushHistory(full) - slashRef.current(full) - composerActions.clearIn() - - return - } - - if (full.startsWith('!')) { - composerActions.clearIn() - shellExec(full.slice(1).trim()) - - return - } - - const editIdx = composerRefs.queueEditRef.current - composerActions.clearIn() - - if (editIdx !== null) { - composerActions.replaceQueue(editIdx, full) - const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] - composerActions.syncQueue() - composerActions.setQueueEdit(null) - - if (picked && getUiState().busy && live.sid) { - composerRefs.queueRef.current.unshift(picked) - composerActions.syncQueue() - - return - } - - if (picked && live.sid) { - sendQueued(picked) - } - - return - } - - composerActions.pushHistory(full) - - if (getUiState().busy) { - composerActions.enqueue(full) - - return - } - - if (hasInterpolation(full)) { - patchUiState({ busy: true }) - interpolate(full, send) - - return - } - - send(full) - }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] - ) - - // ── Input handling ─────────────────────────────────────────────── - 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 = 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 - }, - turn: { - actions: { - clearReasoning, - endReasoningPhase: turnActions.endReasoningPhase, - idle, - pruneTransient: turnActions.pruneTransient, - pulseReasoningStreaming: turnActions.pulseReasoningStreaming, - pushActivity: turnActions.pushActivity, - pushTrail: turnActions.pushTrail, - scheduleReasoning: turnActions.scheduleReasoning, - scheduleStreaming: turnActions.scheduleStreaming, - setActivity: turnActions.setActivity, - setReasoningTokens: turnActions.setReasoningTokens, - setStreaming: turnActions.setStreaming, - setSubagents: turnActions.setSubagents, - setToolTokens: turnActions.setToolTokens, - setTools: turnActions.setTools, - setTurnTrail: turnActions.setTurnTrail - }, - refs: { - activeToolsRef: turnRefs.activeToolsRef, - bufRef: turnRefs.bufRef, - interruptedRef: turnRefs.interruptedRef, - lastStatusNoteRef: turnRefs.lastStatusNoteRef, - persistedToolLabelsRef: turnRefs.persistedToolLabelsRef, - protocolWarnedRef: turnRefs.protocolWarnedRef, - reasoningRef: turnRefs.reasoningRef, - statusTimerRef: turnRefs.statusTimerRef, - toolTokenAccRef: turnRefs.toolTokenAccRef, - toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, - turnToolsRef: turnRefs.turnToolsRef - } - } - }), - [ - appendMessage, - bellOnComplete, - clearReasoning, - composerActions, - composerRefs, - gateway, - idle, - newSession, - resetSession, - sendQueued, - sys, - turnActions, - turnRefs, - stdout - ] - ) - - onEventRef.current = onEvent - - useEffect(() => { - const handler = (ev: GatewayEvent) => onEventRef.current(ev) - - const exitHandler = () => { - patchUiState({ busy: false, sid: null, status: 'gateway exited' }) - turnActions.pushActivity('gateway exited · /logs to inspect', 'error') - sys('error: gateway exited') - } - - gw.on('event', handler) - gw.on('exit', exitHandler) - gw.drain() - - return () => { - gw.off('event', handler) - gw.off('exit', exitHandler) - gw.kill() - } - }, [gw, turnActions, sys]) - - useEffect(() => { - if (!ui.busy || !turnState.tools.length) { - longRunCharmRef.current.clear() - - return - } - - const tick = () => { - const now = Date.now() - const liveIds = new Set(turnState.tools.map(tool => tool.id)) - - for (const key of [...longRunCharmRef.current.keys()]) { - if (!liveIds.has(key)) { - longRunCharmRef.current.delete(key) - } - } - - for (const tool of turnState.tools) { - if (!tool.startedAt || now - tool.startedAt < LONG_RUN_CHARM_DELAY_MS) { - continue - } - - const slot = longRunCharmRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } - - if (slot.count >= LONG_RUN_CHARM_MAX || now - slot.lastAt < LONG_RUN_CHARM_INTERVAL_MS) { - continue - } - - slot.count += 1 - slot.lastAt = now - longRunCharmRef.current.set(tool.id, slot) - - const charm = LONG_RUN_CHARMS[Math.floor(Math.random() * LONG_RUN_CHARMS.length)]! - const sec = Math.round((now - tool.startedAt) / 1000) - turnActions.pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) - } - } - - tick() - const id = setInterval(tick, 1000) - - return () => clearInterval(id) - }, [turnActions, turnState.tools, ui.busy]) - - // ── Slash commands ─────────────────────────────────────────────── - - const slash = useMemo( - () => - createSlashHandler({ - composer: { - enqueue: composerActions.enqueue, - hasSelection, - paste, - queueRef: composerRefs.queueRef, - selection, - setInput: composerActions.setInput - }, - gateway, - local: { - catalog, - getHistoryItems: () => historyItemsRef.current, - getLastUserMsg: () => lastUserMsgRef.current, - maybeWarn - }, - session: { - closeSession, - die, - guardBusySessionSwitch, - newSession, - resetVisibleHistory, - resumeById, - setSessionStartedAt - }, - transcript: { - page, - panel, - send, - setHistoryItems, - sys, - trimLastExchange - }, - voice: { - setVoiceEnabled - } - }), - [ - catalog, - closeSession, - composerActions, - composerRefs, - die, - gateway, - guardBusySessionSwitch, - hasSelection, - maybeWarn, - newSession, - page, - panel, - paste, - resetVisibleHistory, - resumeById, - selection, - send, - setSessionStartedAt, - setHistoryItems, - setVoiceEnabled, - sys, - trimLastExchange - ] - ) - - slashRef.current = slash - - // ── Submit ─────────────────────────────────────────────────────── - - const submit = useCallback( - (value: string) => { - if (value.startsWith('/') && composerState.completions.length) { - const row = composerState.completions[composerState.compIdx] - - if (row?.text) { - const text = - value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0 - ? row.text.slice(1) - : row.text - - const next = value.slice(0, composerState.compReplace) + text - - if (next !== value) { - composerActions.setInput(next) - - return - } - } - } - - if (!value.trim() && !composerState.inputBuf.length) { - const live = getUiState() - const now = Date.now() - const dbl = now - lastEmptyAt.current < 450 - lastEmptyAt.current = now - - if (dbl && live.busy && live.sid) { - turnActions.interruptTurn({ - appendMessage, - gw, - sid: live.sid, - sys - }) - - return - } - - if (dbl && composerRefs.queueRef.current.length) { - const next = composerActions.dequeue() - - if (next && live.sid) { - composerActions.setQueueEdit(null) - dispatchSubmission(next) - } - } - - return - } - - lastEmptyAt.current = 0 - - if (value.endsWith('\\')) { - composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) - composerActions.setInput('') - - return - } - - dispatchSubmission([...composerState.inputBuf, value].join('\n')) - }, - [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions] - ) - - submitRef.current = submit - - // ── Derived ────────────────────────────────────────────────────── - - const statusColor = - 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 sessionStarted = ui.sid ? sessionStartedAt : null - const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` - const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) - const showStreamingArea = Boolean(turnState.streaming) - const showStickyPrompt = !!stickyPrompt - - const hasReasoning = Boolean(turnState.reasoning.trim()) - - const showProgressArea = - ui.detailsMode === 'hidden' - ? turnState.activity.some(item => item.tone !== 'info') - : Boolean( - ui.busy || - turnState.subagents.length || - 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}`) - }, []) - - const appActions = useMemo( - () => ({ - answerApproval, - answerClarify, - answerSecret, - answerSudo, - onModelSelect, - resumeById, - setStickyPrompt - }), - [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt] - ) - - const appComposer = useMemo( - () => ({ - cols, - compIdx: composerState.compIdx, - completions: composerState.completions, - empty, - handleTextPaste, - input: composerState.input, - inputBuf: composerState.inputBuf, - pagerPageSize, - queueEditIdx: composerState.queueEditIdx, - queuedDisplay: composerState.queuedDisplay, - submit, - updateInput: composerActions.setInput - }), - [ - cols, - composerActions.setInput, - composerState.compIdx, - composerState.completions, - composerState.input, - composerState.inputBuf, - composerState.queueEditIdx, - composerState.queuedDisplay, - empty, - handleTextPaste, - pagerPageSize, - submit - ] - ) - - const appProgress = useMemo( - () => ({ - activity: turnState.activity, - reasoning: turnState.reasoning, - reasoningActive: turnState.reasoningActive, - reasoningStreaming: turnState.reasoningStreaming, - reasoningTokens: turnState.reasoningTokens, - showProgressArea, - showStreamingArea, - streaming: turnState.streaming, - subagents: turnState.subagents, - toolTokens: turnState.toolTokens, - tools: turnState.tools, - turnTrail: turnState.turnTrail - }), - [showProgressArea, showStreamingArea, turnState] - ) - - const appStatus = useMemo( - () => ({ - cwdLabel, - goodVibesTick, - sessionStartedAt: sessionStarted, - showStickyPrompt, - statusColor, - stickyPrompt, - voiceLabel - }), - [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] - ) - - const appTranscript = useMemo( - () => ({ - historyItems, - scrollRef, - virtualHistory, - virtualRows - }), - [historyItems, scrollRef, virtualHistory, virtualRows] - ) - - // ── Render ─────────────────────────────────────────────────────── + const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw) return ( diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index b208043805..1a23943c09 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,9 +1,11 @@ -import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { SlashExecResponse } from '../gatewayTypes.js' +import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' import type { SlashHandlerContext } from './interfaces.js' import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js' import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js' import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js' +import { isStaleSlash } from './slash/isStaleSlash.js' import { createSlashShared, parseSlashCommand } from './slash/shared.js' import { getUiState } from './uiStore.js' @@ -11,14 +13,16 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b const { gw } = ctx.gateway const { catalog } = ctx.local const { send, sys } = ctx.transcript - const shared = createSlashShared({ ...ctx.transcript, gw }) + const shared = createSlashShared({ ...ctx.transcript, gw, slashFlightRef: ctx.slashFlightRef }) const handleCore = createSlashCoreHandler(ctx) const handleSession = createSlashSessionHandler(ctx, shared) const handleOps = createSlashOpsHandler(ctx) const handler = (cmd: string): boolean => { + const flight = ++ctx.slashFlightRef.current const ui = getUiState() - const parsed = { ...parseSlashCommand(cmd), sid: ui.sid, ui } + const sidAtSend = ui.sid + const parsed = { ...parseSlashCommand(cmd), flight, sid: sidAtSend, ui } const argTail = parsed.arg ? ` ${parsed.arg}` : '' if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) { @@ -47,8 +51,12 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } - gw.request('slash.exec', { command: cmd.slice(1), session_id: ui.sid }) - .then((r: any) => { + gw.request('slash.exec', { command: cmd.slice(1), session_id: sidAtSend }) + .then(r => { + if (isStaleSlash(ctx, flight, sidAtSend)) { + return + } + sys( r?.warning ? `warning: ${r.warning}\n${r?.output || `/${parsed.name}: no output`}` @@ -56,11 +64,15 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b ) }) .catch(() => { - gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: ui.sid }) - .then((raw: any) => { - const d = asRpcResult(raw) + gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: sidAtSend }) + .then((raw: unknown) => { + if (isStaleSlash(ctx, flight, sidAtSend)) { + return + } - if (!d?.type) { + const d = asCommandDispatch(raw) + + if (!d) { sys('error: invalid response: command.dispatch') return @@ -80,7 +92,13 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (isStaleSlash(ctx, flight, sidAtSend)) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) }) return true diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 719116cb8d..aa7e28d4dd 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -320,6 +320,7 @@ export interface GatewayEventHandlerContext { } export interface SlashHandlerContext { + slashFlightRef: MutableRefObject composer: { enqueue: (text: string) => void hasSelection: boolean diff --git a/ui-tui/src/app/slash/createSlashCoreHandler.ts b/ui-tui/src/app/slash/createSlashCoreHandler.ts index cb980248a4..7cb893c4c9 100644 --- a/ui-tui/src/app/slash/createSlashCoreHandler.ts +++ b/ui-tui/src/app/slash/createSlashCoreHandler.ts @@ -6,6 +6,8 @@ import type { SlashHandlerContext } from '../interfaces.js' import { patchOverlayState } from '../overlayStore.js' import { patchUiState } from '../uiStore.js' +import { isStaleSlash } from './isStaleSlash.js' + const FORTUNES = [ 'you are one clean refactor away from clarity', 'a tiny rename today prevents a huge bug tomorrow', @@ -53,7 +55,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { const { guardBusySessionSwitch, newSession, resumeById } = ctx.session const { panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript - return ({ arg, name, sid, ui }: SlashCommand) => { + return ({ arg, flight, name, sid, ui }: SlashCommand) => { switch (name) { case 'help': { const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({ @@ -132,12 +134,22 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { ctx.gateway .rpc('config.get', { key: 'details_mode' }) .then((r: any) => { + if (isStaleSlash(ctx, flight, sid)) { + return + } + const mode = parseDetailsMode(r?.value) ?? ui.detailsMode patchUiState({ detailsMode: mode }) sys(`details: ${mode}`) }) - .catch(() => sys(`details: ${ui.detailsMode}`)) + .catch(() => { + if (isStaleSlash(ctx, flight, sid)) { + return + } + + sys(`details: ${ui.detailsMode}`) + }) return true } @@ -265,7 +277,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { } ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { + if (isStaleSlash(ctx, flight, sid) || !r) { return } @@ -294,7 +306,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { } ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { + if (isStaleSlash(ctx, flight, sid) || !r) { return } @@ -318,6 +330,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { interface SlashCommand { arg: string + flight: number name: string sid: null | string ui: { diff --git a/ui-tui/src/app/slash/createSlashOpsHandler.ts b/ui-tui/src/app/slash/createSlashOpsHandler.ts index 4627244e3b..9b8a277be5 100644 --- a/ui-tui/src/app/slash/createSlashOpsHandler.ts +++ b/ui-tui/src/app/slash/createSlashOpsHandler.ts @@ -3,6 +3,7 @@ import { rpcErrorMessage } from '../../lib/rpc.js' import type { PanelSection } from '../../types.js' import type { SlashHandlerContext } from '../interfaces.js' +import { isStaleSlash } from './isStaleSlash.js' import type { ParsedSlashCommand } from './shared.js' export function createSlashOpsHandler(ctx: SlashHandlerContext) { @@ -10,14 +11,16 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { const { resetVisibleHistory, setSessionStartedAt } = ctx.session const { panel, sys } = ctx.transcript - return ({ arg, cmd, name, sid }: OpsSlashCommand) => { + return ({ arg, cmd, flight, name, sid }: OpsSlashCommand) => { + const stale = () => isStaleSlash(ctx, flight, sid) + switch (name) { case 'rollback': { const [sub, ...rest] = (arg || 'list').split(/\s+/) if (!sub || sub === 'list') { rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -46,7 +49,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { session_id: sid, hash, ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => r && sys(r.rendered || r.diff || r.message || 'done')) + }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(r.rendered || r.diff || r.message || 'done') + }) return true } @@ -54,16 +63,20 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'browser': { const [action, ...rest] = (arg || 'status').split(/\s+/) - rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then( - (r: any) => r && sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - ) + rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') + }) return true } case 'plugins': rpc('plugins.list', {}).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -86,7 +99,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (!sub || sub === 'list') { rpc('skills.manage', { action: 'list' }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -111,7 +124,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 rpc('skills.manage', { action: 'browse', page: pageNumber }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -149,14 +162,24 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { ctx.gateway.gw .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + sys( r?.warning ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` : r?.output || '/skills: no output' ) - ) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -166,7 +189,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'tasks': rpc('agents.list', {}) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -188,7 +211,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { !sections.length && sections.push({ text: 'No active processes' }) panel('Agents', sections) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true @@ -196,7 +225,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (!arg || arg === 'list') { rpc('cron.manage', { action: 'list' }) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -217,14 +246,30 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { } ]) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) } else { ctx.gateway.gw .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') - ) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) } return true @@ -232,7 +277,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'config': rpc('config.show', {}) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -244,7 +289,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { })) ) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true case 'tools': { @@ -253,6 +304,10 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (!subcommand) { rpc('tools.show', { session_id: sid }) .then(r => { + if (stale()) { + return + } + if (!r?.sections?.length) { sys('no tools') @@ -267,7 +322,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { })) ) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -275,6 +336,10 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (subcommand === 'list') { rpc('tools.list', { session_id: sid }) .then(r => { + if (stale()) { + return + } + if (!r?.toolsets?.length) { sys('no tools') @@ -289,7 +354,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { })) ) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -309,7 +380,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { session_id: sid }) .then(r => { - if (!r) { + if (stale() || !r) { return } @@ -323,7 +394,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { r.missing_servers?.length && sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) r.reset && sys('session reset. new tool configuration is active.') }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -336,7 +413,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'toolsets': rpc('toolsets.list', { session_id: sid }) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -358,7 +435,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { } ]) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -368,5 +451,6 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { } interface OpsSlashCommand extends ParsedSlashCommand { + flight: number sid: null | string } diff --git a/ui-tui/src/app/slash/createSlashSessionHandler.ts b/ui-tui/src/app/slash/createSlashSessionHandler.ts index 5fa817f5a1..d4c1e404fd 100644 --- a/ui-tui/src/app/slash/createSlashSessionHandler.ts +++ b/ui-tui/src/app/slash/createSlashSessionHandler.ts @@ -7,6 +7,7 @@ import type { SlashHandlerContext } from '../interfaces.js' import { patchOverlayState } from '../overlayStore.js' import { patchUiState } from '../uiStore.js' +import { isStaleSlash } from './isStaleSlash.js' import type { ParsedSlashCommand, SlashShared } from './shared.js' const SLASH_OUTPUT_PAGE: Record = { @@ -24,11 +25,12 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas const { page, panel, setHistoryItems, sys } = ctx.transcript const { setVoiceEnabled } = ctx.voice - return ({ arg, cmd, name, sid }: SessionSlashCommand) => { + return ({ arg, cmd, flight, name, sid }: SessionSlashCommand) => { + const stale = () => isStaleSlash(ctx, flight, sid) const pageTitle = SLASH_OUTPUT_PAGE[name] if (pageTitle) { - shared.showSlashOutput(pageTitle, cmd.slice(1), sid) + shared.showSlashOutput({ command: cmd.slice(1), flight, sid, title: pageTitle }) return true } @@ -44,6 +46,10 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } rpc('prompt.background', { session_id: sid, text: arg }).then(r => { + if (stale()) { + return + } + const taskId = r?.task_id if (!taskId) { @@ -64,7 +70,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -86,7 +92,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -108,7 +114,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'image': rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -122,56 +128,94 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + page( r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', 'Provider' ) - ) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true case 'skin': if (arg) { - rpc('config.set', { key: 'skin', value: arg }).then((r: any) => r?.value && sys(`skin → ${r.value}`)) + rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { + if (stale() || !r?.value) { + return + } + + sys(`skin → ${r.value}`) + }) } else { - rpc('config.get', { key: 'skin' }).then((r: any) => r && sys(`skin: ${r.value || 'default'}`)) + rpc('config.get', { key: 'skin' }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`skin: ${r.value || 'default'}`) + }) } return true case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then( - (r: any) => r && sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - ) + rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) + }) return true case 'reasoning': if (!arg) { - rpc('config.get', { key: 'reasoning' }).then( - (r: any) => r?.value && sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) - ) + rpc('config.get', { key: 'reasoning' }).then((r: any) => { + if (stale() || !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) => r?.value && sys(`reasoning: ${r.value}`) - ) + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { + if (stale() || !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) => r?.value && sys(`verbose: ${r.value}`) - ) + rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { + if (stale() || !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) { + if (stale() || !r) { return } @@ -184,20 +228,30 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + 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)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true case 'compress': rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -220,7 +274,13 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas return true case 'stop': - rpc('process.stop', {}).then((r: any) => r && sys(`killed ${r.killed ?? 0} registered process(es)`)) + rpc('process.stop', {}).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`killed ${r.killed ?? 0} registered process(es)`) + }) return true @@ -229,7 +289,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas const prevSid = sid rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (!r?.session_id) { + if (stale() || !r?.session_id) { return } @@ -246,19 +306,33 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'reload-mcp': case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then((r: any) => r && sys('MCP reloaded')) + rpc('reload.mcp', { session_id: sid }).then((r: any) => { + if (stale() || !r) { + return + } + + sys('MCP reloaded') + }) return true case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then( - (r: any) => r && sys(`title: ${r.title || '(none)'}`) - ) + rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`title: ${r.title || '(none)'}`) + }) return true case 'usage': rpc('session.usage', { session_id: sid }).then((r: any) => { + if (stale()) { + return + } + if (r) { patchUiState({ usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 } @@ -272,6 +346,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } const f = (v: number) => (v ?? 0).toLocaleString() + const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null @@ -297,13 +372,19 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas return true case 'save': - rpc('session.save', { session_id: sid }).then((r: any) => r?.file && sys(`saved: ${r.file}`)) + rpc('session.save', { session_id: sid }).then((r: any) => { + if (stale() || !r?.file) { + return + } + + sys(`saved: ${r.file}`) + }) return true case 'history': rpc('session.history', { session_id: sid }).then(r => { - if (typeof r?.count !== 'number') { + if (stale() || typeof r?.count !== 'number') { return } @@ -329,7 +410,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'profile': rpc('config.get', { key: 'profile' }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -343,7 +424,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'voice': rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -355,7 +436,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'insights': rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -378,5 +459,6 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } interface SessionSlashCommand extends ParsedSlashCommand { + flight: number sid: null | string } diff --git a/ui-tui/src/app/slash/isStaleSlash.ts b/ui-tui/src/app/slash/isStaleSlash.ts new file mode 100644 index 0000000000..0d8386fbd5 --- /dev/null +++ b/ui-tui/src/app/slash/isStaleSlash.ts @@ -0,0 +1,10 @@ +import type { SlashHandlerContext } from '../interfaces.js' +import { getUiState } from '../uiStore.js' + +export function isStaleSlash( + ctx: Pick, + flight: number, + sid: null | string +): boolean { + return flight !== ctx.slashFlightRef.current || getUiState().sid !== sid +} diff --git a/ui-tui/src/app/slash/shared.ts b/ui-tui/src/app/slash/shared.ts index 221a7e5aea..e862045cf6 100644 --- a/ui-tui/src/app/slash/shared.ts +++ b/ui-tui/src/app/slash/shared.ts @@ -1,5 +1,8 @@ +import type { MutableRefObject } from 'react' + import type { SlashExecResponse } from '../../gatewayTypes.js' import { rpcErrorMessage } from '../../lib/rpc.js' +import { getUiState } from '../uiStore.js' export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) @@ -11,10 +14,14 @@ export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { } } -export const createSlashShared = ({ gw, page, sys }: SlashSharedDeps): SlashShared => ({ - showSlashOutput: (title, command, sid) => { +export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({ + showSlashOutput: ({ command, flight, sid, title }) => { gw.request('slash.exec', { command, session_id: sid }) .then(r => { + if (flight !== slashFlightRef.current || getUiState().sid !== sid) { + return + } + const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' const lines = text.split('\n').filter(Boolean) @@ -25,7 +32,13 @@ export const createSlashShared = ({ gw, page, sys }: SlashSharedDeps): SlashShar sys(text) } }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (flight !== slashFlightRef.current || getUiState().sid !== sid) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) } }) @@ -36,7 +49,7 @@ export interface ParsedSlashCommand { } export interface SlashShared { - showSlashOutput: (title: string, command: string, sid: null | string) => void + showSlashOutput: (opts: { command: string; flight: number; sid: null | string; title: string }) => void } interface SlashSharedDeps { @@ -44,5 +57,6 @@ interface SlashSharedDeps { request: (method: string, params?: Record) => Promise } page: (text: string, title?: string) => void + slashFlightRef: MutableRefObject sys: (text: string) => void } diff --git a/ui-tui/src/app/useLongRunToolCharms.ts b/ui-tui/src/app/useLongRunToolCharms.ts new file mode 100644 index 0000000000..63583fb70d --- /dev/null +++ b/ui-tui/src/app/useLongRunToolCharms.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react' + +import { toolTrailLabel } from '../lib/text.js' +import type { ActiveTool, ActivityItem } from '../types.js' + +const DELAY_MS = 8_000 +const INTERVAL_MS = 10_000 +const MAX = 2 +const CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] + +export function useLongRunToolCharms( + busy: boolean, + tools: ActiveTool[], + pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void +) { + const slotRef = useRef(new Map()) + + useEffect(() => { + if (!busy || !tools.length) { + slotRef.current.clear() + + return + } + + const tick = () => { + const now = Date.now() + const liveIds = new Set(tools.map(t => t.id)) + + for (const key of [...slotRef.current.keys()]) { + if (!liveIds.has(key)) { + slotRef.current.delete(key) + } + } + + for (const tool of tools) { + if (!tool.startedAt || now - tool.startedAt < DELAY_MS) { + continue + } + + const slot = slotRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } + + if (slot.count >= MAX || now - slot.lastAt < INTERVAL_MS) { + continue + } + + slot.count += 1 + slot.lastAt = now + slotRef.current.set(tool.id, slot) + + const charm = CHARMS[Math.floor(Math.random() * CHARMS.length)]! + const sec = Math.round((now - tool.startedAt) / 1000) + + pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) + } + } + + tick() + const id = setInterval(tick, 1000) + + return () => clearInterval(id) + }, [busy, pushActivity, tools]) +} diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts new file mode 100644 index 0000000000..1abc4bdde1 --- /dev/null +++ b/ui-tui/src/app/useMainApp.ts @@ -0,0 +1,1216 @@ +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { INTERPOLATION_RE, ZERO } from '../constants.js' +import { type GatewayClient } from '../gatewayClient.js' +import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from '../gatewayTypes.js' +import { useVirtualHistory } from '../hooks/useVirtualHistory.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import type { Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js' + +import { MAX_HISTORY, PASTE_SNIPPET_RE, STARTUP_RESUME_ID, WHEEL_SCROLL_STEP } from './constants.js' +import { createGatewayEventHandler } from './createGatewayEventHandler.js' +import { createSlashHandler } from './createSlashHandler.js' +import { + imageTokenMeta, + introMsg, + looksLikeSlashCommand, + resolveDetailsMode, + shortCwd, + toTranscriptMessages +} from './helpers.js' +import { type GatewayRpc, type TranscriptRow } from './interfaces.js' +import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { $uiState, getUiState, patchUiState } from './uiStore.js' +import { useComposerState } from './useComposerState.js' +import { useInputHandlers } from './useInputHandlers.js' +import { useLongRunToolCharms } from './useLongRunToolCharms.js' +import { useTurnState } from './useTurnState.js' + +const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i + +export function useMainApp(gw: GatewayClient) { + const { exit } = useApp() + const { stdout } = useStdout() + const [cols, setCols] = useState(stdout?.columns ?? 80) + + useEffect(() => { + if (!stdout) { + return + } + + const sync = () => setCols(stdout.columns ?? 80) + stdout.on('resize', sync) + + if (stdout.isTTY) { + stdout.write('\x1b[?2004h') + } + + return () => { + stdout.off('resize', sync) + + if (stdout.isTTY) { + stdout.write('\x1b[?2004l') + } + } + }, [stdout]) + + const [historyItems, setHistoryItems] = useState([]) + const [lastUserMsg, setLastUserMsg] = useState('') + const [stickyPrompt, setStickyPrompt] = useState('') + const [catalog, setCatalog] = useState(null) + const [voiceEnabled, setVoiceEnabled] = useState(false) + const [voiceRecording, setVoiceRecording] = useState(false) + const [voiceProcessing, setVoiceProcessing] = useState(false) + const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [goodVibesTick, setGoodVibesTick] = useState(0) + const [bellOnComplete, setBellOnComplete] = useState(false) + const ui = useStore($uiState) + const overlay = useStore($overlayState) + const isBlocked = useStore($isBlocked) + + const slashFlightRef = useRef(0) + const slashRef = useRef<(cmd: string) => boolean>(() => false) + const lastEmptyAt = useRef(0) + const colsRef = useRef(cols) + 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) + const historyItemsRef = useRef(historyItems) + const lastUserMsgRef = useRef(lastUserMsg) + const msgIdsRef = useRef(new WeakMap()) + const nextMsgIdRef = useRef(0) + colsRef.current = cols + historyItemsRef.current = historyItems + lastUserMsgRef.current = lastUserMsg + + const hasSelection = useHasSelection() + const selection = useSelection() + const turn = useTurnState() + const turnActions = turn.actions + const turnRefs = turn.refs + const turnState = turn.state + + const composer = useComposerState({ + gw, + onClipboardPaste: quiet => clipboardPasteRef.current(quiet), + submitRef + }) + + const composerActions = composer.actions + const composerRefs = composer.refs + const composerState = composer.state + + const empty = !historyItems.some(msg => msg.kind !== 'intro') + + const messageId = useCallback((msg: Msg) => { + const hit = msgIdsRef.current.get(msg) + + if (hit) { + return hit + } + + const next = `m${++nextMsgIdRef.current}` + msgIdsRef.current.set(msg, next) + + return next + }, []) + + const virtualRows = useMemo( + () => + historyItems.map((msg, index) => ({ + index, + key: messageId(msg), + msg + })), + [historyItems, messageId] + ) + + const virtualHistory = useVirtualHistory(scrollRef, virtualRows) + + const scrollWithSelection = useCallback( + (delta: number) => { + const s = scrollRef.current + + const sel = selection.getState() as { + anchor?: { row: number } + focus?: { row: number } + isDragging?: boolean + } | null + + if (!s || !sel?.anchor || !sel.focus) { + s?.scrollBy(delta) + + return + } + + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 + + if (sel.anchor.row < top || sel.anchor.row > bottom) { + s.scrollBy(delta) + + return + } + + if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { + s.scrollBy(delta) + + return + } + + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + const cur = s.getScrollTop() + s.getPendingDelta() + const actual = Math.max(0, Math.min(max, cur + delta)) - cur + + if (actual === 0) { + return + } + + if (actual > 0) { + selection.captureScrolledRows(top, top + actual - 1, 'above') + sel.isDragging ? selection.shiftAnchor(-actual, top, bottom) : selection.shiftSelection(-actual, top, bottom) + } else { + const amount = -actual + selection.captureScrolledRows(bottom - amount + 1, bottom, 'below') + sel.isDragging ? selection.shiftAnchor(amount, top, bottom) : selection.shiftSelection(amount, top, bottom) + } + + s.scrollBy(delta) + }, + [selection] + ) + + const appendMessage = useCallback((msg: Msg) => { + const cap = (items: Msg[]) => + items.length <= MAX_HISTORY + ? items + : items[0]?.kind === 'intro' + ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] + : items.slice(-MAX_HISTORY) + + setHistoryItems(prev => cap([...prev, msg])) + }, []) + + const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) + + const page = useCallback((text: string, title?: string) => { + const lines = text.split('\n') + patchOverlayState({ pager: { lines, offset: 0, title } }) + }, []) + + const panel = useCallback( + (title: string, sections: PanelSection[]) => { + appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) + }, + [appendMessage] + ) + + const maybeWarn = useCallback( + (value: any) => { + if (value?.warning) { + sys(`warning: ${value.warning}`) + } + }, + [sys] + ) + + const maybeGoodVibes = useCallback((text: string) => { + if (!GOOD_VIBES_RE.test(text)) { + return + } + + setGoodVibesTick(v => v + 1) + }, []) + + const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { + const display = cfg?.config?.display ?? {} + + setBellOnComplete(!!display?.bell_on_complete) + patchUiState({ + compact: !!display?.tui_compact, + detailsMode: resolveDetailsMode(display), + statusBar: display?.tui_statusbar !== false + }) + }, []) + + const rpc: GatewayRpc = useCallback( + async = Record>( + method: string, + params: Record = {} + ) => { + try { + const result = asRpcResult(await gw.request(method, params)) + + if (result) { + return result + } + + sys(`error: invalid response: ${method}`) + } catch (e) { + sys(`error: ${rpcErrorMessage(e)}`) + } + + return null + }, + [gw, sys] + ) + + const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + + useEffect(() => { + if (!ui.sid || !stdout) { + return + } + + const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) + stdout.on('resize', onResize) + + return () => { + stdout.off('resize', onResize) + } + }, [rpc, stdout, ui.sid]) + + 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)) + + turnRefs.turnToolsRef.current = nextTrail + turnActions.setTurnTrail(nextTrail) + + rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { + if (!r) { + return + } + + if (answer) { + turnRefs.persistedToolLabelsRef.current.add(label) + appendMessage({ + role: 'system', + text: '', + kind: 'trail', + tools: [buildToolTrailLine('clarify', clarify.question)] + }) + appendMessage({ role: 'user', text: answer }) + patchUiState({ status: 'running…' }) + } else { + sys('prompt cancelled') + } + + patchOverlayState({ clarify: null }) + }) + }, + [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs] + ) + + useEffect(() => { + if (!ui.sid) { + return + } + + rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) + rpc('config.get', { key: 'mtime' }).then(r => { + configMtimeRef.current = Number(r?.mtime ?? 0) + }) + rpc('config.get', { key: 'full' }).then(applyDisplayConfig) + }, [applyDisplayConfig, rpc, ui.sid]) + + useEffect(() => { + if (!ui.sid) { + return + } + + const id = setInterval(() => { + rpc('config.get', { key: 'mtime' }).then(r => { + const next = Number(r?.mtime ?? 0) + + if (configMtimeRef.current && next && next !== configMtimeRef.current) { + configMtimeRef.current = next + rpc('reload.mcp', { session_id: ui.sid }).then(r => { + if (!r) { + return + } + + turnActions.pushActivity('MCP reloaded after config change') + }) + rpc('config.get', { key: 'full' }).then(applyDisplayConfig) + } else if (!configMtimeRef.current && next) { + configMtimeRef.current = next + } + }) + }, 5000) + + return () => clearInterval(id) + }, [applyDisplayConfig, turnActions, rpc, ui.sid]) + + const idle = turnActions.idle + const clearReasoning = turnActions.clearReasoning + + const die = useCallback(() => { + gw.kill() + exit() + }, [exit, gw]) + + const resetSession = useCallback(() => { + idle() + clearReasoning() + setVoiceRecording(false) + setVoiceProcessing(false) + patchUiState({ + bgTasks: new Set(), + info: null, + sid: null, + usage: ZERO + }) + setHistoryItems([]) + setLastUserMsg('') + setStickyPrompt('') + 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 = useCallback( + (info: SessionInfo | null = null) => { + idle() + clearReasoning() + 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 = useCallback((items: Msg[]) => { + const q = [...items] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + if (q.at(-1)?.role === 'user') { + q.pop() + } + + return q + }, []) + + const guardBusySessionSwitch = useCallback( + (what = 'switch sessions') => { + if (!getUiState().busy) { + return false + } + + sys(`interrupt the current turn before trying to ${what}`) + + return true + }, + [sys] + ) + + const closeSession = useCallback( + (targetSid?: string | null) => { + if (!targetSid) { + return Promise.resolve(null) + } + + return rpc('session.close', { session_id: targetSid }) + }, + [rpc] + ) + + const newSession = useCallback( + async (msg?: string) => { + await closeSession(getUiState().sid) + + return rpc('session.create', { cols: colsRef.current }).then(r => { + if (!r) { + patchUiState({ status: 'ready' }) + + return + } + + resetSession() + setSessionStartedAt(Date.now()) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO + }) + + if (r.info) { + setHistoryItems([introMsg(r.info)]) + } + + if (r.info?.credential_warning) { + sys(`warning: ${r.info.credential_warning}`) + } + + if (msg) { + sys(msg) + } + }) + }, + [closeSession, resetSession, rpc, sys] + ) + + const resumeById = useCallback( + (id: string) => { + 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) => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.resume') + patchUiState({ status: 'ready' }) + + return + } + + resetSession() + setSessionStartedAt(Date.now()) + const resumed = toTranscriptMessages(r.messages) + + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + 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}`) + patchUiState({ status: 'ready' }) + }) + ) + }, + [closeSession, gw, resetSession, sys] + ) + + const paste = useCallback( + (quiet = false) => + rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => { + if (!r) { + return + } + + if (r.attached) { + const meta = imageTokenMeta(r) + sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) + + return + } + + quiet || sys(r.message || 'No image found in clipboard') + }), + [rpc, sys] + ) + + clipboardPasteRef.current = paste + const handleTextPaste = composerActions.handleTextPaste + + const send = useCallback( + (text: string) => { + const expandPasteSnips = (value: string) => { + const byLabel = new Map() + + 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) + } + + const startSubmit = (displayText: string, submitText: string) => { + const sid = getUiState().sid + + if (!sid) { + sys('session not ready yet') + + return + } + + if (turnRefs.statusTimerRef.current) { + clearTimeout(turnRefs.statusTimerRef.current) + turnRefs.statusTimerRef.current = null + } + + maybeGoodVibes(submitText) + setLastUserMsg(text) + appendMessage({ role: 'user', text: displayText }) + patchUiState({ busy: true, status: 'running…' }) + turnRefs.bufRef.current = '' + turnRefs.interruptedRef.current = false + + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ busy: false, status: 'ready' }) + }) + } + + const sid = getUiState().sid + + if (!sid) { + sys('session not ready yet') + + return + } + + gw.request('input.detect_drop', { session_id: sid, text }) + .then((r: any) => { + if (r?.matched) { + if (r.is_image) { + const meta = imageTokenMeta(r) + turnActions.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + } else { + turnActions.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, maybeGoodVibes, turnActions, 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] + ) + + 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' }) + composerActions.pushHistory(full) + slashRef.current(full) + composerActions.clearIn() + + return + } + + if (full.startsWith('!')) { + composerActions.clearIn() + shellExec(full.slice(1).trim()) + + return + } + + const editIdx = composerRefs.queueEditRef.current + composerActions.clearIn() + + if (editIdx !== null) { + composerActions.replaceQueue(editIdx, full) + const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] + composerActions.syncQueue() + composerActions.setQueueEdit(null) + + if (picked && getUiState().busy && live.sid) { + composerRefs.queueRef.current.unshift(picked) + composerActions.syncQueue() + + return + } + + if (picked && live.sid) { + sendQueued(picked) + } + + return + } + + composerActions.pushHistory(full) + + if (getUiState().busy) { + composerActions.enqueue(full) + + return + } + + if (hasInterpolation(full)) { + patchUiState({ busy: true }) + interpolate(full, send) + + return + } + + send(full) + }, + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] + ) + + 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 + }) + + 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 + }, + turn: { + actions: { + clearReasoning, + endReasoningPhase: turnActions.endReasoningPhase, + idle, + pruneTransient: turnActions.pruneTransient, + pulseReasoningStreaming: turnActions.pulseReasoningStreaming, + pushActivity: turnActions.pushActivity, + pushTrail: turnActions.pushTrail, + scheduleReasoning: turnActions.scheduleReasoning, + scheduleStreaming: turnActions.scheduleStreaming, + setActivity: turnActions.setActivity, + setReasoningTokens: turnActions.setReasoningTokens, + setStreaming: turnActions.setStreaming, + setSubagents: turnActions.setSubagents, + setToolTokens: turnActions.setToolTokens, + setTools: turnActions.setTools, + setTurnTrail: turnActions.setTurnTrail + }, + refs: { + activeToolsRef: turnRefs.activeToolsRef, + bufRef: turnRefs.bufRef, + interruptedRef: turnRefs.interruptedRef, + lastStatusNoteRef: turnRefs.lastStatusNoteRef, + persistedToolLabelsRef: turnRefs.persistedToolLabelsRef, + protocolWarnedRef: turnRefs.protocolWarnedRef, + reasoningRef: turnRefs.reasoningRef, + statusTimerRef: turnRefs.statusTimerRef, + toolTokenAccRef: turnRefs.toolTokenAccRef, + toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, + turnToolsRef: turnRefs.turnToolsRef + } + } + }), + [ + appendMessage, + bellOnComplete, + clearReasoning, + composerActions, + composerRefs, + gateway, + idle, + newSession, + resetSession, + sendQueued, + sys, + turnActions, + turnRefs, + stdout + ] + ) + + onEventRef.current = onEvent + + useEffect(() => { + const handler = (ev: GatewayEvent) => onEventRef.current(ev) + + const exitHandler = () => { + patchUiState({ busy: false, sid: null, status: 'gateway exited' }) + turnActions.pushActivity('gateway exited · /logs to inspect', 'error') + sys('error: gateway exited') + } + + gw.on('event', handler) + gw.on('exit', exitHandler) + gw.drain() + + return () => { + gw.off('event', handler) + gw.off('exit', exitHandler) + gw.kill() + } + }, [gw, turnActions, sys]) + + useLongRunToolCharms(ui.busy, turnState.tools, turnActions.pushActivity) + + const slash = useMemo( + () => + createSlashHandler({ + slashFlightRef, + composer: { + enqueue: composerActions.enqueue, + hasSelection, + paste, + queueRef: composerRefs.queueRef, + selection, + setInput: composerActions.setInput + }, + gateway, + local: { + catalog, + getHistoryItems: () => historyItemsRef.current, + getLastUserMsg: () => lastUserMsgRef.current, + maybeWarn + }, + session: { + closeSession, + die, + guardBusySessionSwitch, + newSession, + resetVisibleHistory, + resumeById, + setSessionStartedAt + }, + transcript: { + page, + panel, + send, + setHistoryItems, + sys, + trimLastExchange + }, + voice: { + setVoiceEnabled + } + }), + [ + catalog, + closeSession, + composerActions, + composerRefs, + die, + gateway, + slashFlightRef, + guardBusySessionSwitch, + hasSelection, + maybeWarn, + newSession, + page, + panel, + paste, + resetVisibleHistory, + resumeById, + selection, + send, + setSessionStartedAt, + setHistoryItems, + setVoiceEnabled, + sys, + trimLastExchange + ] + ) + + slashRef.current = slash + + const submit = useCallback( + (value: string) => { + if (value.startsWith('/') && composerState.completions.length) { + const row = composerState.completions[composerState.compIdx] + + if (row?.text) { + const text = + value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0 + ? row.text.slice(1) + : row.text + + const next = value.slice(0, composerState.compReplace) + text + + if (next !== value) { + composerActions.setInput(next) + + return + } + } + } + + if (!value.trim() && !composerState.inputBuf.length) { + const live = getUiState() + const now = Date.now() + const dbl = now - lastEmptyAt.current < 450 + lastEmptyAt.current = now + + if (dbl && live.busy && live.sid) { + turnActions.interruptTurn({ + appendMessage, + gw, + sid: live.sid, + sys + }) + + return + } + + if (dbl && composerRefs.queueRef.current.length) { + const next = composerActions.dequeue() + + if (next && live.sid) { + composerActions.setQueueEdit(null) + dispatchSubmission(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + if (value.endsWith('\\')) { + composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) + composerActions.setInput('') + + return + } + + dispatchSubmission([...composerState.inputBuf, value].join('\n')) + }, + [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions] + ) + + submitRef.current = submit + + const statusColor = + 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 sessionStarted = ui.sid ? sessionStartedAt : null + const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` + const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) + const showStreamingArea = Boolean(turnState.streaming) + const showStickyPrompt = !!stickyPrompt + + const hasReasoning = Boolean(turnState.reasoning.trim()) + + const showProgressArea = + ui.detailsMode === 'hidden' + ? turnState.activity.some(item => item.tone !== 'info') + : Boolean( + ui.busy || + turnState.subagents.length || + 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}`) + }, []) + + const appActions = useMemo( + () => ({ + answerApproval, + answerClarify, + answerSecret, + answerSudo, + onModelSelect, + resumeById, + setStickyPrompt + }), + [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt] + ) + + const appComposer = useMemo( + () => ({ + cols, + compIdx: composerState.compIdx, + completions: composerState.completions, + empty, + handleTextPaste, + input: composerState.input, + inputBuf: composerState.inputBuf, + pagerPageSize, + queueEditIdx: composerState.queueEditIdx, + queuedDisplay: composerState.queuedDisplay, + submit, + updateInput: composerActions.setInput + }), + [ + cols, + composerActions.setInput, + composerState.compIdx, + composerState.completions, + composerState.input, + composerState.inputBuf, + composerState.queueEditIdx, + composerState.queuedDisplay, + empty, + handleTextPaste, + pagerPageSize, + submit + ] + ) + + const appProgress = useMemo( + () => ({ + activity: turnState.activity, + reasoning: turnState.reasoning, + reasoningActive: turnState.reasoningActive, + reasoningStreaming: turnState.reasoningStreaming, + reasoningTokens: turnState.reasoningTokens, + showProgressArea, + showStreamingArea, + streaming: turnState.streaming, + subagents: turnState.subagents, + toolTokens: turnState.toolTokens, + tools: turnState.tools, + turnTrail: turnState.turnTrail + }), + [showProgressArea, showStreamingArea, turnState] + ) + + const appStatus = useMemo( + () => ({ + cwdLabel, + goodVibesTick, + sessionStartedAt: sessionStarted, + showStickyPrompt, + statusColor, + stickyPrompt, + voiceLabel + }), + [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] + ) + + const appTranscript = useMemo( + () => ({ + historyItems, + scrollRef, + virtualHistory, + virtualRows + }), + [historyItems, scrollRef, virtualHistory, virtualRows] + ) + + return { + appActions, + appComposer, + appProgress, + appStatus, + appTranscript, + gateway + } +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 2f40b33c9f..6eff78c58c 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -147,6 +147,11 @@ export interface SlashExecResponse { warning?: string } +export type CommandDispatchResponse = + | { output?: string; type: 'exec' | 'plugin' } + | { target: string; type: 'alias' } + | { message?: string; name: string; type: 'skill' } + export interface SubagentEventPayload { duration_seconds?: number goal: string diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index 8bfa7fe201..c2360dd0ca 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -1,3 +1,5 @@ +import type { CommandDispatchResponse } from '../gatewayTypes.js' + export type RpcResult = Record export const asRpcResult = (value: unknown): T | null => { @@ -8,6 +10,34 @@ export const asRpcResult = (value: unknown): T return value as T } +export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => { + const o = asRpcResult(value) + + if (!o || typeof o.type !== 'string') { + return null + } + + const t = o.type + + if (t === 'exec' || t === 'plugin') { + return { type: t, output: typeof o.output === 'string' ? o.output : undefined } + } + + if (t === 'alias' && typeof o.target === 'string') { + return { type: 'alias', target: o.target } + } + + if (t === 'skill' && typeof o.name === 'string') { + return { + type: 'skill', + name: o.name, + message: typeof o.message === 'string' ? o.message : undefined + } + } + + return null +} + export const rpcErrorMessage = (err: unknown) => { if (err instanceof Error && err.message) { return err.message