Files
hermes-agent/ui-tui/src/app/slash/createSlashSessionHandler.ts
Brooklyn Nicholson 8e06db56fd chore: uptick
2026-04-16 01:04:35 -05:00

465 lines
12 KiB
TypeScript

import type { BackgroundStartResponse, SessionHistoryResponse } from '../../gatewayTypes.js'
import { rpcErrorMessage } from '../../lib/rpc.js'
import { fmtK } from '../../lib/text.js'
import type { PanelSection } from '../../types.js'
import { imageTokenMeta, introMsg, toTranscriptMessages } from '../helpers.js'
import type { SlashHandlerContext } from '../interfaces.js'
import { patchOverlayState } from '../overlayStore.js'
import { patchUiState } from '../uiStore.js'
import { isStaleSlash } from './isStaleSlash.js'
import type { ParsedSlashCommand, SlashShared } from './shared.js'
const SLASH_OUTPUT_PAGE: Record<string, string> = {
debug: 'Debug',
fast: 'Fast',
platforms: 'Platforms',
snapshot: 'Snapshot'
}
export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: SlashShared) {
const { setInput } = ctx.composer
const { gw, rpc } = ctx.gateway
const { maybeWarn } = ctx.local
const { closeSession, guardBusySessionSwitch, resetVisibleHistory, setSessionStartedAt } = ctx.session
const { page, panel, setHistoryItems, sys } = ctx.transcript
const { setVoiceEnabled } = ctx.voice
return ({ arg, cmd, flight, name, sid }: SessionSlashCommand) => {
const stale = () => isStaleSlash(ctx, flight, sid)
const pageTitle = SLASH_OUTPUT_PAGE[name]
if (pageTitle) {
shared.showSlashOutput({ command: cmd.slice(1), flight, sid, title: pageTitle })
return true
}
switch (name) {
case 'background':
case 'bg':
if (!arg) {
sys('/background <prompt>')
return true
}
rpc<BackgroundStartResponse>('prompt.background', { session_id: sid, text: arg }).then(r => {
if (stale()) {
return
}
const taskId = r?.task_id
if (!taskId) {
return
}
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) }))
sys(`bg ${taskId} started`)
})
return true
case 'btw':
if (!arg) {
sys('/btw <question>')
return true
}
rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => {
if (stale() || !r) {
return
}
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') }))
sys('btw running…')
})
return true
case 'model':
if (guardBusySessionSwitch('change models')) {
return true
}
if (!arg) {
patchOverlayState({ modelPicker: true })
return true
}
rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => {
if (stale() || !r) {
return
}
if (!r.value) {
sys('error: invalid response: model switch')
return
}
sys(`model → ${r.value}`)
maybeWarn(r)
patchUiState(state => ({
...state,
info: state.info ? { ...state.info, model: r.value } : { model: r.value, skills: {}, tools: {} }
}))
})
return true
case 'image':
rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => {
if (stale() || !r) {
return
}
const meta = imageTokenMeta(r)
sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
r?.remainder && setInput(r.remainder)
})
return true
case 'provider':
gw.request('slash.exec', { command: 'provider', session_id: sid })
.then((r: any) => {
if (stale()) {
return
}
page(
r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)',
'Provider'
)
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
case 'skin':
if (arg) {
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => {
if (stale() || !r?.value) {
return
}
sys(`skin → ${r.value}`)
})
} else {
rpc('config.get', { key: 'skin' }).then((r: any) => {
if (stale() || !r) {
return
}
sys(`skin: ${r.value || 'default'}`)
})
}
return true
case 'yolo':
rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => {
if (stale() || !r) {
return
}
sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
})
return true
case 'reasoning':
if (!arg) {
rpc('config.get', { key: 'reasoning' }).then((r: any) => {
if (stale() || !r?.value) {
return
}
sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
})
} else {
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => {
if (stale() || !r?.value) {
return
}
sys(`reasoning: ${r.value}`)
})
}
return true
case 'verbose':
rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => {
if (stale() || !r?.value) {
return
}
sys(`verbose: ${r.value}`)
})
return true
case 'personality':
if (arg) {
rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => {
if (stale() || !r) {
return
}
r.history_reset && resetVisibleHistory(r.info ?? null)
sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`)
maybeWarn(r)
})
return true
}
gw.request('slash.exec', { command: 'personality', session_id: sid })
.then((r: any) => {
if (stale()) {
return
}
panel('Personality', [
{
text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)'
}
])
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
case 'compress':
rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => {
if (stale() || !r) {
return
}
Array.isArray(r.messages) &&
setHistoryItems(
r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages)
)
r.info && patchUiState({ info: r.info })
r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } }))
if ((r.removed ?? 0) <= 0) {
sys('nothing to compress')
return
}
sys(`compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}`)
})
return true
case 'stop':
rpc('process.stop', {}).then((r: any) => {
if (stale() || !r) {
return
}
sys(`killed ${r.killed ?? 0} registered process(es)`)
})
return true
case 'branch':
case 'fork': {
const prevSid = sid
rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => {
if (stale() || !r?.session_id) {
return
}
void closeSession(prevSid)
patchUiState({ sid: r.session_id })
setSessionStartedAt(Date.now())
setHistoryItems([])
sys(`branched → ${r.title}`)
})
return true
}
case 'reload-mcp':
case 'reload_mcp':
rpc('reload.mcp', { session_id: sid }).then((r: any) => {
if (stale() || !r) {
return
}
sys('MCP reloaded')
})
return true
case 'title':
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
if (stale() || !r) {
return
}
sys(`title: ${r.title || '(none)'}`)
})
return true
case 'usage':
rpc('session.usage', { session_id: sid }).then((r: any) => {
if (stale()) {
return
}
if (r) {
patchUiState({
usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }
})
}
if (!r?.calls) {
sys('no API calls yet')
return
}
const f = (v: number) => (v ?? 0).toLocaleString()
const cost =
r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
const rows: [string, string][] = [
['Model', r.model ?? ''],
['Input tokens', f(r.input)],
['Cache read tokens', f(r.cache_read)],
['Cache write tokens', f(r.cache_write)],
['Output tokens', f(r.output)],
['Total tokens', f(r.total)],
['API calls', f(r.calls)]
]
const sections: PanelSection[] = [{ rows }]
cost && rows.push(['Cost', cost])
r.context_max &&
sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` })
r.compressions && sections.push({ text: `Compressions: ${r.compressions}` })
panel('Usage', sections)
})
return true
case 'save':
rpc('session.save', { session_id: sid }).then((r: any) => {
if (stale() || !r?.file) {
return
}
sys(`saved: ${r.file}`)
})
return true
case 'history':
rpc<SessionHistoryResponse>('session.history', { session_id: sid }).then(r => {
if (stale() || typeof r?.count !== 'number') {
return
}
if (!r.messages?.length) {
sys(`${r.count} messages`)
return
}
page(
r.messages
.map((msg, index) =>
msg.role === 'tool'
? `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim()
: `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim()
)
.join('\n\n'),
`History (${r.count})`
)
})
return true
case 'profile':
rpc('config.get', { key: 'profile' }).then((r: any) => {
if (stale() || !r) {
return
}
const text = r.display || r.home || '(unknown profile)'
const lines = text.split('\n').filter(Boolean)
lines.length <= 2 ? panel('Profile', [{ text }]) : page(text, 'Profile')
})
return true
case 'voice':
rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => {
if (stale() || !r) {
return
}
setVoiceEnabled(!!r?.enabled)
sys(`voice: ${r.enabled ? 'on' : 'off'}`)
})
return true
case 'insights':
rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => {
if (stale() || !r) {
return
}
panel('Insights', [
{
rows: [
['Period', `${r.days} days`],
['Sessions', `${r.sessions}`],
['Messages', `${r.messages}`]
]
}
])
})
return true
}
return false
}
}
interface SessionSlashCommand extends ParsedSlashCommand {
flight: number
sid: null | string
}