mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
refactor(tui): store-driven turn state + slash registry + module split
Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.
Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.
Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).
Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.
Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.
Tests: 50 passing. Build + type-check clean.
This commit is contained in:
parent
9c71f3a6ea
commit
68ecdb6e26
56 changed files with 3666 additions and 4117 deletions
293
ui-tui/src/app/slash/commands/core.ts
Normal file
293
ui-tui/src/app/slash/commands/core.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
368
ui-tui/src/app/slash/commands/ops.ts
Normal file
368
ui-tui/src/app/slash/commands/ops.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
462
ui-tui/src/app/slash/commands/session.ts
Normal file
462
ui-tui/src/app/slash/commands/session.ts
Normal 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}`]
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
18
ui-tui/src/app/slash/registry.ts
Normal file
18
ui-tui/src/app/slash/registry.ts
Normal 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())
|
||||
|
|
@ -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)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
24
ui-tui/src/app/slash/types.ts
Normal file
24
ui-tui/src/app/slash/types.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue