chore: uptick

This commit is contained in:
Brooklyn Nicholson 2026-04-15 23:29:00 -05:00
parent 097702c8a7
commit cb31732c4f
10 changed files with 1344 additions and 1237 deletions

View file

@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSlashHandler } from '../app/createSlashHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { getUiState, resetUiState } from '../app/uiStore.js'
describe('createSlashHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
})
it('opens the resume picker locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/resume')).toBe(true)
expect(getOverlayState().picker).toBe(true)
})
it('cycles details mode and persists it', async () => {
const ctx = buildCtx()
expect(getUiState().detailsMode).toBe('collapsed')
expect(createSlashHandler(ctx)('/details toggle')).toBe(true)
expect(getUiState().detailsMode).toBe('expanded')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'details_mode',
value: 'expanded'
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded')
})
it('shows tool enable usage when names are missing', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/tools enable')).toBe(true)
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(1, 'usage: /tools enable <name> [name ...]')
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(2, 'built-in toolset: /tools enable web')
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue')
})
it('resolves unique local aliases through the catalog', () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/h': '/help',
'/help': '/help'
}
}
}
})
expect(createSlashHandler(ctx)('/h')).toBe(true)
expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array))
})
})
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
...overrides,
composer: { ...buildComposer(), ...overrides.composer },
gateway: { ...buildGateway(), ...overrides.gateway },
local: { ...buildLocal(), ...overrides.local },
session: { ...buildSession(), ...overrides.session },
transcript: { ...buildTranscript(), ...overrides.transcript },
voice: { ...buildVoice(), ...overrides.voice }
})
const buildComposer = () => ({
enqueue: vi.fn(),
hasSelection: false,
paste: vi.fn(),
queueRef: { current: [] as string[] },
selection: { copySelection: vi.fn(() => '') },
setInput: vi.fn()
})
const buildGateway = () => ({
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn(() => Promise.resolve({}))
},
rpc: vi.fn(() => Promise.resolve({}))
})
const buildLocal = () => ({
catalog: null,
getHistoryItems: vi.fn(() => []),
getLastUserMsg: vi.fn(() => ''),
maybeWarn: vi.fn()
})
const buildSession = () => ({
closeSession: vi.fn(() => Promise.resolve(null)),
die: vi.fn(),
guardBusySessionSwitch: vi.fn(() => false),
newSession: vi.fn(),
resetVisibleHistory: vi.fn(),
resumeById: vi.fn(),
setSessionStartedAt: vi.fn()
})
const buildTranscript = () => ({
page: vi.fn(),
panel: vi.fn(),
send: vi.fn(),
setHistoryItems: vi.fn(),
sys: vi.fn(),
trimLastExchange: vi.fn(items => items)
})
const buildVoice = () => ({
setVoiceEnabled: vi.fn()
})
interface Ctx {
composer: ReturnType<typeof buildComposer>
gateway: ReturnType<typeof buildGateway>
local: ReturnType<typeof buildLocal>
session: ReturnType<typeof buildSession>
transcript: ReturnType<typeof buildTranscript>
voice: ReturnType<typeof buildVoice>
}

View file

@ -235,16 +235,13 @@ export function App({ gw }: { gw: GatewayClient }) {
[sys] [sys]
) )
const maybeGoodVibes = useCallback( const maybeGoodVibes = useCallback((text: string) => {
(text: string) => {
if (!GOOD_VIBES_RE.test(text)) { if (!GOOD_VIBES_RE.test(text)) {
return return
} }
setGoodVibesTick(v => v + 1) setGoodVibesTick(v => v + 1)
}, }, [])
[]
)
const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => {
const display = cfg?.config?.display ?? {} const display = cfg?.config?.display ?? {}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,328 @@
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'
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, 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) => {
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
patchUiState({ detailsMode: mode })
sys(`details: ${mode}`)
})
.catch(() => 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 (!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 (!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
name: string
sid: null | string
ui: {
compact: boolean
detailsMode: DetailsMode
statusBar: boolean
}
}

View file

@ -0,0 +1,372 @@
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 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, name, sid }: OpsSlashCommand) => {
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 (!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) => r && 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) => r && sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
)
return true
}
case 'plugins':
rpc('plugins.list', {}).then((r: any) => {
if (!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 (!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 (!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) =>
sys(
r?.warning
? `warning: ${r.warning}\n${r?.output || '/skills: no output'}`
: r?.output || '/skills: no output'
)
)
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
}
case 'agents':
case 'tasks':
rpc('agents.list', {})
.then((r: any) => {
if (!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) => sys(`error: ${rpcErrorMessage(e)}`))
return true
case 'cron':
if (!arg || arg === 'list') {
rpc('cron.manage', { action: 'list' })
.then((r: any) => {
if (!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) => sys(`error: ${rpcErrorMessage(e)}`))
} else {
ctx.gateway.gw
.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) =>
sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)')
)
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
}
return true
case 'config':
rpc('config.show', {})
.then((r: any) => {
if (!r) {
return
}
panel(
'Config',
(r.sections ?? []).map((s: any) => ({
title: s.title,
rows: s.rows
}))
)
})
.catch((e: unknown) => 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 (!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) => sys(`error: ${rpcErrorMessage(e)}`))
return true
}
if (subcommand === 'list') {
rpc<ToolsListResponse>('tools.list', { session_id: sid })
.then(r => {
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) => 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 (!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) => 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 (!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) => sys(`error: ${rpcErrorMessage(e)}`))
return true
}
return false
}
}
interface OpsSlashCommand extends ParsedSlashCommand {
sid: null | string
}

View file

@ -0,0 +1,382 @@
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 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, name, sid }: SessionSlashCommand) => {
const pageTitle = SLASH_OUTPUT_PAGE[name]
if (pageTitle) {
shared.showSlashOutput(pageTitle, cmd.slice(1), sid)
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 => {
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 (!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 (!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 (!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) =>
page(
r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)',
'Provider'
)
)
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
case 'skin':
if (arg) {
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => r?.value && sys(`skin → ${r.value}`))
} else {
rpc('config.get', { key: 'skin' }).then((r: any) => r && sys(`skin: ${r.value || 'default'}`))
}
return true
case 'yolo':
rpc('config.set', { session_id: sid, key: 'yolo' }).then(
(r: any) => r && sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
)
return true
case 'reasoning':
if (!arg) {
rpc('config.get', { key: 'reasoning' }).then(
(r: any) => r?.value && sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
)
} else {
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then(
(r: any) => r?.value && sys(`reasoning: ${r.value}`)
)
}
return true
case 'verbose':
rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then(
(r: any) => r?.value && sys(`verbose: ${r.value}`)
)
return true
case 'personality':
if (arg) {
rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => {
if (!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) =>
panel('Personality', [
{
text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)'
}
])
)
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
case 'compress':
rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => {
if (!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) => r && 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 (!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) => r && sys('MCP reloaded'))
return true
case 'title':
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then(
(r: any) => r && sys(`title: ${r.title || '(none)'}`)
)
return true
case 'usage':
rpc('session.usage', { session_id: sid }).then((r: any) => {
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) => r?.file && sys(`saved: ${r.file}`))
return true
case 'history':
rpc<SessionHistoryResponse>('session.history', { session_id: sid }).then(r => {
if (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 (!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 (!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 (!r) {
return
}
panel('Insights', [
{
rows: [
['Period', `${r.days} days`],
['Sessions', `${r.sessions}`],
['Messages', `${r.messages}`]
]
}
])
})
return true
}
return false
}
}
interface SessionSlashCommand extends ParsedSlashCommand {
sid: null | string
}

View file

@ -0,0 +1,48 @@
import type { SlashExecResponse } from '../../gatewayTypes.js'
import { rpcErrorMessage } from '../../lib/rpc.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, sys }: SlashSharedDeps): SlashShared => ({
showSlashOutput: (title, command, sid) => {
gw.request<SlashExecResponse>('slash.exec', { command, session_id: sid })
.then(r => {
const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)'
const lines = text.split('\n').filter(Boolean)
if (lines.length > 2 || text.length > 180) {
page(text, title)
} else {
sys(text)
}
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
}
})
export interface ParsedSlashCommand {
arg: string
cmd: string
name: string
}
export interface SlashShared {
showSlashOutput: (title: string, command: string, sid: null | string) => void
}
interface SlashSharedDeps {
gw: {
request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
page: (text: string, title?: string) => void
sys: (text: string) => void
}

View file

@ -125,13 +125,7 @@ function TreeNode({
) )
} }
export function Spinner({ export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
color,
variant = 'think'
}: {
color: string
variant?: 'think' | 'tool'
}) {
const spin = useMemo(() => { const spin = useMemo(() => {
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]

View file

@ -73,6 +73,7 @@ export function useVirtualHistory(
}, [items]) }, [items])
const offsets = useMemo(() => { const offsets = useMemo(() => {
void ver
const out = new Array<number>(items.length + 1).fill(0) const out = new Array<number>(items.length + 1).fill(0)
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {

7
ui-tui/vitest.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
exclude: ['dist/**', 'node_modules/**']
}
})