mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
refactor(tui): store-driven turn state + slash registry + module split
Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.
Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.
Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).
Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.
Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.
Tests: 50 passing. Build + type-check clean.
This commit is contained in:
parent
9c71f3a6ea
commit
68ecdb6e26
56 changed files with 3666 additions and 4117 deletions
353
ui-tui/src/app/turnController.ts
Normal file
353
ui-tui/src/app/turnController.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
|
||||
import {
|
||||
buildToolTrailLine,
|
||||
estimateTokensRough,
|
||||
isToolTrailResultLine,
|
||||
isTransientTrailLine,
|
||||
sameToolTrailGroup,
|
||||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
||||
|
||||
import { resetOverlayState } from './overlayStore.js'
|
||||
import { patchTurnState, resetTurnState } from './turnStore.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
const INTERRUPT_COOLDOWN_MS = 1500
|
||||
const ACTIVITY_LIMIT = 8
|
||||
const TRAIL_LIMIT = 8
|
||||
|
||||
export interface InterruptDeps {
|
||||
appendMessage: (msg: Msg) => void
|
||||
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
|
||||
sid: string
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
||||
type Timer = null | ReturnType<typeof setTimeout>
|
||||
|
||||
const clear = (t: Timer): null => {
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
class TurnController {
|
||||
bufRef = ''
|
||||
interrupted = false
|
||||
lastStatusNote = ''
|
||||
persistedToolLabels = new Set<string>()
|
||||
protocolWarned = false
|
||||
reasoningText = ''
|
||||
statusTimer: Timer = null
|
||||
toolTokenAcc = 0
|
||||
turnTools: string[] = []
|
||||
|
||||
private activeTools: ActiveTool[] = []
|
||||
private activityId = 0
|
||||
private reasoningStreamingTimer: Timer = null
|
||||
private reasoningTimer: Timer = null
|
||||
private streamTimer: Timer = null
|
||||
private toolProgressTimer: Timer = null
|
||||
|
||||
clearReasoning() {
|
||||
this.reasoningTimer = clear(this.reasoningTimer)
|
||||
this.reasoningText = ''
|
||||
this.toolTokenAcc = 0
|
||||
patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 })
|
||||
}
|
||||
|
||||
clearStatusTimer() {
|
||||
this.statusTimer = clear(this.statusTimer)
|
||||
}
|
||||
|
||||
endReasoningPhase() {
|
||||
this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer)
|
||||
patchTurnState({ reasoningActive: false, reasoningStreaming: false })
|
||||
}
|
||||
|
||||
idle() {
|
||||
this.endReasoningPhase()
|
||||
this.activeTools = []
|
||||
this.streamTimer = clear(this.streamTimer)
|
||||
this.bufRef = ''
|
||||
|
||||
patchTurnState({ streaming: '', subagents: [], tools: [], turnTrail: [] })
|
||||
patchUiState({ busy: false })
|
||||
resetOverlayState()
|
||||
}
|
||||
|
||||
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
|
||||
this.interrupted = true
|
||||
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sid }).catch(() => {})
|
||||
|
||||
const partial = this.bufRef.trimStart()
|
||||
|
||||
partial ? appendMessage({ role: 'assistant', text: `${partial}\n\n*[interrupted]*` }) : sys('interrupted')
|
||||
|
||||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.turnTools = []
|
||||
patchTurnState({ activity: [] })
|
||||
patchUiState({ status: 'interrupted' })
|
||||
this.clearStatusTimer()
|
||||
|
||||
this.statusTimer = setTimeout(() => {
|
||||
this.statusTimer = null
|
||||
patchUiState({ status: 'ready' })
|
||||
}, INTERRUPT_COOLDOWN_MS)
|
||||
}
|
||||
|
||||
pruneTransient() {
|
||||
this.turnTools = this.turnTools.filter(line => !isTransientTrailLine(line))
|
||||
patchTurnState(state => {
|
||||
const next = state.turnTrail.filter(line => !isTransientTrailLine(line))
|
||||
|
||||
return next.length === state.turnTrail.length ? state : { ...state, turnTrail: next }
|
||||
})
|
||||
}
|
||||
|
||||
pulseReasoningStreaming() {
|
||||
this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer)
|
||||
patchTurnState({ reasoningActive: true, reasoningStreaming: true })
|
||||
|
||||
this.reasoningStreamingTimer = setTimeout(() => {
|
||||
this.reasoningStreamingTimer = null
|
||||
patchTurnState({ reasoningStreaming: false })
|
||||
}, REASONING_PULSE_MS)
|
||||
}
|
||||
|
||||
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
|
||||
patchTurnState(state => {
|
||||
const base = replaceLabel
|
||||
? state.activity.filter(item => !sameToolTrailGroup(replaceLabel, item.text))
|
||||
: state.activity
|
||||
|
||||
const tail = base.at(-1)
|
||||
|
||||
if (tail?.text === text && tail.tone === tone) {
|
||||
return state
|
||||
}
|
||||
|
||||
return { ...state, activity: [...base, { id: ++this.activityId, text, tone }].slice(-ACTIVITY_LIMIT) }
|
||||
})
|
||||
}
|
||||
|
||||
pushTrail(line: string) {
|
||||
patchTurnState(state => {
|
||||
if (state.turnTrail.at(-1) === line) {
|
||||
return state
|
||||
}
|
||||
|
||||
const next = [...state.turnTrail.filter(item => !isTransientTrailLine(item)), line].slice(-TRAIL_LIMIT)
|
||||
|
||||
this.turnTools = next
|
||||
|
||||
return { ...state, turnTrail: next }
|
||||
})
|
||||
}
|
||||
|
||||
recordError() {
|
||||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.clearStatusTimer()
|
||||
this.turnTools = []
|
||||
this.persistedToolLabels.clear()
|
||||
}
|
||||
|
||||
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
|
||||
const finalText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
||||
const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
|
||||
const savedToolTokens = this.toolTokenAcc
|
||||
const persisted = [...this.persistedToolLabels]
|
||||
|
||||
const savedTools = this.turnTools.filter(
|
||||
line => isToolTrailResultLine(line) && !persisted.some(label => sameToolTrailGroup(label, line))
|
||||
)
|
||||
|
||||
const wasInterrupted = this.interrupted
|
||||
|
||||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.turnTools = []
|
||||
this.persistedToolLabels.clear()
|
||||
this.bufRef = ''
|
||||
patchTurnState({ activity: [] })
|
||||
|
||||
return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted }
|
||||
}
|
||||
|
||||
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
|
||||
this.pruneTransient()
|
||||
this.endReasoningPhase()
|
||||
|
||||
if (!text || this.interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bufRef = rendered ?? this.bufRef + text
|
||||
this.scheduleStreaming()
|
||||
}
|
||||
|
||||
recordReasoningAvailable(text: string) {
|
||||
const incoming = text.trim()
|
||||
|
||||
if (!incoming || this.reasoningText.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.reasoningText = incoming
|
||||
this.scheduleReasoning()
|
||||
this.pulseReasoningStreaming()
|
||||
}
|
||||
|
||||
recordReasoningDelta(text: string) {
|
||||
this.reasoningText += text
|
||||
this.scheduleReasoning()
|
||||
this.pulseReasoningStreaming()
|
||||
}
|
||||
|
||||
recordToolComplete(toolId: string, fallbackName?: string, error?: string, summary?: string) {
|
||||
const done = this.activeTools.find(tool => tool.id === toolId)
|
||||
const name = done?.name ?? fallbackName ?? 'tool'
|
||||
const label = toolTrailLabel(name)
|
||||
const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '')
|
||||
|
||||
this.activeTools = this.activeTools.filter(tool => tool.id !== toolId)
|
||||
|
||||
const next = [...this.turnTools.filter(item => !sameToolTrailGroup(label, item)), line]
|
||||
|
||||
if (!this.activeTools.length) {
|
||||
next.push('analyzing tool output…')
|
||||
}
|
||||
|
||||
this.turnTools = next.slice(-TRAIL_LIMIT)
|
||||
patchTurnState({ tools: this.activeTools, turnTrail: this.turnTools })
|
||||
}
|
||||
|
||||
recordToolProgress(toolName: string, preview: string) {
|
||||
const index = this.activeTools.findIndex(tool => tool.name === toolName)
|
||||
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.activeTools = this.activeTools.map((tool, i) => (i === index ? { ...tool, context: preview } : tool))
|
||||
|
||||
if (this.toolProgressTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
this.toolProgressTimer = setTimeout(() => {
|
||||
this.toolProgressTimer = null
|
||||
patchTurnState({ tools: [...this.activeTools] })
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
recordToolStart(toolId: string, name: string, context: string) {
|
||||
this.pruneTransient()
|
||||
this.endReasoningPhase()
|
||||
|
||||
const sample = `${name} ${context}`.trim()
|
||||
|
||||
this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0
|
||||
this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now() }]
|
||||
|
||||
patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools })
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.clearReasoning()
|
||||
this.clearStatusTimer()
|
||||
this.idle()
|
||||
this.bufRef = ''
|
||||
this.interrupted = false
|
||||
this.lastStatusNote = ''
|
||||
this.protocolWarned = false
|
||||
this.turnTools = []
|
||||
this.toolTokenAcc = 0
|
||||
this.persistedToolLabels.clear()
|
||||
patchTurnState({ activity: [] })
|
||||
}
|
||||
|
||||
fullReset() {
|
||||
this.reset()
|
||||
resetTurnState()
|
||||
}
|
||||
|
||||
scheduleReasoning() {
|
||||
if (this.reasoningTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
this.reasoningTimer = setTimeout(() => {
|
||||
this.reasoningTimer = null
|
||||
patchTurnState({
|
||||
reasoning: this.reasoningText,
|
||||
reasoningTokens: estimateTokensRough(this.reasoningText)
|
||||
})
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
scheduleStreaming() {
|
||||
if (this.streamTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
this.streamTimer = setTimeout(() => {
|
||||
this.streamTimer = null
|
||||
patchTurnState({ streaming: this.bufRef.trimStart() })
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
startMessage() {
|
||||
this.endReasoningPhase()
|
||||
this.clearReasoning()
|
||||
this.activeTools = []
|
||||
this.turnTools = []
|
||||
this.toolTokenAcc = 0
|
||||
this.persistedToolLabels.clear()
|
||||
patchUiState({ busy: true })
|
||||
patchTurnState({ activity: [], subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
||||
}
|
||||
|
||||
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) {
|
||||
const id = `sa:${p.task_index}:${p.goal || 'subagent'}`
|
||||
|
||||
patchTurnState(state => {
|
||||
const existing = state.subagents.find(item => item.id === id)
|
||||
|
||||
const base: SubagentProgress = existing ?? {
|
||||
goal: p.goal,
|
||||
id,
|
||||
index: p.task_index,
|
||||
notes: [],
|
||||
status: 'running',
|
||||
taskCount: p.task_count ?? 1,
|
||||
thinking: [],
|
||||
tools: []
|
||||
}
|
||||
|
||||
const next: SubagentProgress = {
|
||||
...base,
|
||||
goal: p.goal || base.goal,
|
||||
taskCount: p.task_count ?? base.taskCount,
|
||||
...patch(base)
|
||||
}
|
||||
|
||||
const subagents = existing
|
||||
? state.subagents.map(item => (item.id === id ? next : item))
|
||||
: [...state.subagents, next].sort((a, b) => a.index - b.index)
|
||||
|
||||
return { ...state, subagents }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const turnController = new TurnController()
|
||||
|
||||
export type { TurnController }
|
||||
Loading…
Add table
Add a link
Reference in a new issue