mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 02:21:47 +00:00
feat: just more cleaning
This commit is contained in:
parent
46cef4b7fa
commit
4b4b4d47bc
24 changed files with 2852 additions and 829 deletions
|
|
@ -1,14 +1,16 @@
|
|||
import type { GatewayEvent } from '../gatewayClient.js'
|
||||
import type { CommandsCatalogResponse, GatewayEvent, SessionResumeResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import {
|
||||
buildToolTrailLine,
|
||||
estimateTokensRough,
|
||||
formatToolCall,
|
||||
isToolTrailResultLine,
|
||||
sameToolTrailGroup,
|
||||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
import { fromSkin } from '../theme.js'
|
||||
|
||||
import { STREAM_BATCH_MS } from './constants.js'
|
||||
import { introMsg, toTranscriptMessages } from './helpers.js'
|
||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||
import { patchOverlayState } from './overlayStore.js'
|
||||
|
|
@ -19,7 +21,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
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 { appendMessage, setHistoryItems } = ctx.transcript
|
||||
|
||||
const {
|
||||
clearReasoning,
|
||||
|
|
@ -32,8 +34,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
scheduleReasoning,
|
||||
scheduleStreaming,
|
||||
setActivity,
|
||||
setReasoningTokens,
|
||||
setStreaming,
|
||||
setSubagents,
|
||||
setToolTokens,
|
||||
setTools,
|
||||
setTurnTrail
|
||||
|
|
@ -53,6 +55,108 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
turnToolsRef
|
||||
} = ctx.turn.refs
|
||||
|
||||
let pendingThinkingStatus = ''
|
||||
let thinkingStatusTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let toolProgressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cancelThinkingStatus = () => {
|
||||
pendingThinkingStatus = ''
|
||||
|
||||
if (thinkingStatusTimer) {
|
||||
clearTimeout(thinkingStatusTimer)
|
||||
thinkingStatusTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const setStatus = (status: string) => {
|
||||
cancelThinkingStatus()
|
||||
patchUiState({ status })
|
||||
}
|
||||
|
||||
const scheduleThinkingStatus = (status: string) => {
|
||||
pendingThinkingStatus = status
|
||||
|
||||
if (thinkingStatusTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
thinkingStatusTimer = setTimeout(() => {
|
||||
thinkingStatusTimer = null
|
||||
patchUiState({ status: pendingThinkingStatus || (getUiState().busy ? 'running…' : 'ready') })
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
const scheduleToolProgress = () => {
|
||||
if (toolProgressTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
toolProgressTimer = setTimeout(() => {
|
||||
toolProgressTimer = null
|
||||
setTools([...activeToolsRef.current])
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
const upsertSubagent = (
|
||||
taskIndex: number,
|
||||
taskCount: number,
|
||||
goal: string,
|
||||
update: (current: {
|
||||
durationSeconds?: number
|
||||
goal: string
|
||||
id: string
|
||||
index: number
|
||||
notes: string[]
|
||||
status: 'completed' | 'failed' | 'interrupted' | 'running'
|
||||
summary?: string
|
||||
taskCount: number
|
||||
thinking: string[]
|
||||
tools: string[]
|
||||
}) => {
|
||||
durationSeconds?: number
|
||||
goal: string
|
||||
id: string
|
||||
index: number
|
||||
notes: string[]
|
||||
status: 'completed' | 'failed' | 'interrupted' | 'running'
|
||||
summary?: string
|
||||
taskCount: number
|
||||
thinking: string[]
|
||||
tools: string[]
|
||||
}
|
||||
) => {
|
||||
const id = `sa:${taskIndex}:${goal || 'subagent'}`
|
||||
|
||||
setSubagents(prev => {
|
||||
const index = prev.findIndex(item => item.id === id)
|
||||
|
||||
const base =
|
||||
index >= 0
|
||||
? prev[index]!
|
||||
: {
|
||||
id,
|
||||
index: taskIndex,
|
||||
taskCount,
|
||||
goal,
|
||||
notes: [],
|
||||
status: 'running' as const,
|
||||
thinking: [],
|
||||
tools: []
|
||||
}
|
||||
|
||||
const nextItem = update(base)
|
||||
|
||||
if (index < 0) {
|
||||
return [...prev, nextItem].sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
const next = [...prev]
|
||||
next[index] = nextItem
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (ev: GatewayEvent) => {
|
||||
const sid = getUiState().sid
|
||||
|
||||
|
|
@ -60,10 +164,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
return
|
||||
}
|
||||
|
||||
const p = ev.payload as any
|
||||
|
||||
switch (ev.type) {
|
||||
case 'gateway.ready':
|
||||
case 'gateway.ready': {
|
||||
const p = ev.payload
|
||||
|
||||
if (p?.skin) {
|
||||
patchUiState({
|
||||
theme: fromSkin(
|
||||
|
|
@ -75,15 +179,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
})
|
||||
}
|
||||
|
||||
rpc('commands.catalog', {})
|
||||
.then((r: any) => {
|
||||
rpc<CommandsCatalogResponse>('commands.catalog', {})
|
||||
.then(r => {
|
||||
if (!r?.pairs) {
|
||||
return
|
||||
}
|
||||
|
||||
setCatalog({
|
||||
canon: (r.canon ?? {}) as Record<string, string>,
|
||||
categories: (r.categories ?? []) as any,
|
||||
categories: r.categories ?? [],
|
||||
pairs: r.pairs as [string, string][],
|
||||
skillCount: (r.skill_count ?? 0) as number,
|
||||
sub: (r.sub ?? {}) as Record<string, string[]>
|
||||
|
|
@ -97,9 +201,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
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)
|
||||
gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
|
||||
.then(raw => {
|
||||
const r = asRpcResult<SessionResumeResponse>(raw)
|
||||
|
||||
if (!r) {
|
||||
throw new Error('invalid response: session.resume')
|
||||
|
|
@ -114,7 +218,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
status: 'ready',
|
||||
usage: r.info?.usage ?? getUiState().usage
|
||||
})
|
||||
setMessages(resumed)
|
||||
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
|
|
@ -128,8 +231,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'skin.changed': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'skin.changed':
|
||||
if (p) {
|
||||
patchUiState({
|
||||
theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')
|
||||
|
|
@ -137,28 +243,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'session.info': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'session.info':
|
||||
patchUiState(state => ({
|
||||
...state,
|
||||
info: p as any,
|
||||
usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage
|
||||
info: p,
|
||||
usage: p.usage ? { ...state.usage, ...p.usage } : state.usage
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking.delta': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'thinking.delta':
|
||||
if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
|
||||
patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' })
|
||||
scheduleThinkingStatus(p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready')
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.start':
|
||||
patchUiState({ busy: true })
|
||||
endReasoningPhase()
|
||||
clearReasoning()
|
||||
setActivity([])
|
||||
setSubagents([])
|
||||
setTurnTrail([])
|
||||
activeToolsRef.current = []
|
||||
setTools([])
|
||||
|
|
@ -168,10 +282,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
setToolTokens(0)
|
||||
|
||||
break
|
||||
case 'status.update': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'status.update':
|
||||
if (p?.text) {
|
||||
patchUiState({ status: p.text })
|
||||
setStatus(p.text)
|
||||
|
||||
if (p.kind && p.kind !== 'status') {
|
||||
if (lastStatusNoteRef.current !== p.text) {
|
||||
|
|
@ -194,8 +309,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'gateway.stderr': {
|
||||
const p = ev.payload
|
||||
|
||||
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'
|
||||
|
|
@ -204,18 +322,24 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'gateway.start_timeout':
|
||||
patchUiState({ status: 'gateway startup timeout' })
|
||||
case 'gateway.start_timeout': {
|
||||
const p = ev.payload
|
||||
|
||||
setStatus('gateway startup timeout')
|
||||
pushActivity(
|
||||
`gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`,
|
||||
'error'
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'gateway.protocol_error':
|
||||
patchUiState({ status: 'protocol warning' })
|
||||
case 'gateway.protocol_error': {
|
||||
const p = ev.payload
|
||||
|
||||
setStatus('protocol warning')
|
||||
|
||||
if (statusTimerRef.current) {
|
||||
clearTimeout(statusTimerRef.current)
|
||||
|
|
@ -236,17 +360,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning.delta': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'reasoning.delta':
|
||||
if (p?.text) {
|
||||
reasoningRef.current += p.text
|
||||
setReasoningTokens(estimateTokensRough(reasoningRef.current))
|
||||
scheduleReasoning()
|
||||
pulseReasoningStreaming()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning.available': {
|
||||
const p = ev.payload
|
||||
const incoming = String(p?.text ?? '').trim()
|
||||
|
||||
if (!incoming) {
|
||||
|
|
@ -261,7 +390,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
// nothing.
|
||||
if (!current) {
|
||||
reasoningRef.current = incoming
|
||||
setReasoningTokens(estimateTokensRough(reasoningRef.current))
|
||||
scheduleReasoning()
|
||||
pulseReasoningStreaming()
|
||||
}
|
||||
|
|
@ -269,7 +397,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
break
|
||||
}
|
||||
|
||||
case 'tool.progress':
|
||||
case 'tool.progress': {
|
||||
const p = ev.payload
|
||||
|
||||
if (p?.preview) {
|
||||
const index = activeToolsRef.current.findIndex(tool => tool.name === p.name)
|
||||
|
||||
|
|
@ -278,28 +408,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
next[index] = { ...next[index]!, context: p.preview as string }
|
||||
activeToolsRef.current = next
|
||||
setTools(next)
|
||||
scheduleToolProgress()
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool.generating': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'tool.generating':
|
||||
if (p?.name) {
|
||||
pushTrail(`drafting ${p.name}…`)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool.start': {
|
||||
const p = ev.payload
|
||||
pruneTransient()
|
||||
endReasoningPhase()
|
||||
const ctx = (p.context as string) || ''
|
||||
const name = p.name ?? 'tool'
|
||||
const ctx = p.context ?? ''
|
||||
const sample = `${String(p.name ?? '')} ${ctx}`.trim()
|
||||
toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0
|
||||
setToolTokens(toolTokenAccRef.current)
|
||||
activeToolsRef.current = [
|
||||
...activeToolsRef.current,
|
||||
{ id: p.tool_id, name: p.name, context: ctx, startedAt: Date.now() }
|
||||
{ id: p.tool_id, name, context: ctx, startedAt: Date.now() }
|
||||
]
|
||||
setTools(activeToolsRef.current)
|
||||
|
||||
|
|
@ -307,17 +444,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
case 'tool.complete': {
|
||||
const p = ev.payload
|
||||
toolCompleteRibbonRef.current = null
|
||||
const done = activeToolsRef.current.find(tool => tool.id === p.tool_id)
|
||||
const name = done?.name ?? p.name
|
||||
const name = done?.name ?? p.name ?? 'tool'
|
||||
const label = toolTrailLabel(name)
|
||||
|
||||
const line = buildToolTrailLine(
|
||||
name,
|
||||
done?.context || '',
|
||||
!!p.error,
|
||||
(p.error as string) || (p.summary as string) || ''
|
||||
)
|
||||
const line = buildToolTrailLine(name, done?.context || '', !!p.error, p.error || p.summary || '')
|
||||
|
||||
const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
|
||||
|
||||
|
|
@ -333,37 +466,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
setTurnTrail(turnToolsRef.current)
|
||||
|
||||
if (p?.inline_diff) {
|
||||
sys(p.inline_diff as string)
|
||||
sys(p.inline_diff)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'clarify.request':
|
||||
case 'clarify.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } })
|
||||
patchUiState({ status: 'waiting for input…' })
|
||||
setStatus('waiting for input…')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.request':
|
||||
case 'approval.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ approval: { command: p.command, description: p.description } })
|
||||
patchUiState({ status: 'approval needed' })
|
||||
setStatus('approval needed')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'sudo.request':
|
||||
case 'sudo.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ sudo: { requestId: p.request_id } })
|
||||
patchUiState({ status: 'sudo password needed' })
|
||||
setStatus('sudo password needed')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'secret.request':
|
||||
case 'secret.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } })
|
||||
patchUiState({ status: 'secret input needed' })
|
||||
setStatus('secret input needed')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'background.complete':
|
||||
case 'background.complete': {
|
||||
const p = ev.payload
|
||||
patchUiState(state => {
|
||||
const next = new Set(state.bgTasks)
|
||||
|
||||
|
|
@ -374,8 +516,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
sys(`[bg ${p.task_id}] ${p.text}`)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'btw.complete':
|
||||
case 'btw.complete': {
|
||||
const p = ev.payload
|
||||
patchUiState(state => {
|
||||
const next = new Set(state.bgTasks)
|
||||
|
||||
|
|
@ -386,8 +530,92 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
sys(`[btw] ${p.text}`)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.delta':
|
||||
case 'subagent.start': {
|
||||
const p = ev.payload
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: 'running',
|
||||
taskCount: p.task_count ?? current.taskCount
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.thinking': {
|
||||
const p = ev.payload
|
||||
const text = String(p.text ?? '').trim()
|
||||
|
||||
if (!text) {
|
||||
break
|
||||
}
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: current.status === 'completed' ? current.status : 'running',
|
||||
taskCount: p.task_count ?? current.taskCount,
|
||||
thinking: current.thinking.at(-1) === text ? current.thinking : [...current.thinking, text].slice(-6)
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.tool': {
|
||||
const p = ev.payload
|
||||
const line = formatToolCall(p.tool_name ?? 'delegate_task', p.tool_preview ?? p.text ?? '')
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: current.status === 'completed' ? current.status : 'running',
|
||||
taskCount: p.task_count ?? current.taskCount,
|
||||
tools: current.tools.at(-1) === line ? current.tools : [...current.tools, line].slice(-8)
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.progress': {
|
||||
const p = ev.payload
|
||||
const text = String(p.text ?? '').trim()
|
||||
|
||||
if (!text) {
|
||||
break
|
||||
}
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: current.status === 'completed' ? current.status : 'running',
|
||||
taskCount: p.task_count ?? current.taskCount,
|
||||
notes: current.notes.at(-1) === text ? current.notes : [...current.notes, text].slice(-6)
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.complete': {
|
||||
const p = ev.payload
|
||||
const status = p.status ?? 'completed'
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
durationSeconds: p.duration_seconds ?? current.durationSeconds,
|
||||
goal: p.goal || current.goal,
|
||||
status,
|
||||
summary: p.summary || p.text || current.summary,
|
||||
taskCount: p.task_count ?? current.taskCount
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.delta': {
|
||||
const p = ev.payload
|
||||
pruneTransient()
|
||||
endReasoningPhase()
|
||||
|
||||
|
|
@ -397,7 +625,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.complete': {
|
||||
const p = ev.payload
|
||||
const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart()
|
||||
const persisted = persistedToolLabelsRef.current
|
||||
const savedReasoning = reasoningRef.current.trim()
|
||||
|
|
@ -432,7 +663,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
persistedToolLabelsRef.current.clear()
|
||||
setActivity([])
|
||||
bufRef.current = ''
|
||||
patchUiState({ status: 'ready' })
|
||||
setStatus('ready')
|
||||
|
||||
if (p?.usage) {
|
||||
patchUiState({ usage: p.usage })
|
||||
|
|
@ -451,7 +682,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
break
|
||||
}
|
||||
|
||||
case 'error':
|
||||
case 'error': {
|
||||
const p = ev.payload
|
||||
idle()
|
||||
clearReasoning()
|
||||
turnToolsRef.current = []
|
||||
|
|
@ -464,9 +696,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
pushActivity(String(p?.message || 'unknown error'), 'error')
|
||||
sys(`error: ${p?.message}`)
|
||||
patchUiState({ status: 'ready' })
|
||||
setStatus('ready')
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { HOTKEYS } from '../constants.js'
|
||||
import type {
|
||||
BackgroundStartResponse,
|
||||
SessionHistoryResponse,
|
||||
SlashExecResponse,
|
||||
ToolsConfigureResponse,
|
||||
ToolsListResponse,
|
||||
ToolsShowResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { writeOsc52Clipboard } from '../lib/osc52.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
|
|
@ -12,7 +20,7 @@ import { getUiState, patchUiState } from './uiStore.js'
|
|||
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
|
||||
const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
|
||||
const { gw, rpc } = ctx.gateway
|
||||
const { catalog, lastUserMsg, maybeWarn, messages } = ctx.local
|
||||
const { catalog, getHistoryItems, getLastUserMsg, maybeWarn } = ctx.local
|
||||
|
||||
const {
|
||||
closeSession,
|
||||
|
|
@ -24,9 +32,24 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
setSessionStartedAt
|
||||
} = ctx.session
|
||||
|
||||
const { page, panel, send, setHistoryItems, setMessages, sys, trimLastExchange } = ctx.transcript
|
||||
const { page, panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript
|
||||
const { setVoiceEnabled } = ctx.voice
|
||||
|
||||
const showSlashOutput = (title: string, command: string) => {
|
||||
gw.request<SlashExecResponse>('slash.exec', { command, session_id: getUiState().sid })
|
||||
.then(r => {
|
||||
const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)'
|
||||
const lines = text.split('\n').filter(Boolean)
|
||||
|
||||
if (lines.length > 2 || text.length > 180) {
|
||||
page(text, title)
|
||||
} else {
|
||||
sys(text)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
}
|
||||
|
||||
const handler = (cmd: string): boolean => {
|
||||
const ui = getUiState()
|
||||
const detailsMode = ui.detailsMode
|
||||
|
|
@ -160,7 +183,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
}
|
||||
}
|
||||
|
||||
const all = messages.filter((m: any) => m.role === 'assistant')
|
||||
const all = getHistoryItems().filter((m: any) => m.role === 'assistant')
|
||||
|
||||
if (arg && Number.isNaN(parseInt(arg, 10))) {
|
||||
sys('usage: /copy [number]')
|
||||
|
|
@ -244,7 +267,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
}
|
||||
|
||||
if (r.removed > 0) {
|
||||
setMessages((prev: any[]) => trimLastExchange(prev))
|
||||
setHistoryItems((prev: any[]) => trimLastExchange(prev))
|
||||
sys(`undid ${r.removed} messages`)
|
||||
} else {
|
||||
|
|
@ -253,8 +275,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
})
|
||||
|
||||
return true
|
||||
case 'retry': {
|
||||
const lastUserMsg = getLastUserMsg()
|
||||
|
||||
case 'retry':
|
||||
if (!lastUserMsg) {
|
||||
sys('nothing to retry')
|
||||
|
||||
|
|
@ -273,7 +296,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return
|
||||
}
|
||||
|
||||
setMessages((prev: any[]) => trimLastExchange(prev))
|
||||
setHistoryItems((prev: any[]) => trimLastExchange(prev))
|
||||
send(lastUserMsg)
|
||||
})
|
||||
|
|
@ -284,6 +306,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
send(lastUserMsg)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case 'background':
|
||||
|
||||
|
|
@ -294,13 +317,15 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
}
|
||||
|
||||
rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => {
|
||||
if (!r?.task_id) {
|
||||
rpc<BackgroundStartResponse>('prompt.background', { session_id: sid, text: arg }).then(r => {
|
||||
const taskId = r?.task_id
|
||||
|
||||
if (!taskId) {
|
||||
return
|
||||
}
|
||||
|
||||
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id) }))
|
||||
sys(`bg ${r.task_id} started`)
|
||||
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) }))
|
||||
sys(`bg ${taskId} started`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
|
@ -483,7 +508,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
|
||||
if (Array.isArray(r.messages)) {
|
||||
const resumed = toTranscriptMessages(r.messages)
|
||||
setMessages(resumed)
|
||||
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
|
||||
}
|
||||
|
||||
|
|
@ -526,7 +550,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
patchUiState({ sid: r.session_id })
|
||||
setSessionStartedAt(Date.now())
|
||||
setHistoryItems([])
|
||||
setMessages([])
|
||||
sys(`branched → ${r.title}`)
|
||||
}
|
||||
})
|
||||
|
|
@ -547,6 +570,26 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
|
||||
return true
|
||||
|
||||
case 'fast':
|
||||
showSlashOutput('Fast', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'debug':
|
||||
showSlashOutput('Debug', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'snapshot':
|
||||
showSlashOutput('Snapshot', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'platforms':
|
||||
showSlashOutput('Platforms', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'title':
|
||||
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
|
||||
if (!r) {
|
||||
|
|
@ -618,12 +661,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
|
||||
case 'history':
|
||||
rpc('session.history', { session_id: sid }).then((r: any) => {
|
||||
rpc<SessionHistoryResponse>('session.history', { session_id: sid }).then(r => {
|
||||
if (typeof r?.count !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`${r.count} messages`)
|
||||
if (!r.messages?.length) {
|
||||
sys(`${r.count} messages`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const text = r.messages
|
||||
.map((msg, index) => {
|
||||
if (msg.role === 'tool') {
|
||||
return `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim()
|
||||
}
|
||||
|
||||
return `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim()
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
page(text, `History (${r.count})`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
|
@ -917,29 +976,98 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
return true
|
||||
case 'tools': {
|
||||
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
|
||||
|
||||
case 'tools':
|
||||
rpc('tools.list', { session_id: sid })
|
||||
.then((r: any) => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
if (!subcommand) {
|
||||
rpc<ToolsShowResponse>('tools.show', { session_id: sid })
|
||||
.then(r => {
|
||||
if (!r?.sections?.length) {
|
||||
return sys('no tools')
|
||||
}
|
||||
|
||||
if (!r.toolsets?.length) {
|
||||
return sys('no tools')
|
||||
}
|
||||
panel(
|
||||
`Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`,
|
||||
r.sections.map(section => ({
|
||||
title: section.name,
|
||||
rows: section.tools.map(tool => [tool.name, tool.description] as [string, string])
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
panel(
|
||||
'Tools',
|
||||
r.toolsets.map((ts: any) => ({
|
||||
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
|
||||
items: ts.tools
|
||||
}))
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (subcommand === 'list') {
|
||||
rpc<ToolsListResponse>('tools.list', { session_id: sid })
|
||||
.then(r => {
|
||||
if (!r?.toolsets?.length) {
|
||||
return sys('no tools')
|
||||
}
|
||||
|
||||
panel(
|
||||
'Tools',
|
||||
r.toolsets.map(ts => ({
|
||||
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
|
||||
items: ts.tools
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (subcommand === 'disable' || subcommand === 'enable') {
|
||||
if (!names.length) {
|
||||
sys(`usage: /tools ${subcommand} <name> [name ...]`)
|
||||
sys(`built-in toolset: /tools ${subcommand} web`)
|
||||
sys(`MCP tool: /tools ${subcommand} github:create_issue`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
rpc<ToolsConfigureResponse>('tools.configure', {
|
||||
action: subcommand,
|
||||
names,
|
||||
session_id: sid
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.then(r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
if (r.info) {
|
||||
setSessionStartedAt(Date.now())
|
||||
resetVisibleHistory(r.info)
|
||||
}
|
||||
|
||||
if (r.changed?.length) {
|
||||
sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
|
||||
}
|
||||
|
||||
if (r.unknown?.length) {
|
||||
sys(`unknown toolsets: ${r.unknown.join(', ')}`)
|
||||
}
|
||||
|
||||
if (r.missing_servers?.length) {
|
||||
sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
|
||||
}
|
||||
|
||||
if (r.reset) {
|
||||
sys('session reset. new tool configuration is active.')
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
sys('usage: /tools [list|disable|enable] ...')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case 'toolsets':
|
||||
rpc('toolsets.list', { session_id: sid })
|
||||
|
|
@ -969,6 +1097,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
|
||||
default:
|
||||
if (catalog?.canon) {
|
||||
const needle = `/${name}`.toLowerCase()
|
||||
|
||||
const matches = [
|
||||
...new Set(
|
||||
Object.entries(catalog.canon)
|
||||
.filter(([alias]) => alias.startsWith(needle))
|
||||
.map(([, canon]) => canon)
|
||||
)
|
||||
]
|
||||
|
||||
if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
|
||||
return handler(`${matches[0]}${arg ? ' ' + arg : ''}`)
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
||||
.then((r: any) => {
|
||||
sys(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
SecretReq,
|
||||
SessionInfo,
|
||||
SlashCatalog,
|
||||
SubagentProgress,
|
||||
SudoReq,
|
||||
Usage
|
||||
} from '../types.js'
|
||||
|
|
@ -35,7 +36,7 @@ export interface CompletionItem {
|
|||
}
|
||||
|
||||
export interface GatewayRpc {
|
||||
(method: string, params?: Record<string, unknown>): Promise<RpcResult | null>
|
||||
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<T | null>
|
||||
}
|
||||
|
||||
export interface GatewayServices {
|
||||
|
|
@ -176,6 +177,7 @@ export interface TurnActions {
|
|||
setToolTokens: StateSetter<number>
|
||||
setReasoningStreaming: StateSetter<boolean>
|
||||
setStreaming: StateSetter<string>
|
||||
setSubagents: StateSetter<SubagentProgress[]>
|
||||
setTools: StateSetter<ActiveTool[]>
|
||||
setTurnTrail: StateSetter<string[]>
|
||||
}
|
||||
|
|
@ -204,6 +206,7 @@ export interface TurnState {
|
|||
reasoningActive: boolean
|
||||
reasoningStreaming: boolean
|
||||
streaming: string
|
||||
subagents: SubagentProgress[]
|
||||
toolTokens: number
|
||||
tools: ActiveTool[]
|
||||
turnTrail: string[]
|
||||
|
|
@ -278,7 +281,6 @@ export interface GatewayEventHandlerContext {
|
|||
transcript: {
|
||||
appendMessage: (msg: Msg) => void
|
||||
setHistoryItems: StateSetter<Msg[]>
|
||||
setMessages: StateSetter<Msg[]>
|
||||
}
|
||||
turn: {
|
||||
actions: Pick<
|
||||
|
|
@ -295,6 +297,7 @@ export interface GatewayEventHandlerContext {
|
|||
| 'setActivity'
|
||||
| 'setReasoningTokens'
|
||||
| 'setStreaming'
|
||||
| 'setSubagents'
|
||||
| 'setToolTokens'
|
||||
| 'setTools'
|
||||
| 'setTurnTrail'
|
||||
|
|
@ -328,9 +331,9 @@ export interface SlashHandlerContext {
|
|||
gateway: GatewayServices
|
||||
local: {
|
||||
catalog: SlashCatalog | null
|
||||
lastUserMsg: string
|
||||
getHistoryItems: () => Msg[]
|
||||
getLastUserMsg: () => string
|
||||
maybeWarn: (value: any) => void
|
||||
messages: Msg[]
|
||||
}
|
||||
session: {
|
||||
closeSession: (targetSid?: string | null) => Promise<unknown>
|
||||
|
|
@ -346,7 +349,6 @@ export interface SlashHandlerContext {
|
|||
panel: (title: string, sections: PanelSection[]) => void
|
||||
send: (text: string) => void
|
||||
setHistoryItems: StateSetter<Msg[]>
|
||||
setMessages: StateSetter<Msg[]>
|
||||
sys: (text: string) => void
|
||||
trimLastExchange: (items: Msg[]) => Msg[]
|
||||
}
|
||||
|
|
@ -389,6 +391,7 @@ export interface AppLayoutProgressProps {
|
|||
showProgressArea: boolean
|
||||
showStreamingArea: boolean
|
||||
streaming: string
|
||||
subagents: SubagentProgress[]
|
||||
toolTokens: number
|
||||
tools: ActiveTool[]
|
||||
turnTrail: string[]
|
||||
|
|
@ -396,7 +399,7 @@ export interface AppLayoutProgressProps {
|
|||
|
||||
export interface AppLayoutStatusProps {
|
||||
cwdLabel: string
|
||||
durationLabel: string
|
||||
sessionStartedAt: number | null
|
||||
showStickyPrompt: boolean
|
||||
statusColor: string
|
||||
stickyPrompt: string
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem } from '../types.js'
|
||||
import { estimateTokensRough, isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
|
||||
|
||||
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js'
|
||||
import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js'
|
||||
|
|
@ -16,6 +16,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
const [toolTokens, setToolTokens] = useState(0)
|
||||
const [reasoningStreaming, setReasoningStreaming] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [subagents, setSubagents] = useState<SubagentProgress[]>([])
|
||||
const [tools, setTools] = useState<ActiveTool[]>([])
|
||||
const [turnTrail, setTurnTrail] = useState<string[]>([])
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
reasoningTimerRef.current = setTimeout(() => {
|
||||
reasoningTimerRef.current = null
|
||||
setReasoning(reasoningRef.current)
|
||||
setReasoningTokens(estimateTokensRough(reasoningRef.current))
|
||||
}, STREAM_BATCH_MS)
|
||||
}, [])
|
||||
|
||||
|
|
@ -147,6 +149,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
const idle = useCallback(() => {
|
||||
endReasoningPhase()
|
||||
activeToolsRef.current = []
|
||||
setSubagents([])
|
||||
setTools([])
|
||||
setTurnTrail([])
|
||||
patchUiState({ busy: false })
|
||||
|
|
@ -165,7 +168,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => {
|
||||
interruptedRef.current = true
|
||||
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
|
||||
const partial = (streaming || bufRef.current).trimStart()
|
||||
const partial = bufRef.current.trimStart()
|
||||
|
||||
if (partial) {
|
||||
appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' })
|
||||
|
|
@ -188,7 +191,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
patchUiState({ status: 'ready' })
|
||||
}, 1500)
|
||||
},
|
||||
[clearReasoning, idle, streaming]
|
||||
[clearReasoning, idle]
|
||||
)
|
||||
|
||||
const actions = useMemo(
|
||||
|
|
@ -210,6 +213,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
setToolTokens,
|
||||
setReasoningStreaming,
|
||||
setStreaming,
|
||||
setSubagents,
|
||||
setTools,
|
||||
setTurnTrail
|
||||
}),
|
||||
|
|
@ -256,10 +260,22 @@ export function useTurnState(): UseTurnStateResult {
|
|||
toolTokens,
|
||||
reasoningStreaming,
|
||||
streaming,
|
||||
subagents,
|
||||
tools,
|
||||
turnTrail
|
||||
}),
|
||||
[activity, reasoning, reasoningTokens, reasoningActive, toolTokens, reasoningStreaming, streaming, tools, turnTrail]
|
||||
[
|
||||
activity,
|
||||
reasoning,
|
||||
reasoningTokens,
|
||||
reasoningActive,
|
||||
toolTokens,
|
||||
reasoningStreaming,
|
||||
streaming,
|
||||
subagents,
|
||||
tools,
|
||||
turnTrail
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
40
ui-tui/src/app/widgetStore.ts
Normal file
40
ui-tui/src/app/widgetStore.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { WIDGET_CATALOG } from '../widgets.js'
|
||||
|
||||
export interface WidgetState {
|
||||
enabled: Record<string, boolean>
|
||||
params: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
function defaults(): WidgetState {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
|
||||
for (const w of WIDGET_CATALOG) {
|
||||
enabled[w.id] = w.defaultOn
|
||||
}
|
||||
|
||||
return { enabled, params: {} }
|
||||
}
|
||||
|
||||
export const $widgetState = atom<WidgetState>(defaults())
|
||||
|
||||
export function toggleWidget(id: string, force?: boolean) {
|
||||
const s = $widgetState.get()
|
||||
const next = force ?? !s.enabled[id]
|
||||
|
||||
$widgetState.set({ ...s, enabled: { ...s.enabled, [id]: next } })
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function setWidgetParam(id: string, key: string, value: string) {
|
||||
const s = $widgetState.get()
|
||||
const prev = s.params[id] ?? {}
|
||||
|
||||
$widgetState.set({ ...s, params: { ...s.params, [id]: { ...prev, [key]: value } } })
|
||||
}
|
||||
|
||||
export function getWidgetEnabled(id: string): boolean {
|
||||
return $widgetState.get().enabled[id] ?? false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue