feat: refactor by splitting up app and doing proper state

This commit is contained in:
Brooklyn Nicholson
2026-04-14 22:30:18 -05:00
parent 4cbf54fb33
commit 99d859ce4a
27 changed files with 4087 additions and 2939 deletions
+15
View File
@@ -0,0 +1,15 @@
import { PLACEHOLDERS } from '../constants.js'
import { pick } from '../lib/text.js'
export const PLACEHOLDER = pick(PLACEHOLDERS)
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const LARGE_PASTE = { chars: 8000, lines: 80 }
export const MAX_HISTORY = 800
export const REASONING_PULSE_MS = 700
export const STREAM_BATCH_MS = 16
export const WHEEL_SCROLL_STEP = 3
export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test(
(process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()
)
export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g
+487
View File
@@ -0,0 +1,487 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { GatewayEvent } from '../gatewayClient.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { buildToolTrailLine, isToolTrailResultLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { fromSkin } from '../theme.js'
import type { Msg, SlashCatalog } from '../types.js'
import { introMsg, toTranscriptMessages } from './helpers.js'
import type { GatewayServices } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import type { TurnActions, TurnRefs } from './useTurnState.js'
export interface GatewayEventHandlerContext {
composer: {
dequeue: () => string | undefined
queueEditRef: MutableRefObject<number | null>
sendQueued: (text: string) => void
}
gateway: GatewayServices
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
resetSession: () => void
setCatalog: Dispatch<SetStateAction<SlashCatalog | null>>
}
system: {
bellOnComplete: boolean
stdout?: NodeJS.WriteStream
sys: (text: string) => void
}
transcript: {
appendMessage: (msg: Msg) => void
setHistoryItems: Dispatch<SetStateAction<Msg[]>>
setMessages: Dispatch<SetStateAction<Msg[]>>
}
turn: {
actions: Pick<
TurnActions,
| 'clearReasoning'
| 'endReasoningPhase'
| 'idle'
| 'pruneTransient'
| 'pulseReasoningStreaming'
| 'pushActivity'
| 'pushTrail'
| 'scheduleReasoning'
| 'scheduleStreaming'
| 'setActivity'
| 'setStreaming'
| 'setTools'
| 'setTurnTrail'
>
refs: Pick<
TurnRefs,
| 'bufRef'
| 'interruptedRef'
| 'lastStatusNoteRef'
| 'persistedToolLabelsRef'
| 'protocolWarnedRef'
| 'reasoningRef'
| 'statusTimerRef'
| 'toolCompleteRibbonRef'
| 'turnToolsRef'
>
}
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
const { dequeue, queueEditRef, sendQueued } = ctx.composer
const { gw, rpc } = ctx.gateway
const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system
const { appendMessage, setHistoryItems, setMessages } = ctx.transcript
const {
clearReasoning,
endReasoningPhase,
idle,
pruneTransient,
pulseReasoningStreaming,
pushActivity,
pushTrail,
scheduleReasoning,
scheduleStreaming,
setActivity,
setStreaming,
setTools,
setTurnTrail
} = ctx.turn.actions
const {
bufRef,
interruptedRef,
lastStatusNoteRef,
persistedToolLabelsRef,
protocolWarnedRef,
reasoningRef,
statusTimerRef,
toolCompleteRibbonRef,
turnToolsRef
} = ctx.turn.refs
return (ev: GatewayEvent) => {
const sid = getUiState().sid
if (ev.session_id && sid && ev.session_id !== sid && !ev.type.startsWith('gateway.')) {
return
}
const p = ev.payload as any
switch (ev.type) {
case 'gateway.ready':
if (p?.skin) {
patchUiState({
theme: fromSkin(
p.skin.colors ?? {},
p.skin.branding ?? {},
p.skin.banner_logo ?? '',
p.skin.banner_hero ?? ''
)
})
}
rpc('commands.catalog', {})
.then((r: any) => {
if (!r?.pairs) {
return
}
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
categories: (r.categories ?? []) as any,
pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]>
})
if (r.warning) {
pushActivity(String(r.warning), 'warn')
}
})
.catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn'))
if (STARTUP_RESUME_ID) {
patchUiState({ status: 'resuming…' })
gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then((raw: any) => {
const r = asRpcResult(raw)
if (!r) {
throw new Error('invalid response: session.resume')
}
resetSession()
const resumed = toTranscriptMessages(r.messages)
patchUiState({
info: r.info ?? null,
sid: r.session_id,
status: 'ready',
usage: r.info?.usage ?? getUiState().usage
})
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
})
.catch((e: unknown) => {
sys(`resume failed: ${rpcErrorMessage(e)}`)
patchUiState({ status: 'forging session…' })
newSession('started a new session')
})
} else {
patchUiState({ status: 'forging session…' })
newSession()
}
break
case 'skin.changed':
if (p) {
patchUiState({
theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')
})
}
break
case 'session.info':
patchUiState(state => ({
...state,
info: p as any,
usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage
}))
break
case 'thinking.delta':
if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' })
}
break
case 'message.start':
patchUiState({ busy: true })
endReasoningPhase()
clearReasoning()
setActivity([])
setTurnTrail([])
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
break
case 'status.update':
if (p?.text) {
patchUiState({ status: p.text })
if (p.kind && p.kind !== 'status') {
if (lastStatusNoteRef.current !== p.text) {
lastStatusNoteRef.current = p.text
pushActivity(
p.text,
p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
)
}
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
patchUiState({ status: getUiState().busy ? 'running…' : 'ready' })
}, 4000)
}
}
break
case 'gateway.stderr':
if (p?.line) {
const line = String(p.line).slice(0, 120)
const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn'
pushActivity(line, tone)
}
break
case 'gateway.start_timeout':
patchUiState({ status: 'gateway startup timeout' })
pushActivity(
`gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`,
'error'
)
break
case 'gateway.protocol_error':
patchUiState({ status: 'protocol warning' })
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
patchUiState({ status: getUiState().busy ? 'running…' : 'ready' })
}, 4000)
if (!protocolWarnedRef.current) {
protocolWarnedRef.current = true
pushActivity('protocol noise detected · /logs to inspect', 'warn')
}
if (p?.preview) {
pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn')
}
break
case 'reasoning.delta':
if (p?.text) {
reasoningRef.current += p.text
scheduleReasoning()
pulseReasoningStreaming()
}
break
case 'tool.progress':
if (p?.preview) {
setTools(prev => {
const index = prev.findIndex(tool => tool.name === p.name)
return index >= 0
? [...prev.slice(0, index), { ...prev[index]!, context: p.preview as string }, ...prev.slice(index + 1)]
: prev
})
}
break
case 'tool.generating':
if (p?.name) {
pushTrail(`drafting ${p.name}`)
}
break
case 'tool.start':
pruneTransient()
endReasoningPhase()
setTools(prev => [
...prev,
{ id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() }
])
break
case 'tool.complete': {
toolCompleteRibbonRef.current = null
setTools(prev => {
const done = prev.find(tool => tool.id === p.tool_id)
const name = done?.name ?? p.name
const label = toolTrailLabel(name)
const line = buildToolTrailLine(
name,
done?.context || '',
!!p.error,
(p.error as string) || (p.summary as string) || ''
)
const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
const remaining = prev.filter(tool => tool.id !== p.tool_id)
toolCompleteRibbonRef.current = { label, line }
if (!remaining.length) {
next.push('analyzing tool output…')
}
turnToolsRef.current = next.slice(-8)
setTurnTrail(turnToolsRef.current)
return remaining
})
if (p?.inline_diff) {
sys(p.inline_diff as string)
}
break
}
case 'clarify.request':
patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } })
patchUiState({ status: 'waiting for input…' })
break
case 'approval.request':
patchOverlayState({ approval: { command: p.command, description: p.description } })
patchUiState({ status: 'approval needed' })
break
case 'sudo.request':
patchOverlayState({ sudo: { requestId: p.request_id } })
patchUiState({ status: 'sudo password needed' })
break
case 'secret.request':
patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } })
patchUiState({ status: 'secret input needed' })
break
case 'background.complete':
patchUiState(state => {
const next = new Set(state.bgTasks)
next.delete(p.task_id)
return { ...state, bgTasks: next }
})
sys(`[bg ${p.task_id}] ${p.text}`)
break
case 'btw.complete':
patchUiState(state => {
const next = new Set(state.bgTasks)
next.delete('btw:x')
return { ...state, bgTasks: next }
})
sys(`[btw] ${p.text}`)
break
case 'message.delta':
pruneTransient()
endReasoningPhase()
if (p?.text && !interruptedRef.current) {
bufRef.current = p.rendered ?? bufRef.current + p.text
scheduleStreaming()
}
break
case 'message.complete': {
const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart()
const persisted = persistedToolLabelsRef.current
const savedReasoning = reasoningRef.current.trim()
const savedTools = turnToolsRef.current.filter(
line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line))
)
const wasInterrupted = interruptedRef.current
idle()
clearReasoning()
setStreaming('')
if (!wasInterrupted) {
appendMessage({
role: 'assistant',
text: finalText,
thinking: savedReasoning || undefined,
tools: savedTools.length ? savedTools : undefined
})
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')
}
}
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
setActivity([])
bufRef.current = ''
patchUiState({ status: 'ready' })
if (p?.usage) {
patchUiState({ usage: p.usage })
}
if (queueEditRef.current !== null) {
break
}
const next = dequeue()
if (next) {
sendQueued(next)
}
break
}
case 'error':
idle()
clearReasoning()
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
statusTimerRef.current = null
}
pushActivity(String(p?.message || 'unknown error'), 'error')
sys(`error: ${p?.message}`)
patchUiState({ status: 'ready' })
break
}
}
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import { createContext, type ReactNode, useContext } from 'react'
import type { GatewayServices } from './interfaces.js'
const GatewayContext = createContext<GatewayServices | null>(null)
export interface GatewayProviderProps {
children: ReactNode
value: GatewayServices
}
export function GatewayProvider({ children, value }: GatewayProviderProps) {
return <GatewayContext.Provider value={value}>{children}</GatewayContext.Provider>
}
export function useGateway() {
const value = useContext(GatewayContext)
if (!value) {
throw new Error('GatewayContext missing')
}
return value
}
+167
View File
@@ -0,0 +1,167 @@
import { buildToolTrailLine, fmtK, userDisplay } from '../lib/text.js'
import type { DetailsMode, Msg, SessionInfo } from '../types.js'
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
export interface PasteSnippet {
label: string
text: string
}
export const parseDetailsMode = (v: unknown): DetailsMode | null => {
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
}
export const resolveDetailsMode = (d: any): DetailsMode =>
parseDetailsMode(d?.details_mode) ??
{ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
String(d?.thinking_mode ?? '')
.trim()
.toLowerCase()
] ??
'collapsed'
export const nextDetailsMode = (m: DetailsMode): DetailsMode =>
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
export const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info })
export const shortCwd = (cwd: string, max = 28) => {
const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd
return p.length <= max ? p : `${p.slice(-(max - 1))}`
}
export const imageTokenMeta = (
info: { height?: number; token_estimate?: number; width?: number } | null | undefined
) => {
const dims = info?.width && info?.height ? `${info.width}x${info.height}` : ''
const tok =
typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
return [dims, tok].filter(Boolean).join(' · ')
}
export const looksLikeSlashCommand = (text: string) => {
if (!text.startsWith('/')) {
return false
}
const first = text.split(/\s+/, 1)[0] || ''
return !first.slice(1).includes('/')
}
export const toTranscriptMessages = (rows: unknown): Msg[] => {
if (!Array.isArray(rows)) {
return []
}
const result: Msg[] = []
let pendingTools: string[] = []
for (const row of rows) {
if (!row || typeof row !== 'object') {
continue
}
const role = (row as any).role
const text = (row as any).text
if (role === 'tool') {
const name = (row as any).name ?? 'tool'
const ctx = (row as any).context ?? ''
pendingTools.push(buildToolTrailLine(name, ctx))
continue
}
if (typeof text !== 'string' || !text.trim()) {
continue
}
if (role === 'assistant') {
const msg: Msg = { role, text }
if (pendingTools.length) {
msg.tools = pendingTools
pendingTools = []
}
result.push(msg)
continue
}
if (role === 'user' || role === 'system') {
pendingTools = []
result.push({ role, text })
}
}
return result
}
export function fmtDuration(ms: number) {
const total = Math.max(0, Math.floor(ms / 1000))
const hours = Math.floor(total / 3600)
const mins = Math.floor((total % 3600) / 60)
const secs = total % 60
if (hours > 0) {
return `${hours}h ${mins}m`
}
if (mins > 0) {
return `${mins}m ${secs}s`
}
return `${secs}s`
}
export const stickyPromptFromViewport = (
messages: readonly Msg[],
offsets: ArrayLike<number>,
top: number,
sticky: boolean
) => {
if (sticky || !messages.length) {
return ''
}
let lo = 0
let hi = offsets.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (offsets[mid]! <= top) {
lo = mid + 1
} else {
hi = mid
}
}
const first = Math.max(0, Math.min(messages.length - 1, lo - 1))
if (messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top) {
return ''
}
for (let i = first - 1; i >= 0; i--) {
if (messages[i]?.role !== 'user') {
continue
}
if ((offsets[i] ?? 0) + 1 >= top) {
continue
}
return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
}
return ''
}
+67
View File
@@ -0,0 +1,67 @@
import type { GatewayClient } from '../gatewayClient.js'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq, DetailsMode, Msg, SecretReq, SessionInfo, SudoReq, Usage } from '../types.js'
export interface CompletionItem {
display: string
meta?: string
text: string
}
export interface GatewayRpc {
(method: string, params?: Record<string, unknown>): Promise<any | null>
}
export interface GatewayServices {
gw: GatewayClient
rpc: GatewayRpc
}
export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
modelPicker: boolean
pager: PagerState | null
picker: boolean
secret: SecretReq | null
sudo: SudoReq | null
}
export interface PagerState {
lines: string[]
offset: number
title?: string
}
export interface ToolCompleteRibbon {
label: string
line: string
}
export interface TranscriptRow {
index: number
key: string
msg: Msg
}
export interface UiState {
bgTasks: Set<string>
busy: boolean
compact: boolean
detailsMode: DetailsMode
info: SessionInfo | null
sid: string | null
status: string
statusBar: boolean
theme: Theme
usage: Usage
}
export interface VirtualHistoryState {
bottomSpacer: number
end: number
measureRef: (key: string) => (el: unknown) => void
offsets: ArrayLike<number>
start: number
topSpacer: number
}
+41
View File
@@ -0,0 +1,41 @@
import { atom, computed } from 'nanostores'
import type { OverlayState } from './interfaces.js'
function buildOverlayState(): OverlayState {
return {
approval: null,
clarify: null,
modelPicker: false,
pager: null,
picker: false,
secret: null,
sudo: null
}
}
export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed($overlayState, state =>
Boolean(
state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo
)
)
export function getOverlayState() {
return $overlayState.get()
}
export function patchOverlayState(next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) {
if (typeof next === 'function') {
$overlayState.set(next($overlayState.get()))
return
}
$overlayState.set({ ...$overlayState.get(), ...next })
}
export function resetOverlayState() {
$overlayState.set(buildOverlayState())
}
+41
View File
@@ -0,0 +1,41 @@
import { atom } from 'nanostores'
import { ZERO } from '../constants.js'
import { DEFAULT_THEME } from '../theme.js'
import type { UiState } from './interfaces.js'
function buildUiState(): UiState {
return {
bgTasks: new Set(),
busy: false,
compact: false,
detailsMode: 'collapsed',
info: null,
sid: null,
status: 'summoning hermes…',
statusBar: true,
theme: DEFAULT_THEME,
usage: ZERO
}
}
export const $uiState = atom<UiState>(buildUiState())
export function getUiState() {
return $uiState.get()
}
export function patchUiState(next: Partial<UiState> | ((state: UiState) => UiState)) {
if (typeof next === 'function') {
$uiState.set(next($uiState.get()))
return
}
$uiState.set({ ...$uiState.get(), ...next })
}
export function resetUiState() {
$uiState.set(buildUiState())
}
+199
View File
@@ -0,0 +1,199 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { useStore } from '@nanostores/react'
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useState } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
import { LARGE_PASTE } from './constants.js'
import type { PasteSnippet } from './helpers.js'
import type { CompletionItem } from './interfaces.js'
import { $isBlocked } from './overlayStore.js'
export interface ComposerPasteResult {
cursor: number
value: string
}
export interface ComposerActions {
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
openEditor: () => void
pushHistory: (text: string) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: Dispatch<SetStateAction<number>>
setHistoryIdx: Dispatch<SetStateAction<number | null>>
setInput: Dispatch<SetStateAction<string>>
setInputBuf: Dispatch<SetStateAction<string[]>>
setPasteSnips: Dispatch<SetStateAction<PasteSnippet[]>>
setQueueEdit: (index: number | null) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<number | null>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
}
export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: number | null
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: number | null
queuedDisplay: string[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
submitRef: MutableRefObject<(value: string) => void>
}
export interface UseComposerStateResult {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {
const [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
const isBlocked = useStore($isBlocked)
const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } =
useQueue()
const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw)
const clearIn = useCallback(() => {
setInput('')
setInputBuf([])
setQueueEdit(null)
setHistoryIdx(null)
historyDraftRef.current = ''
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
const handleTextPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent) => {
if (hotkey) {
void onClipboardPaste(false)
return null
}
const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
if (bracketed) {
void onClipboardPaste(true)
}
return null
}
const lineCount = cleanedText.split('\n').length
if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
return {
cursor: cursor + cleanedText.length,
value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
}
}
const label = pasteTokenLabel(cleanedText, lineCount)
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
const insert = `${lead}${label}${tail}`
setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))
return {
cursor: cursor + insert.length,
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
},
[onClipboardPaste]
)
const openEditor = useCallback(() => {
const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md')
writeFileSync(file, [...inputBuf, input].join('\n'))
process.stdout.write('\x1b[?1049l')
const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' })
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H')
if (code === 0) {
const text = readFileSync(file, 'utf8').trimEnd()
if (text) {
setInput('')
setInputBuf([])
submitRef.current(text)
}
}
try {
unlinkSync(file)
} catch {
/* noop */
}
}, [input, inputBuf, submitRef])
return {
actions: {
clearIn,
dequeue,
enqueue,
handleTextPaste,
openEditor,
pushHistory,
replaceQueue: replaceQ,
setCompIdx,
setHistoryIdx,
setInput,
setInputBuf,
setPasteSnips,
setQueueEdit,
syncQueue
},
refs: {
historyDraftRef,
historyRef,
queueEditRef,
queueRef,
submitRef
},
state: {
compIdx,
compReplace,
completions,
historyIdx,
input,
inputBuf,
pasteSnips,
queueEditIdx,
queuedDisplay
}
}
}
+345
View File
@@ -0,0 +1,345 @@
import { type ScrollBoxHandle, useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { Msg } from '../types.js'
import type { GatewayServices } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import type { ComposerActions, ComposerRefs, ComposerState } from './useComposerState.js'
import type { TurnActions, TurnRefs } from './useTurnState.js'
export interface InputHandlerActions {
answerClarify: (answer: string) => void
appendMessage: (msg: Msg) => void
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
sys: (text: string) => void
}
export interface InputHandlerContext {
actions: InputHandlerActions
composer: {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
gateway: GatewayServices
terminal: {
hasSelection: boolean
scrollRef: RefObject<ScrollBoxHandle | null>
scrollWithSelection: (delta: number) => void
selection: {
copySelection: () => string
}
stdout?: NodeJS.WriteStream
}
turn: {
actions: TurnActions
refs: TurnRefs
}
voice: {
recording: boolean
setProcessing: Dispatch<SetStateAction<boolean>>
setRecording: Dispatch<SetStateAction<boolean>>
}
wheelStep: number
}
export interface InputHandlerResult {
pagerPageSize: number
}
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const copySelection = () => {
if (terminal.selection.copySelection()) {
actions.sys('copied selection')
}
}
useInput((ch, key) => {
const live = getUiState()
if (isBlocked) {
if (overlay.pager) {
if (key.return || ch === ' ') {
const next = overlay.pager.offset + pagerPageSize
patchOverlayState({
pager: next >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: next }
})
} else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') {
patchOverlayState({ pager: null })
}
return
}
if (ctrl(key, ch, 'c')) {
if (overlay.clarify) {
actions.answerClarify('')
} else if (overlay.approval) {
gateway.rpc('approval.respond', { choice: 'deny', session_id: live.sid }).then(r => {
if (!r) {
return
}
patchOverlayState({ approval: null })
actions.sys('denied')
})
} else if (overlay.sudo) {
gateway.rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }).then(r => {
if (!r) {
return
}
patchOverlayState({ sudo: null })
actions.sys('sudo cancelled')
})
} else if (overlay.secret) {
gateway.rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }).then(r => {
if (!r) {
return
}
patchOverlayState({ secret: null })
actions.sys('secret entry cancelled')
})
} else if (overlay.modelPicker) {
patchOverlayState({ modelPicker: false })
} else if (overlay.picker) {
patchOverlayState({ picker: false })
}
} else if (key.escape && overlay.picker) {
patchOverlayState({ picker: false })
}
return
}
if (
composer.state.completions.length &&
composer.state.input &&
composer.state.historyIdx === null &&
(key.upArrow || key.downArrow)
) {
composer.actions.setCompIdx(index =>
key.upArrow
? (index - 1 + composer.state.completions.length) % composer.state.completions.length
: (index + 1) % composer.state.completions.length
)
return
}
if (key.wheelUp) {
terminal.scrollWithSelection(-wheelStep)
return
}
if (key.wheelDown) {
terminal.scrollWithSelection(wheelStep)
return
}
if (key.shift && key.upArrow) {
terminal.scrollWithSelection(-1)
return
}
if (key.shift && key.downArrow) {
terminal.scrollWithSelection(1)
return
}
if (key.pageUp || key.pageDown) {
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
const step = Math.max(4, viewport - 2)
terminal.scrollWithSelection(key.pageUp ? -step : step)
return
}
if (key.ctrl && key.shift && ch.toLowerCase() === 'c') {
copySelection()
return
}
if (key.upArrow && !composer.state.inputBuf.length) {
if (composer.refs.queueRef.current.length) {
const index =
composer.state.queueEditIdx === null
? 0
: (composer.state.queueEditIdx + 1) % composer.refs.queueRef.current.length
composer.actions.setQueueEdit(index)
composer.actions.setHistoryIdx(null)
composer.actions.setInput(composer.refs.queueRef.current[index] ?? '')
} else if (composer.refs.historyRef.current.length) {
const index =
composer.state.historyIdx === null
? composer.refs.historyRef.current.length - 1
: Math.max(0, composer.state.historyIdx - 1)
if (composer.state.historyIdx === null) {
composer.refs.historyDraftRef.current = composer.state.input
}
composer.actions.setHistoryIdx(index)
composer.actions.setQueueEdit(null)
composer.actions.setInput(composer.refs.historyRef.current[index] ?? '')
}
return
}
if (key.downArrow && !composer.state.inputBuf.length) {
if (composer.refs.queueRef.current.length) {
const index =
composer.state.queueEditIdx === null
? composer.refs.queueRef.current.length - 1
: (composer.state.queueEditIdx - 1 + composer.refs.queueRef.current.length) %
composer.refs.queueRef.current.length
composer.actions.setQueueEdit(index)
composer.actions.setHistoryIdx(null)
composer.actions.setInput(composer.refs.queueRef.current[index] ?? '')
} else if (composer.state.historyIdx !== null) {
const next = composer.state.historyIdx + 1
if (next >= composer.refs.historyRef.current.length) {
composer.actions.setHistoryIdx(null)
composer.actions.setInput(composer.refs.historyDraftRef.current)
} else {
composer.actions.setHistoryIdx(next)
composer.actions.setInput(composer.refs.historyRef.current[next] ?? '')
}
}
return
}
if (ctrl(key, ch, 'c')) {
if (terminal.hasSelection) {
copySelection()
} else if (live.busy && live.sid) {
turn.actions.interruptTurn({
appendMessage: actions.appendMessage,
gw: gateway.gw,
sid: live.sid,
sys: actions.sys
})
} else if (composer.state.input || composer.state.inputBuf.length) {
composer.actions.clearIn()
} else {
return actions.die()
}
return
}
if (ctrl(key, ch, 'd')) {
return actions.die()
}
if (ctrl(key, ch, 'l')) {
if (actions.guardBusySessionSwitch()) {
return
}
patchUiState({ status: 'forging session…' })
actions.newSession()
return
}
if (ctrl(key, ch, 'b')) {
if (voice.recording) {
voice.setRecording(false)
voice.setProcessing(true)
gateway
.rpc('voice.record', { action: 'stop' })
.then((r: any) => {
if (!r) {
return
}
const transcript = String(r?.text || '').trim()
if (transcript) {
composer.actions.setInput(prev =>
prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript
)
} else {
actions.sys('voice: no speech detected')
}
})
.catch((e: Error) => actions.sys(`voice error: ${e.message}`))
.finally(() => {
voice.setProcessing(false)
patchUiState({ status: 'ready' })
})
} else {
gateway
.rpc('voice.record', { action: 'start' })
.then((r: any) => {
if (!r) {
return
}
voice.setRecording(true)
patchUiState({ status: 'recording…' })
})
.catch((e: Error) => actions.sys(`voice error: ${e.message}`))
}
return
}
if (ctrl(key, ch, 'g')) {
return composer.actions.openEditor()
}
if (key.tab && composer.state.completions.length) {
const row = composer.state.completions[composer.state.compIdx]
if (row?.text) {
const text =
composer.state.input.startsWith('/') && row.text.startsWith('/') && composer.state.compReplace > 0
? row.text.slice(1)
: row.text
composer.actions.setInput(composer.state.input.slice(0, composer.state.compReplace) + text)
}
return
}
if (ctrl(key, ch, 'k') && composer.refs.queueRef.current.length && live.sid) {
const next = composer.actions.dequeue()
if (next) {
composer.actions.setQueueEdit(null)
actions.dispatchSubmission(next)
}
}
})
return { pagerPageSize }
}
+296
View File
@@ -0,0 +1,296 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
import type { ActiveTool, ActivityItem, Msg } from '../types.js'
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js'
import type { ToolCompleteRibbon } from './interfaces.js'
import { resetOverlayState } from './overlayStore.js'
import { patchUiState } from './uiStore.js'
export interface InterruptTurnOptions {
appendMessage: (msg: Msg) => void
gw: { request: (method: string, params?: Record<string, unknown>) => Promise<unknown> }
sid: string
sys: (text: string) => void
}
export interface TurnActions {
clearReasoning: () => void
endReasoningPhase: () => void
idle: () => void
interruptTurn: (options: InterruptTurnOptions) => void
pruneTransient: () => void
pulseReasoningStreaming: () => void
pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
pushTrail: (line: string) => void
scheduleReasoning: () => void
scheduleStreaming: () => void
setActivity: Dispatch<SetStateAction<ActivityItem[]>>
setReasoning: Dispatch<SetStateAction<string>>
setReasoningActive: Dispatch<SetStateAction<boolean>>
setReasoningStreaming: Dispatch<SetStateAction<boolean>>
setStreaming: Dispatch<SetStateAction<string>>
setTools: Dispatch<SetStateAction<ActiveTool[]>>
setTurnTrail: Dispatch<SetStateAction<string[]>>
}
export interface TurnRefs {
bufRef: MutableRefObject<string>
interruptedRef: MutableRefObject<boolean>
lastStatusNoteRef: MutableRefObject<string>
persistedToolLabelsRef: MutableRefObject<Set<string>>
protocolWarnedRef: MutableRefObject<boolean>
reasoningRef: MutableRefObject<string>
reasoningStreamingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
reasoningTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
statusTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
streamTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
toolCompleteRibbonRef: MutableRefObject<ToolCompleteRibbon | null>
turnToolsRef: MutableRefObject<string[]>
}
export interface TurnState {
activity: ActivityItem[]
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
streaming: string
tools: ActiveTool[]
turnTrail: string[]
}
export interface UseTurnStateResult {
actions: TurnActions
refs: TurnRefs
state: TurnState
}
export function useTurnState(): UseTurnStateResult {
const [activity, setActivity] = useState<ActivityItem[]>([])
const [reasoning, setReasoning] = useState('')
const [reasoningActive, setReasoningActive] = useState(false)
const [reasoningStreaming, setReasoningStreaming] = useState(false)
const [streaming, setStreaming] = useState('')
const [tools, setTools] = useState<ActiveTool[]>([])
const [turnTrail, setTurnTrail] = useState<string[]>([])
const activityIdRef = useRef(0)
const bufRef = useRef('')
const interruptedRef = useRef(false)
const lastStatusNoteRef = useRef('')
const persistedToolLabelsRef = useRef<Set<string>>(new Set())
const protocolWarnedRef = useRef(false)
const reasoningRef = useRef('')
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reasoningTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const toolCompleteRibbonRef = useRef<ToolCompleteRibbon | null>(null)
const turnToolsRef = useRef<string[]>([])
const setTrail = (next: string[]) => {
turnToolsRef.current = next
return next
}
const pulseReasoningStreaming = useCallback(() => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
}
setReasoningActive(true)
setReasoningStreaming(true)
reasoningStreamingTimerRef.current = setTimeout(() => {
reasoningStreamingTimerRef.current = null
setReasoningStreaming(false)
}, REASONING_PULSE_MS)
}, [])
const scheduleStreaming = useCallback(() => {
if (streamTimerRef.current) {
return
}
streamTimerRef.current = setTimeout(() => {
streamTimerRef.current = null
setStreaming(bufRef.current.trimStart())
}, STREAM_BATCH_MS)
}, [])
const scheduleReasoning = useCallback(() => {
if (reasoningTimerRef.current) {
return
}
reasoningTimerRef.current = setTimeout(() => {
reasoningTimerRef.current = null
setReasoning(reasoningRef.current)
}, STREAM_BATCH_MS)
}, [])
const endReasoningPhase = useCallback(() => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
reasoningStreamingTimerRef.current = null
}
setReasoningStreaming(false)
setReasoningActive(false)
}, [])
useEffect(
() => () => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current)
}
if (reasoningTimerRef.current) {
clearTimeout(reasoningTimerRef.current)
}
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
}
},
[]
)
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => {
const base = replaceLabel ? prev.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) : prev
if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) {
return base
}
activityIdRef.current++
return [...base, { id: activityIdRef.current, text, tone }].slice(-8)
})
}, [])
const pruneTransient = useCallback(() => {
setTurnTrail(prev => {
const next = prev.filter(line => !isTransientTrailLine(line))
return next.length === prev.length ? prev : setTrail(next)
})
}, [])
const pushTrail = useCallback((line: string) => {
setTurnTrail(prev =>
prev.at(-1) === line ? prev : setTrail([...prev.filter(item => !isTransientTrailLine(item)), line].slice(-8))
)
}, [])
const clearReasoning = useCallback(() => {
if (reasoningTimerRef.current) {
clearTimeout(reasoningTimerRef.current)
reasoningTimerRef.current = null
}
reasoningRef.current = ''
setReasoning('')
}, [])
const idle = useCallback(() => {
endReasoningPhase()
setTools([])
setTurnTrail([])
patchUiState({ busy: false })
resetOverlayState()
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current)
streamTimerRef.current = null
}
setStreaming('')
bufRef.current = ''
}, [endReasoningPhase])
const interruptTurn = useCallback(
({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => {
interruptedRef.current = true
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
const partial = (streaming || bufRef.current).trimStart()
if (partial) {
appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' })
} else {
sys('interrupted')
}
idle()
clearReasoning()
setActivity([])
turnToolsRef.current = []
patchUiState({ status: 'interrupted' })
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
patchUiState({ status: 'ready' })
}, 1500)
},
[clearReasoning, idle, streaming]
)
return {
actions: {
clearReasoning,
endReasoningPhase,
idle,
interruptTurn,
pruneTransient,
pulseReasoningStreaming,
pushActivity,
pushTrail,
scheduleReasoning,
scheduleStreaming,
setActivity,
setReasoning,
setReasoningActive,
setReasoningStreaming,
setStreaming,
setTools,
setTurnTrail
},
refs: {
bufRef,
interruptedRef,
lastStatusNoteRef,
persistedToolLabelsRef,
protocolWarnedRef,
reasoningRef,
reasoningStreamingTimerRef,
reasoningTimerRef,
statusTimerRef,
streamTimerRef,
toolCompleteRibbonRef,
turnToolsRef
},
state: {
activity,
reasoning,
reasoningActive,
reasoningStreaming,
streaming,
tools,
turnTrail
}
}
}