import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { fmtCwdBranch, shortCwd } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, ClipboardPasteResponse, GatewayEvent, TerminalResizeResponse } from '../gatewayTypes.js' import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { $turnState, patchTurnState } from './turnStore.js' import { $uiState, getUiState, patchUiState } from './uiStore.js' import { useComposerState } from './useComposerState.js' import { useConfigSync } from './useConfigSync.js' import { useInputHandlers } from './useInputHandlers.js' import { useLongRunToolCharms } from './useLongRunToolCharms.js' import { useSessionLifecycle } from './useSessionLifecycle.js' import { useSubmission } from './useSubmission.js' const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i const BRACKET_PASTE_ON = '\x1b[?2004h' const BRACKET_PASTE_OFF = '\x1b[?2004l' const capHistory = (items: Msg[]): Msg[] => { if (items.length <= MAX_HISTORY) { return items } return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY) } const statusColorOf = (status: string, t: { dim: string; error: string; ok: string; warn: string }) => { if (status === 'ready') { return t.ok } if (status.startsWith('error')) { return t.error } if (status === 'interrupted') { return t.warn } return t.dim } interface SelectionSnap { anchor?: { row: number } focus?: { row: number } isDragging?: boolean } 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(BRACKET_PASTE_ON) } return () => { stdout.off('resize', sync) if (stdout.isTTY) { stdout.write(BRACKET_PASTE_OFF) } } }, [stdout]) const [historyItems, setHistoryItems] = useState(() => [{ kind: 'intro', role: 'system', text: '' }]) 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 [turnStartedAt, setTurnStartedAt] = useState(null) const [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) const ui = useStore($uiState) const overlay = useStore($overlayState) const turn = useStore($turnState) const slashFlightRef = useRef(0) const slashRef = useRef<(cmd: string) => boolean>(() => false) 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 terminalHintsShownRef = useRef(new Set()) 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() useEffect(() => { selection.setSelectionBgColor(ui.theme.color.selectionBg) }, [selection, ui.theme.color.selectionBg]) const composer = useComposerState({ gw, onClipboardPaste: quiet => clipboardPasteRef.current(quiet), onImageAttached: info => { sys(attachedImageNotice(info)) }, submitRef }) const { actions: composerActions, refs: composerRefs, state: composerState } = composer const empty = !historyItems.some(msg => msg.kind !== 'intro') useEffect(() => { void terminalParityHints() .then(hints => { for (const hint of hints) { if (terminalHintsShownRef.current.has(hint.key)) { continue } terminalHintsShownRef.current.add(hint.key) turnController.pushActivity(hint.message, hint.tone) } }) .catch(() => {}) }, []) 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, cols) const scrollWithSelection = useCallback( (delta: number) => { const s = scrollRef.current if (!s) { return } const sel = selection.getState() as null | SelectionSnap const top = s.getViewportTop() const bottom = top + s.getViewportHeight() - 1 if ( !sel?.anchor || !sel.focus || sel.anchor.row < top || sel.anchor.row > bottom || (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) ) { return s.scrollBy(delta) } 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 } const shift = sel!.isDragging ? selection.shiftAnchor : selection.shiftSelection if (actual > 0) { selection.captureScrolledRows(top, top + actual - 1, 'above') } else { selection.captureScrolledRows(bottom + actual + 1, bottom, 'below') } shift(-actual, top, bottom) s.scrollBy(delta) }, [selection] ) const appendMessage = useCallback((msg: Msg) => setHistoryItems(prev => capHistory([...prev, msg])), []) const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage]) const page = useCallback( (text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }), [] ) const panel = useCallback( (title: string, sections: PanelSection[]) => appendMessage({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }), [appendMessage] ) const maybeWarn = useCallback( (value: unknown) => { const warning = (value as { warning?: unknown } | null)?.warning if (typeof warning === 'string' && warning) { sys(`warning: ${warning}`) } }, [sys] ) const maybeGoodVibes = useCallback((text: string) => { if (GOOD_VIBES_RE.test(text)) { setGoodVibesTick(v => v + 1) } }, []) 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]) const die = useCallback(() => { gw.kill() exit() }, [exit, gw]) const session = useSessionLifecycle({ colsRef, composerActions, gw, panel, rpc, scrollRef, setHistoryItems, setLastUserMsg, setSessionStartedAt, setStickyPrompt, setVoiceProcessing, setVoiceRecording, sys }) useEffect(() => { if (ui.busy) { setTurnStartedAt(prev => prev ?? Date.now()) } else { setTurnStartedAt(null) } }, [ui.busy]) useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. const model = ui.info?.model?.replace(/^.*\//, '') ?? '' const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓' const tabCwd = ui.info?.cwd useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes') useEffect(() => { if (!ui.sid || !stdout) { return } let timer: ReturnType | undefined const onResize = () => { clearTimeout(timer) timer = setTimeout(() => { timer = undefined void rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) }, 100) } stdout.on('resize', onResize) return () => { clearTimeout(timer) stdout.off('resize', onResize) } }, [rpc, stdout, ui.sid]) const answerClarify = useCallback( (answer: string) => { const clarify = overlay.clarify if (!clarify) { return } const label = toolTrailLabel('clarify') turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line)) patchTurnState({ turnTrail: turnController.turnTools }) rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { if (!r) { return } if (answer) { turnController.persistedToolLabels.add(label) appendMessage({ kind: 'trail', role: 'system', text: '', 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] ) const paste = useCallback( (quiet = false) => rpc('clipboard.paste', { session_id: getUiState().sid }).then(r => { if (!r) { return } if (r.attached) { const meta = imageTokenMeta(r) return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) } if (!quiet) { sys(r.message || 'No image found in clipboard') } }), [rpc, sys] ) clipboardPasteRef.current = paste const { dispatchSubmission, send, sendQueued, shellExec, submit } = useSubmission({ appendMessage, composerActions, composerRefs, composerState, gw, maybeGoodVibes, setLastUserMsg, slashRef, submitRef, sys }) // Drain one queued message whenever the session settles (busy → false): // agent turn ends, interrupt, shell.exec finishes, error recovered, or the // session first comes up with pre-queued messages. Without this, shell.exec // and error paths never emit message.complete, so anything enqueued while // `!sleep` / a failed turn was running would stay stuck forever. useEffect(() => { if ( !ui.sid || ui.busy || composerRefs.queueEditRef.current !== null || composerRefs.queueRef.current.length === 0 ) { return } const next = composerActions.dequeue() if (next) { sendQueued(next) } }, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued]) const { pagerPageSize } = useInputHandlers({ actions: { answerClarify, appendMessage, die, dispatchSubmission, guardBusySessionSwitch: session.guardBusySessionSwitch, newSession: session.newSession, sys }, composer: { actions: composerActions, refs: composerRefs, state: composerState }, gateway, terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout }, voice: { enabled: voiceEnabled, recording: voiceRecording, setProcessing: setVoiceProcessing, setRecording: setVoiceRecording, setVoiceEnabled }, wheelStep: WHEEL_SCROLL_STEP }) const onEvent = useMemo( () => createGatewayEventHandler({ composer: { setInput: composerActions.setInput }, gateway, session: { STARTUP_RESUME_ID, colsRef, newSession: session.newSession, resetSession: session.resetSession, resumeById: session.resumeById, setCatalog }, submission: { submitRef }, system: { bellOnComplete, stdout, sys }, transcript: { appendMessage, panel, setHistoryItems }, voice: { setProcessing: setVoiceProcessing, setRecording: setVoiceRecording, setVoiceEnabled } }), [ appendMessage, bellOnComplete, composerActions.setInput, gateway, panel, session.newSession, session.resetSession, session.resumeById, setVoiceEnabled, setVoiceProcessing, setVoiceRecording, stdout, submitRef, sys ] ) onEventRef.current = onEvent useEffect(() => { const handler = (ev: GatewayEvent) => onEventRef.current(ev) const exitHandler = () => { turnController.reset() patchUiState({ busy: false, sid: null, status: 'gateway exited' }) turnController.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, sys]) useLongRunToolCharms(ui.busy, turn.tools) 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: session.closeSession, die, guardBusySessionSwitch: session.guardBusySessionSwitch, newSession: session.newSession, resetVisibleHistory: session.resetVisibleHistory, resumeById: session.resumeById, setSessionStartedAt }, slashFlightRef, transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange }, voice: { setVoiceEnabled } }), [ catalog, composerActions, composerRefs, die, gateway, hasSelection, maybeWarn, page, panel, paste, selection, send, session, sys ] ) slashRef.current = slash const respondWith = useCallback( (method: string, params: Record, done: () => void) => rpc(method, params).then(r => r && done()), [rpc] ) const answerApproval = useCallback( (choice: string) => respondWith('approval.respond', { choice, session_id: ui.sid }, () => { patchOverlayState({ approval: null }) patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` }) patchUiState({ status: 'running…' }) }), [respondWith, ui.sid] ) const answerSudo = useCallback( (pw: string) => { if (!overlay.sudo) { return } return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) }, [overlay.sudo, respondWith] ) const answerSecret = useCallback( (value: string) => { if (!overlay.secret) { return } return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) }, [overlay.secret, respondWith] ) const onModelSelect = useCallback((value: string) => { patchOverlayState({ modelPicker: false }) slashRef.current(`/model ${value}`) }, []) const hasReasoning = Boolean(turn.reasoning.trim()) // Per-section overrides win over the global mode — when every section is // resolved to hidden, the only thing ToolTrail will surface is the // floating-alert backstop (errors/warnings). Mirror that so we don't // render an empty wrapper Box above the streaming area in quiet mode. const anyPanelVisible = SECTION_NAMES.some(s => sectionMode(s, ui.detailsMode, ui.sections) !== 'hidden') const showProgressArea = anyPanelVisible ? Boolean( ui.busy || turn.outcome || turn.streamPendingTools.length || turn.streamSegments.length || turn.subagents.length || turn.tools.length || turn.turnTrail.length || hasReasoning || turn.activity.length ) : turn.activity.some(item => item.tone !== 'info') const appActions = useMemo( () => ({ answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById: session.resumeById, setStickyPrompt }), [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, session.resumeById] ) const appComposer = useMemo( () => ({ cols, compIdx: composerState.compIdx, completions: composerState.completions, empty, handleTextPaste: composerActions.handleTextPaste, input: composerState.input, inputBuf: composerState.inputBuf, pagerPageSize, queueEditIdx: composerState.queueEditIdx, queuedDisplay: composerState.queuedDisplay, submit, updateInput: composerActions.setInput }), [cols, composerActions, composerState, empty, pagerPageSize, submit] ) const liveTailVisible = (() => { const s = scrollRef.current if (!s) { return true } const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) const vp = Math.max(0, s.getViewportHeight()) const total = Math.max(vp, s.getScrollHeight()) return top + vp >= total - 3 })() const liveProgress = useMemo( () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), [turn, showProgressArea] ) const frozenProgressRef = useRef(liveProgress) // Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI. if (liveTailVisible || !ui.busy) { frozenProgressRef.current = liveProgress } const appProgress = liveTailVisible || !ui.busy ? liveProgress : frozenProgressRef.current const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd() const gitBranch = useGitBranch(cwd) const appStatus = useMemo( () => ({ cwdLabel: fmtCwdBranch(cwd, gitBranch), goodVibesTick, sessionStartedAt: ui.sid ? sessionStartedAt : null, showStickyPrompt: !!stickyPrompt, statusColor: statusColorOf(ui.status, ui.theme.color), stickyPrompt, turnStartedAt: ui.sid ? turnStartedAt : null, // CLI parity: the classic prompt_toolkit status bar shows a red dot // on REC (cli.py:_get_voice_status_fragments line 2344). voiceLabel: voiceRecording ? '● REC' : voiceProcessing ? '◉ STT' : `voice ${voiceEnabled ? 'on' : 'off'}` }), [ cwd, gitBranch, goodVibesTick, sessionStartedAt, stickyPrompt, turnStartedAt, ui, voiceEnabled, voiceProcessing, voiceRecording ] ) const appTranscript = useMemo( () => ({ historyItems, scrollRef, virtualHistory, virtualRows }), [historyItems, virtualHistory, virtualRows] ) return { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } }