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:
Brooklyn Nicholson 2026-04-16 12:18:56 -05:00
parent 9c71f3a6ea
commit 68ecdb6e26
56 changed files with 3666 additions and 4117 deletions

View file

@ -0,0 +1,293 @@
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import type { ConfigGetValueResponse, ConfigSetResponse, SessionUndoResponse } from '../../../gatewayTypes.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const flagFromArg = (arg: string, current: boolean): boolean | null => {
const mode = arg.trim().toLowerCase()
if (!arg) {
return !current
}
if (mode === 'on') {
return true
}
if (mode === 'off') {
return false
}
if (mode === 'toggle') {
return !current
}
return null
}
const DETAIL_MODES = new Set(['collapsed', 'cycle', 'expanded', 'hidden', 'toggle'])
export const coreCommands: SlashCommand[] = [
{
help: 'list commands + hotkeys',
name: 'help',
run: (_arg, ctx) => {
const sections: PanelSection[] = (ctx.local.catalog?.categories ?? []).map(cat => ({
rows: cat.pairs,
title: cat.name
}))
if (ctx.local.catalog?.skillCount) {
sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
}
sections.push({
rows: [
['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'],
['/fortune [random|daily]', 'show a random or daily local fortune']
],
title: 'TUI'
})
sections.push({ rows: HOTKEYS, title: 'Hotkeys' })
ctx.transcript.panel('Commands', sections)
}
},
{
aliases: ['exit', 'q'],
help: 'exit hermes',
name: 'quit',
run: (_arg, ctx) => ctx.session.die()
},
{
aliases: ['new'],
help: 'start a new session',
name: 'clear',
run: (_arg, ctx, cmd) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
patchUiState({ status: 'forging session…' })
ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined)
}
},
{
help: 'resume a prior session',
name: 'resume',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
arg ? ctx.session.resumeById(arg) : patchOverlayState({ picker: true })
}
},
{
help: 'toggle compact transcript',
name: 'compact',
run: (arg, ctx) => {
const next = flagFromArg(arg, ctx.ui.compact)
if (next === null) {
return ctx.transcript.sys('usage: /compact [on|off|toggle]')
}
patchUiState({ compact: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`compact ${next ? 'on' : 'off'}`))
}
},
{
aliases: ['detail'],
help: 'control agent detail visibility',
name: 'details',
run: (arg, ctx) => {
const { gateway, transcript, ui } = ctx
if (!arg) {
gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'details_mode' })
.then(r => {
if (ctx.stale()) {
return
}
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
patchUiState({ detailsMode: mode })
transcript.sys(`details: ${mode}`)
})
.catch(() => {
if (!ctx.stale()) {
transcript.sys(`details: ${ui.detailsMode}`)
}
})
return
}
const mode = arg.trim().toLowerCase()
if (!DETAIL_MODES.has(mode)) {
return transcript.sys('usage: /details [hidden|collapsed|expanded|cycle]')
}
const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode)
patchUiState({ detailsMode: next })
gateway.rpc<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
transcript.sys(`details: ${next}`)
}
},
{
help: 'local fortune',
name: 'fortune',
run: (arg, ctx) => {
const key = arg.trim().toLowerCase()
if (!arg || key === 'random') {
return ctx.transcript.sys(randomFortune())
}
if (['daily', 'stable', 'today'].includes(key)) {
return ctx.transcript.sys(dailyFortune(ctx.sid))
}
ctx.transcript.sys('usage: /fortune [random|daily]')
}
},
{
help: 'copy selection or assistant message',
name: 'copy',
run: (arg, ctx) => {
const { sys } = ctx.transcript
if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) {
return sys('copied selection')
}
if (arg && Number.isNaN(parseInt(arg, 10))) {
return sys('usage: /copy [number]')
}
const all = ctx.local.getHistoryItems().filter(m => m.role === 'assistant')
const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
if (!target) {
return sys('nothing to copy')
}
writeOsc52Clipboard(target.text)
sys('sent OSC52 copy sequence (terminal support required)')
}
},
{
help: 'paste clipboard image',
name: 'paste',
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste())
},
{
help: 'view gateway logs',
name: 'logs',
run: (arg, ctx) => {
const text = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
text ? ctx.transcript.page(text, 'Logs') : ctx.transcript.sys('no gateway logs')
}
},
{
aliases: ['sb'],
help: 'toggle status bar',
name: 'statusbar',
run: (arg, ctx) => {
const next = flagFromArg(arg, ctx.ui.statusBar)
if (next === null) {
return ctx.transcript.sys('usage: /statusbar [on|off|toggle]')
}
patchUiState({ statusBar: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`))
}
},
{
help: 'inspect or enqueue a message',
name: 'queue',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys(`${ctx.composer.queueRef.current.length} queued message(s)`)
}
ctx.composer.enqueue(arg)
ctx.transcript.sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`)
}
},
{
help: 'undo last exchange',
name: 'undo',
run: (_arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('nothing to undo')
}
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(r => {
if ((r.removed ?? 0) > 0) {
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
ctx.transcript.sys(`undid ${r.removed} messages`)
} else {
ctx.transcript.sys('nothing to undo')
}
})
)
}
},
{
help: 'retry last user message',
name: 'retry',
run: (_arg, ctx) => {
const last = ctx.local.getLastUserMsg()
if (!last) {
return ctx.transcript.sys('nothing to retry')
}
if (!ctx.sid) {
return ctx.transcript.send(last)
}
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(r => {
if ((r.removed ?? 0) <= 0) {
return ctx.transcript.sys('nothing to retry')
}
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
ctx.transcript.send(last)
})
)
}
}
]

View file

@ -0,0 +1,368 @@
import type {
AgentsListResponse,
BrowserManageResponse,
ConfigShowResponse,
CronListResponse,
PluginsListResponse,
RollbackActionResponse,
RollbackListResponse,
SkillsBrowseResponse,
SkillsListResponse,
SlashExecResponse,
ToolsConfigureResponse,
ToolsetsListResponse,
ToolsListResponse,
ToolsShowResponse
} from '../../../gatewayTypes.js'
import type { PanelSection } from '../../../types.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
const passthroughSlash = (ctx: SlashRunCtx, cmd: string, fallback: string) =>
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
ctx.transcript.sys(r?.warning ? `warning: ${r.warning}\n${r?.output || fallback}` : r?.output || fallback)
})
.catch(ctx.guardedErr)
const clip = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}` : s)
export const opsCommands: SlashCommand[] = [
{
help: 'list or restore checkpoints',
name: 'rollback',
run: (arg, ctx) => {
const [sub, ...rest] = (arg || 'list').split(/\s+/)
if (!sub || sub === 'list') {
return ctx.gateway.rpc<RollbackListResponse>('rollback.list', { session_id: ctx.sid }).then(
ctx.guarded<RollbackListResponse>(r => {
if (!r.checkpoints?.length) {
return ctx.transcript.sys('no checkpoints')
}
ctx.transcript.panel('Checkpoints', [
{
rows: r.checkpoints.map(
(c, i) => [`${i + 1} ${c.hash?.slice(0, 8) ?? ''}`, c.message ?? ''] as [string, string]
)
}
])
})
)
}
const isRestoreOrDiff = sub === 'restore' || sub === 'diff'
const hash = isRestoreOrDiff ? rest[0] : sub
const filePath = (isRestoreOrDiff ? rest.slice(1) : rest).join(' ').trim()
const method = sub === 'diff' ? 'rollback.diff' : 'rollback.restore'
ctx.gateway
.rpc<RollbackActionResponse>(method, {
hash,
session_id: ctx.sid,
...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
})
.then(ctx.guarded<RollbackActionResponse>(r => ctx.transcript.sys(r.rendered || r.diff || r.message || 'done')))
}
},
{
help: 'manage browser connection',
name: 'browser',
run: (arg, ctx) => {
const [action, url] = (arg || 'status').split(/\s+/)
ctx.gateway
.rpc<BrowserManageResponse>('browser.manage', { action, ...(url ? { url } : {}) })
.then(
ctx.guarded<BrowserManageResponse>(r =>
ctx.transcript.sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
)
)
}
},
{
help: 'list installed plugins',
name: 'plugins',
run: (_arg, ctx) => {
ctx.gateway.rpc<PluginsListResponse>('plugins.list', {}).then(
ctx.guarded<PluginsListResponse>(r => {
if (!r.plugins?.length) {
return ctx.transcript.sys('no plugins')
}
ctx.transcript.panel('Plugins', [
{ items: r.plugins.map(p => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) }
])
})
)
}
},
{
help: 'list or browse skills',
name: 'skills',
run: (arg, ctx, cmd) => {
const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean)
if (!sub || sub === 'list') {
return ctx.gateway.rpc<SkillsListResponse>('skills.manage', { action: 'list' }).then(
ctx.guarded<SkillsListResponse>(r => {
if (!r.skills || !Object.keys(r.skills).length) {
return ctx.transcript.sys('no skills installed')
}
ctx.transcript.panel(
'Installed Skills',
Object.entries(r.skills).map(([title, items]) => ({ items, title }))
)
})
)
}
if (sub === 'browse') {
const pageNumber = parseInt(rest[0] ?? '1', 10) || 1
return ctx.gateway.rpc<SkillsBrowseResponse>('skills.manage', { action: 'browse', page: pageNumber }).then(
ctx.guarded<SkillsBrowseResponse>(r => {
if (!r.items?.length) {
return ctx.transcript.sys('no skills found in the hub')
}
const page = r.page ?? 1
const totalPages = r.total_pages ?? 1
const sections: PanelSection[] = [
{
rows: r.items.map(s => [s.name ?? '', clip(s.description ?? '', 60)] as [string, string])
}
]
if (page < totalPages) {
sections.push({ text: `/skills browse ${page + 1} → next page` })
}
if (page > 1) {
sections.push({ text: `/skills browse ${page - 1} → prev page` })
}
ctx.transcript.panel(`Skills Hub (page ${page}/${totalPages}, ${r.total ?? 0} total)`, sections)
})
)
}
passthroughSlash(ctx, cmd, '/skills: no output')
}
},
{
aliases: ['tasks'],
help: 'running agents',
name: 'agents',
run: (_arg, ctx) => {
ctx.gateway
.rpc<AgentsListResponse>('agents.list', {})
.then(
ctx.guarded<AgentsListResponse>(r => {
const processes = r.processes ?? []
const running = processes.filter(p => p.status === 'running')
const finished = processes.filter(p => p.status !== 'running')
const sections: PanelSection[] = []
if (running.length) {
sections.push({
rows: running.map(p => [p.session_id.slice(0, 8), p.command ?? '']),
title: `Running (${running.length})`
})
}
if (finished.length) {
sections.push({
rows: finished.map(p => [p.session_id.slice(0, 8), p.command ?? '']),
title: `Finished (${finished.length})`
})
}
if (!sections.length) {
sections.push({ text: 'No active processes' })
}
ctx.transcript.panel('Agents', sections)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'list or manage cron jobs',
name: 'cron',
run: (arg, ctx, cmd) => {
if (arg && arg !== 'list') {
return passthroughSlash(ctx, cmd, '(no output)')
}
ctx.gateway
.rpc<CronListResponse>('cron.manage', { action: 'list' })
.then(
ctx.guarded<CronListResponse>(r => {
const jobs = r.jobs ?? []
if (!jobs.length) {
return ctx.transcript.sys('no scheduled jobs')
}
ctx.transcript.panel('Cron', [
{
rows: jobs.map(
j =>
[j.name || j.job_id?.slice(0, 12) || '', `${j.schedule ?? ''} · ${j.state ?? 'active'}`] as [
string,
string
]
)
}
])
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'show configuration',
name: 'config',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ConfigShowResponse>('config.show', {})
.then(
ctx.guarded<ConfigShowResponse>(r =>
ctx.transcript.panel(
'Config',
(r.sections ?? []).map(s => ({ rows: s.rows, title: s.title }))
)
)
)
.catch(ctx.guardedErr)
}
},
{
help: 'list, enable, disable tools',
name: 'tools',
run: (arg, ctx) => {
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
if (!subcommand) {
return ctx.gateway
.rpc<ToolsShowResponse>('tools.show', { session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
if (!r?.sections?.length) {
return ctx.transcript.sys('no tools')
}
ctx.transcript.panel(
`Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`,
r.sections.map(section => ({
rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]),
title: section.name
}))
)
})
.catch(ctx.guardedErr)
}
if (subcommand === 'list') {
return ctx.gateway
.rpc<ToolsListResponse>('tools.list', { session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
if (!r?.toolsets?.length) {
return ctx.transcript.sys('no tools')
}
ctx.transcript.panel(
'Tools',
r.toolsets.map(ts => ({
items: ts.tools,
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`
}))
)
})
.catch(ctx.guardedErr)
}
if (subcommand === 'disable' || subcommand === 'enable') {
if (!names.length) {
ctx.transcript.sys(`usage: /tools ${subcommand} <name> [name ...]`)
ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`)
ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`)
return
}
return ctx.gateway
.rpc<ToolsConfigureResponse>('tools.configure', { action: subcommand, names, session_id: ctx.sid })
.then(
ctx.guarded<ToolsConfigureResponse>(r => {
if (r.info) {
ctx.session.setSessionStartedAt(Date.now())
ctx.session.resetVisibleHistory(r.info)
}
r.changed?.length &&
ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
r.unknown?.length && ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`)
r.missing_servers?.length && ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
r.reset && ctx.transcript.sys('session reset. new tool configuration is active.')
})
)
.catch(ctx.guardedErr)
}
ctx.transcript.sys('usage: /tools [list|disable|enable] ...')
}
},
{
help: 'list toolsets',
name: 'toolsets',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ToolsetsListResponse>('toolsets.list', { session_id: ctx.sid })
.then(
ctx.guarded<ToolsetsListResponse>(r => {
if (!r.toolsets?.length) {
return ctx.transcript.sys('no toolsets')
}
ctx.transcript.panel('Toolsets', [
{
rows: r.toolsets.map(
ts =>
[`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [
string,
string
]
)
}
])
})
)
.catch(ctx.guardedErr)
}
}
]

View file

@ -0,0 +1,462 @@
import { imageTokenMeta, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import type {
BackgroundStartResponse,
BtwStartResponse,
ConfigGetValueResponse,
ConfigSetResponse,
ImageAttachResponse,
InsightsResponse,
ReloadMcpResponse,
SessionBranchResponse,
SessionCompressResponse,
SessionHistoryResponse,
SessionSaveResponse,
SessionTitleResponse,
SessionUsageResponse,
SlashExecResponse,
VoiceToggleResponse
} from '../../../gatewayTypes.js'
import { fmtK } from '../../../lib/text.js'
import type { PanelSection } from '../../../types.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const PAGE_TITLES: Record<string, string> = {
debug: 'Debug',
fast: 'Fast',
platforms: 'Platforms',
snapshot: 'Snapshot'
}
const passthrough = (name: string): SlashCommand => ({
name,
run: (_arg, ctx, cmd) =>
ctx.shared.showSlashOutput({
command: cmd.slice(1),
flight: ctx.flight,
sid: ctx.sid,
title: PAGE_TITLES[name] ?? name
})
})
const historyLabel = (role: string) => (role === 'assistant' ? 'Hermes' : role === 'user' ? 'You' : 'System')
export const sessionCommands: SlashCommand[] = [
passthrough('debug'),
passthrough('fast'),
passthrough('platforms'),
passthrough('snapshot'),
{
aliases: ['bg'],
help: 'launch a background prompt',
name: 'background',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys('/background <prompt>')
}
ctx.gateway.rpc<BackgroundStartResponse>('prompt.background', { session_id: ctx.sid, text: arg }).then(
ctx.guarded<BackgroundStartResponse>(r => {
if (!r.task_id) {
return
}
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id!) }))
ctx.transcript.sys(`bg ${r.task_id} started`)
})
)
}
},
{
help: 'by-the-way follow-up',
name: 'btw',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys('/btw <question>')
}
ctx.gateway.rpc<BtwStartResponse>('prompt.btw', { session_id: ctx.sid, text: arg }).then(
ctx.guarded(() => {
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') }))
ctx.transcript.sys('btw running…')
})
)
}
},
{
help: 'change or show model',
name: 'model',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('change models')) {
return
}
if (!arg) {
return patchOverlayState({ modelPicker: true })
}
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: arg.trim() }).then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return ctx.transcript.sys('error: invalid response: model switch')
}
ctx.transcript.sys(`model → ${r.value}`)
ctx.local.maybeWarn(r)
patchUiState(state => ({
...state,
info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
}))
})
)
}
},
{
help: 'attach an image',
name: 'image',
run: (arg, ctx) => {
ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
ctx.guarded<ImageAttachResponse>(r => {
const meta = imageTokenMeta(r)
ctx.transcript.sys(`attached image: ${r.name ?? ''}${meta ? ` · ${meta}` : ''}`)
r.remainder && ctx.composer.setInput(r.remainder)
})
)
}
},
{
help: 'show provider details',
name: 'provider',
run: (_arg, ctx) => {
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: 'provider', session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
ctx.transcript.page(
r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)',
'Provider'
)
})
.catch(ctx.guardedErr)
}
},
{
help: 'switch theme skin',
name: 'skin',
run: (arg, ctx) => {
if (arg) {
return ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'skin', value: arg })
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`skin → ${r.value}`)))
}
ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'skin' })
.then(ctx.guarded<ConfigGetValueResponse>(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`)))
}
},
{
help: 'toggle yolo mode',
name: 'yolo',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: ctx.sid })
.then(ctx.guarded<ConfigSetResponse>(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)))
}
},
{
help: 'inspect or set reasoning mode',
name: 'reasoning',
run: (arg, ctx) => {
if (!arg) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'reasoning' })
.then(
ctx.guarded<ConfigGetValueResponse>(
r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
)
)
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg })
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`)))
}
},
{
help: 'cycle verbose output',
name: 'verbose',
run: (arg, ctx) => {
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' })
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`)))
}
},
{
help: 'personality panel or switch',
name: 'personality',
run: (arg, ctx) => {
if (arg) {
return ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg })
.then(
ctx.guarded<ConfigSetResponse>(r => {
r.history_reset && ctx.session.resetVisibleHistory(r.info ?? null)
ctx.transcript.sys(
`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`
)
ctx.local.maybeWarn(r)
})
)
}
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: 'personality', session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
ctx.transcript.panel('Personality', [
{
text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)'
}
])
})
.catch(ctx.guardedErr)
}
},
{
help: 'compress transcript',
name: 'compress',
run: (arg, ctx) => {
ctx.gateway
.rpc<SessionCompressResponse>('session.compress', {
session_id: ctx.sid,
...(arg ? { focus_topic: arg } : {})
})
.then(
ctx.guarded<SessionCompressResponse>(r => {
if (Array.isArray(r.messages)) {
const rows = toTranscriptMessages(r.messages)
ctx.transcript.setHistoryItems(r.info ? [introMsg(r.info), ...rows] : rows)
}
r.info && patchUiState({ info: r.info })
r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } }))
if ((r.removed ?? 0) <= 0) {
return ctx.transcript.sys('nothing to compress')
}
ctx.transcript.sys(
`compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}`
)
})
)
}
},
{
help: 'stop background processes',
name: 'stop',
run: (_arg, ctx) => {
ctx.gateway
.rpc<{ killed?: number }>('process.stop', {})
.then(
ctx.guarded<{ killed?: number }>(r => ctx.transcript.sys(`killed ${r.killed ?? 0} registered process(es)`))
)
}
},
{
aliases: ['fork'],
help: 'branch the session',
name: 'branch',
run: (arg, ctx) => {
const prevSid = ctx.sid
ctx.gateway.rpc<SessionBranchResponse>('session.branch', { name: arg, session_id: ctx.sid }).then(
ctx.guarded<SessionBranchResponse>(r => {
if (!r.session_id) {
return
}
void ctx.session.closeSession(prevSid)
patchUiState({ sid: r.session_id })
ctx.session.setSessionStartedAt(Date.now())
ctx.transcript.setHistoryItems([])
ctx.transcript.sys(`branched → ${r.title ?? ''}`)
})
)
}
},
{
aliases: ['reload_mcp'],
help: 'reload MCP servers',
name: 'reload-mcp',
run: (_arg, ctx) =>
ctx.gateway
.rpc<ReloadMcpResponse>('reload.mcp', { session_id: ctx.sid })
.then(ctx.guarded(() => ctx.transcript.sys('MCP reloaded')))
},
{
help: 'inspect or set session title',
name: 'title',
run: (arg, ctx) => {
ctx.gateway
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid, ...(arg ? { title: arg } : {}) })
.then(ctx.guarded<SessionTitleResponse>(r => ctx.transcript.sys(`title: ${r.title || '(none)'}`)))
}
},
{
help: 'session usage',
name: 'usage',
run: (_arg, ctx) => {
ctx.gateway.rpc<SessionUsageResponse>('session.usage', { session_id: ctx.sid }).then(r => {
if (ctx.stale()) {
return
}
if (r) {
patchUiState({
usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
})
}
if (!r?.calls) {
return ctx.transcript.sys('no API calls yet')
}
const f = (v: number | undefined) => (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}` })
ctx.transcript.panel('Usage', sections)
})
}
},
{
help: 'save transcript to disk',
name: 'save',
run: (_arg, ctx) => {
ctx.gateway
.rpc<SessionSaveResponse>('session.save', { session_id: ctx.sid })
.then(ctx.guarded<SessionSaveResponse>(r => r.file && ctx.transcript.sys(`saved: ${r.file}`)))
}
},
{
help: 'view message history',
name: 'history',
run: (_arg, ctx) => {
ctx.gateway.rpc<SessionHistoryResponse>('session.history', { session_id: ctx.sid }).then(r => {
if (ctx.stale() || typeof r?.count !== 'number') {
return
}
if (!r.messages?.length) {
return ctx.transcript.sys(`${r.count} messages`)
}
const body = r.messages
.map((m, i) =>
m.role === 'tool'
? `[Tool #${i + 1}] ${m.name || 'tool'} ${m.context || ''}`.trim()
: `[${historyLabel(m.role)} #${i + 1}] ${m.text || ''}`.trim()
)
.join('\n\n')
ctx.transcript.page(body, `History (${r.count})`)
})
}
},
{
help: 'show current profile',
name: 'profile',
run: (_arg, ctx) => {
ctx.gateway.rpc<ConfigGetValueResponse>('config.get', { key: 'profile' }).then(
ctx.guarded<ConfigGetValueResponse>(r => {
const text = r.display || r.home || '(unknown profile)'
const lines = text.split('\n').filter(Boolean)
lines.length <= 2 ? ctx.transcript.panel('Profile', [{ text }]) : ctx.transcript.page(text, 'Profile')
})
)
}
},
{
help: 'toggle voice input',
name: 'voice',
run: (arg, ctx) => {
const action = arg === 'on' || arg === 'off' ? arg : 'status'
ctx.gateway.rpc<VoiceToggleResponse>('voice.toggle', { action }).then(
ctx.guarded<VoiceToggleResponse>(r => {
ctx.voice.setVoiceEnabled(!!r.enabled)
ctx.transcript.sys(`voice: ${r.enabled ? 'on' : 'off'}`)
})
)
}
},
{
help: 'view usage insights',
name: 'insights',
run: (arg, ctx) => {
ctx.gateway.rpc<InsightsResponse>('insights.get', { days: parseInt(arg) || 30 }).then(
ctx.guarded<InsightsResponse>(r =>
ctx.transcript.panel('Insights', [
{
rows: [
['Period', `${r.days ?? 0} days`],
['Sessions', `${r.sessions ?? 0}`],
['Messages', `${r.messages ?? 0}`]
]
}
])
)
)
}
}
]

View file

@ -1,341 +0,0 @@
import { HOTKEYS } from '../../constants.js'
import { writeOsc52Clipboard } from '../../lib/osc52.js'
import type { DetailsMode, PanelSection } from '../../types.js'
import { nextDetailsMode, parseDetailsMode } from '../helpers.js'
import type { SlashHandlerContext } from '../interfaces.js'
import { patchOverlayState } from '../overlayStore.js'
import { patchUiState } from '../uiStore.js'
import { isStaleSlash } from './isStaleSlash.js'
const FORTUNES = [
'you are one clean refactor away from clarity',
'a tiny rename today prevents a huge bug tomorrow',
'your next commit message will be immaculate',
'the edge case you are ignoring is already solved in your head',
'minimal diff, maximal calm',
'today favors bold deletions over new abstractions',
'the right helper is already in your codebase',
'you will ship before overthinking catches up',
'tests are about to save your future self',
'your instincts are correctly suspicious of that one branch'
]
const LEGENDARY_FORTUNES = [
'legendary drop: one-line fix, first try',
'legendary drop: every flaky test passes cleanly',
'legendary drop: your diff teaches by itself'
]
const hash = (input: string) => {
let out = 2166136261
for (let i = 0; i < input.length; i++) {
out ^= input.charCodeAt(i)
out = Math.imul(out, 16777619)
}
return out >>> 0
}
const fortuneFromScore = (score: number) => {
const rare = score % 20 === 0
const bag = rare ? LEGENDARY_FORTUNES : FORTUNES
return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}`
}
const randomFortune = () => fortuneFromScore(Math.floor(Math.random() * 0x7fffffff))
const dailyFortune = (sid: null | string) => fortuneFromScore(hash(`${sid || 'anon'}|${new Date().toDateString()}`))
export function createSlashCoreHandler(ctx: SlashHandlerContext) {
const { enqueue, hasSelection, paste, queueRef, selection } = ctx.composer
const { catalog, getHistoryItems, getLastUserMsg } = ctx.local
const { guardBusySessionSwitch, newSession, resumeById } = ctx.session
const { panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript
return ({ arg, flight, name, sid, ui }: SlashCommand) => {
switch (name) {
case 'help': {
const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({
title: catName,
rows: pairs
}))
if (catalog?.skillCount) {
sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` })
}
sections.push({
title: 'TUI',
rows: [
['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'],
['/fortune [random|daily]', 'show a random or daily local fortune']
]
})
sections.push({ title: 'Hotkeys', rows: HOTKEYS })
panel('Commands', sections)
return true
}
case 'quit':
case 'exit':
case 'q':
ctx.session.die()
return true
case 'clear':
case 'new':
if (guardBusySessionSwitch('switch sessions')) {
return true
}
patchUiState({ status: 'forging session…' })
newSession(name === 'new' ? 'new session started' : undefined)
return true
case 'resume':
if (guardBusySessionSwitch('switch sessions')) {
return true
}
arg ? resumeById(arg) : patchOverlayState({ picker: true })
return true
case 'compact': {
const mode = arg.trim().toLowerCase()
if (arg && !['on', 'off', 'toggle'].includes(mode)) {
sys('usage: /compact [on|off|toggle]')
return true
}
const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact
patchUiState({ compact: next })
ctx.gateway.rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`))
return true
}
case 'details':
case 'detail':
if (!arg) {
ctx.gateway
.rpc('config.get', { key: 'details_mode' })
.then((r: any) => {
if (isStaleSlash(ctx, flight, sid)) {
return
}
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
patchUiState({ detailsMode: mode })
sys(`details: ${mode}`)
})
.catch(() => {
if (isStaleSlash(ctx, flight, sid)) {
return
}
sys(`details: ${ui.detailsMode}`)
})
return true
}
{
const mode = arg.trim().toLowerCase()
if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) {
sys('usage: /details [hidden|collapsed|expanded|cycle]')
return true
}
const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode)
patchUiState({ detailsMode: next })
ctx.gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {})
sys(`details: ${next}`)
}
return true
case 'fortune':
if (!arg || arg.trim().toLowerCase() === 'random') {
sys(randomFortune())
return true
}
if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) {
sys(dailyFortune(sid))
return true
}
sys('usage: /fortune [random|daily]')
return true
case 'copy': {
if (!arg && hasSelection) {
const copied = selection.copySelection()
if (copied) {
sys('copied selection')
return true
}
}
if (arg && Number.isNaN(parseInt(arg, 10))) {
sys('usage: /copy [number]')
return true
}
const all = getHistoryItems().filter((m: any) => m.role === 'assistant')
const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
if (!target) {
sys('nothing to copy')
return true
}
writeOsc52Clipboard(target.text)
sys('sent OSC52 copy sequence (terminal support required)')
return true
}
case 'paste':
if (!arg) {
paste()
return true
}
sys('usage: /paste')
return true
case 'logs': {
const logText = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
logText ? ctx.transcript.page(logText, 'Logs') : sys('no gateway logs')
return true
}
case 'statusbar':
case 'sb': {
const mode = arg.trim().toLowerCase()
if (arg && !['on', 'off', 'toggle'].includes(mode)) {
sys('usage: /statusbar [on|off|toggle]')
return true
}
const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar
patchUiState({ statusBar: next })
ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`))
return true
}
case 'queue':
if (!arg) {
sys(`${queueRef.current.length} queued message(s)`)
return true
}
enqueue(arg)
sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`)
return true
case 'undo':
if (!sid) {
sys('nothing to undo')
return true
}
ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => {
if (isStaleSlash(ctx, flight, sid) || !r) {
return
}
if (r.removed > 0) {
setHistoryItems((prev: any[]) => trimLastExchange(prev))
sys(`undid ${r.removed} messages`)
} else {
sys('nothing to undo')
}
})
return true
case 'retry': {
const lastUserMsg = getLastUserMsg()
if (!lastUserMsg) {
sys('nothing to retry')
return true
}
if (!sid) {
send(lastUserMsg)
return true
}
ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => {
if (isStaleSlash(ctx, flight, sid) || !r) {
return
}
if (r.removed <= 0) {
sys('nothing to retry')
return
}
setHistoryItems((prev: any[]) => trimLastExchange(prev))
send(lastUserMsg)
})
return true
}
}
return false
}
}
interface SlashCommand {
arg: string
flight: number
name: string
sid: null | string
ui: {
compact: boolean
detailsMode: DetailsMode
statusBar: boolean
}
}

View file

@ -1,456 +0,0 @@
import type { ToolsConfigureResponse, ToolsListResponse, ToolsShowResponse } from '../../gatewayTypes.js'
import { rpcErrorMessage } from '../../lib/rpc.js'
import type { PanelSection } from '../../types.js'
import type { SlashHandlerContext } from '../interfaces.js'
import { isStaleSlash } from './isStaleSlash.js'
import type { ParsedSlashCommand } from './shared.js'
export function createSlashOpsHandler(ctx: SlashHandlerContext) {
const { rpc } = ctx.gateway
const { resetVisibleHistory, setSessionStartedAt } = ctx.session
const { panel, sys } = ctx.transcript
return ({ arg, cmd, flight, name, sid }: OpsSlashCommand) => {
const stale = () => isStaleSlash(ctx, flight, sid)
switch (name) {
case 'rollback': {
const [sub, ...rest] = (arg || 'list').split(/\s+/)
if (!sub || sub === 'list') {
rpc('rollback.list', { session_id: sid }).then((r: any) => {
if (stale() || !r) {
return
}
if (!r.checkpoints?.length) {
sys('no checkpoints')
return
}
panel('Checkpoints', [
{
rows: r.checkpoints.map(
(c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string]
)
}
])
})
return true
}
const hash = sub === 'restore' || sub === 'diff' ? rest[0] : sub
const filePath = (sub === 'restore' || sub === 'diff' ? rest.slice(1) : rest).join(' ').trim()
rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', {
session_id: sid,
hash,
...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
}).then((r: any) => {
if (stale() || !r) {
return
}
sys(r.rendered || r.diff || r.message || 'done')
})
return true
}
case 'browser': {
const [action, ...rest] = (arg || 'status').split(/\s+/)
rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then((r: any) => {
if (stale() || !r) {
return
}
sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
})
return true
}
case 'plugins':
rpc('plugins.list', {}).then((r: any) => {
if (stale() || !r) {
return
}
if (!r.plugins?.length) {
sys('no plugins')
return
}
panel('Plugins', [
{
items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`)
}
])
})
return true
case 'skills': {
const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean)
if (!sub || sub === 'list') {
rpc('skills.manage', { action: 'list' }).then((r: any) => {
if (stale() || !r) {
return
}
const skills = r.skills as Record<string, string[]> | undefined
if (!skills || !Object.keys(skills).length) {
sys('no skills installed')
return
}
panel(
'Installed Skills',
Object.entries(skills).map(([title, items]) => ({ items, title }))
)
})
return true
}
if (sub === 'browse') {
const pageNumber = parseInt(rest[0] ?? '1', 10) || 1
rpc('skills.manage', { action: 'browse', page: pageNumber }).then((r: any) => {
if (stale() || !r) {
return
}
if (!r.items?.length) {
sys('no skills found in the hub')
return
}
const sections: PanelSection[] = [
{
rows: r.items.map(
(s: any) =>
[s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [
string,
string
]
)
}
]
if (r.page < r.total_pages) {
sections.push({ text: `/skills browse ${r.page + 1} → next page` })
}
if (r.page > 1) {
sections.push({ text: `/skills browse ${r.page - 1} → prev page` })
}
panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections)
})
return true
}
ctx.gateway.gw
.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => {
if (stale()) {
return
}
sys(
r?.warning
? `warning: ${r.warning}\n${r?.output || '/skills: no output'}`
: r?.output || '/skills: no output'
)
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
}
case 'agents':
case 'tasks':
rpc('agents.list', {})
.then((r: any) => {
if (stale() || !r) {
return
}
const processes = r.processes ?? []
const running = processes.filter((p: any) => p.status === 'running')
const finished = processes.filter((p: any) => p.status !== 'running')
const sections: PanelSection[] = []
running.length &&
sections.push({
title: `Running (${running.length})`,
rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command])
})
finished.length &&
sections.push({
title: `Finished (${finished.length})`,
rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command])
})
!sections.length && sections.push({ text: 'No active processes' })
panel('Agents', sections)
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
case 'cron':
if (!arg || arg === 'list') {
rpc('cron.manage', { action: 'list' })
.then((r: any) => {
if (stale() || !r) {
return
}
const jobs = r.jobs ?? []
if (!jobs.length) {
sys('no scheduled jobs')
return
}
panel('Cron', [
{
rows: jobs.map(
(j: any) =>
[j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string]
)
}
])
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
} else {
ctx.gateway.gw
.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => {
if (stale()) {
return
}
sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)')
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
}
return true
case 'config':
rpc('config.show', {})
.then((r: any) => {
if (stale() || !r) {
return
}
panel(
'Config',
(r.sections ?? []).map((s: any) => ({
title: s.title,
rows: s.rows
}))
)
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
case 'tools': {
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
if (!subcommand) {
rpc<ToolsShowResponse>('tools.show', { session_id: sid })
.then(r => {
if (stale()) {
return
}
if (!r?.sections?.length) {
sys('no tools')
return
}
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) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
}
if (subcommand === 'list') {
rpc<ToolsListResponse>('tools.list', { session_id: sid })
.then(r => {
if (stale()) {
return
}
if (!r?.toolsets?.length) {
sys('no tools')
return
}
panel(
'Tools',
r.toolsets.map(ts => ({
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
items: ts.tools
}))
)
})
.catch((e: unknown) => {
if (stale()) {
return
}
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
})
.then(r => {
if (stale() || !r) {
return
}
if (r.info) {
setSessionStartedAt(Date.now())
resetVisibleHistory(r.info)
}
r.changed?.length && sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
r.unknown?.length && sys(`unknown toolsets: ${r.unknown.join(', ')}`)
r.missing_servers?.length && sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
r.reset && sys('session reset. new tool configuration is active.')
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
}
sys('usage: /tools [list|disable|enable] ...')
return true
}
case 'toolsets':
rpc('toolsets.list', { session_id: sid })
.then((r: any) => {
if (stale() || !r) {
return
}
if (!r.toolsets?.length) {
sys('no toolsets')
return
}
panel('Toolsets', [
{
rows: r.toolsets.map(
(ts: any) =>
[`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [
string,
string
]
)
}
])
})
.catch((e: unknown) => {
if (stale()) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
return true
}
return false
}
}
interface OpsSlashCommand extends ParsedSlashCommand {
flight: number
sid: null | string
}

View file

@ -1,464 +0,0 @@
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
}

View file

@ -1,10 +0,0 @@
import type { SlashHandlerContext } from '../interfaces.js'
import { getUiState } from '../uiStore.js'
export function isStaleSlash(
ctx: Pick<SlashHandlerContext, 'slashFlightRef'>,
flight: number,
sid: null | string
): boolean {
return flight !== ctx.slashFlightRef.current || getUiState().sid !== sid
}

View file

@ -0,0 +1,18 @@
import { coreCommands } from './commands/core.js'
import { opsCommands } from './commands/ops.js'
import { sessionCommands } from './commands/session.js'
import type { SlashCommand } from './types.js'
export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands]
const byName = new Map<string, SlashCommand>()
for (const cmd of SLASH_COMMANDS) {
byName.set(cmd.name, cmd)
for (const alias of cmd.aliases ?? []) {
byName.set(alias, cmd)
}
}
export const findSlashCommand = (name: string): SlashCommand | undefined => byName.get(name.toLowerCase())

View file

@ -4,59 +4,35 @@ import type { SlashExecResponse } from '../../gatewayTypes.js'
import { rpcErrorMessage } from '../../lib/rpc.js'
import { getUiState } from '../uiStore.js'
export const parseSlashCommand = (cmd: string): ParsedSlashCommand => {
const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/)
return {
arg: rest.join(' '),
cmd,
name: rawName.toLowerCase()
}
}
export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({
showSlashOutput: ({ command, flight, sid, title }) => {
gw.request<SlashExecResponse>('slash.exec', { command, session_id: sid })
.then(r => {
if (flight !== slashFlightRef.current || getUiState().sid !== sid) {
return
}
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) => {
if (flight !== slashFlightRef.current || getUiState().sid !== sid) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
}
})
export interface ParsedSlashCommand {
arg: string
cmd: string
name: string
}
export interface SlashShared {
showSlashOutput: (opts: { command: string; flight: number; sid: null | string; title: string }) => void
}
interface SlashSharedDeps {
gw: {
request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
page: (text: string, title?: string) => void
slashFlightRef: MutableRefObject<number>
sys: (text: string) => void
}
export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({
showSlashOutput: ({ command, flight, sid, title }) => {
const stale = () => flight !== slashFlightRef.current || getUiState().sid !== sid
gw.request<SlashExecResponse>('slash.exec', { command, session_id: sid })
.then(r => {
if (stale()) {
return
}
const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)'
text.split('\n').filter(Boolean).length > 2 || text.length > 180 ? page(text, title) : sys(text)
})
.catch((e: unknown) => {
if (!stale()) {
sys(`error: ${rpcErrorMessage(e)}`)
}
})
}
})

View file

@ -0,0 +1,24 @@
import type { MutableRefObject } from 'react'
import type { SlashHandlerContext, UiState } from '../interfaces.js'
import type { SlashShared } from './shared.js'
export interface SlashRunCtx extends SlashHandlerContext {
flight: number
guarded: <T>(fn: (r: T) => void) => (r: null | T) => void
guardedErr: (e: unknown) => void
shared: SlashShared
sid: null | string
slashFlightRef: MutableRefObject<number>
stale: () => boolean
ui: UiState
}
export interface SlashCommand {
aliases?: string[]
help?: string
name: string
run: (arg: string, ctx: SlashRunCtx, cmd: string) => void
usage?: string
}