diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts new file mode 100644 index 000000000..b7f895539 --- /dev/null +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -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 ...]') + 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 => ({ + ...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 + gateway: ReturnType + local: ReturnType + session: ReturnType + transcript: ReturnType + voice: ReturnType +} diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 947af602a..ee1a70978 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -235,16 +235,13 @@ export function App({ gw }: { gw: GatewayClient }) { [sys] ) - const maybeGoodVibes = useCallback( - (text: string) => { - if (!GOOD_VIBES_RE.test(text)) { - return - } + const maybeGoodVibes = useCallback((text: string) => { + if (!GOOD_VIBES_RE.test(text)) { + return + } - setGoodVibesTick(v => v + 1) - }, - [] - ) + setGoodVibesTick(v => v + 1) + }, []) const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const display = cfg?.config?.display ?? {} diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index eb5a03583..b20804380 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,1234 +1,89 @@ -import { HOTKEYS } from '../constants.js' -import type { - BackgroundStartResponse, - SessionHistoryResponse, - SlashExecResponse, - ToolsConfigureResponse, - ToolsListResponse, - ToolsShowResponse -} from '../gatewayTypes.js' -import { writeOsc52Clipboard } from '../lib/osc52.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import { fmtK } from '../lib/text.js' -import type { DetailsMode, PanelSection } from '../types.js' -import { imageTokenMeta, introMsg, nextDetailsMode, parseDetailsMode, toTranscriptMessages } from './helpers.js' import type { SlashHandlerContext } from './interfaces.js' -import { patchOverlayState } from './overlayStore.js' -import { getUiState, 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 = () => { - const score = Math.floor(Math.random() * 0x7fffffff) - - return fortuneFromScore(score) -} - -const dailyFortune = (sid: string | null) => { - const seed = `${sid || 'anon'}|${new Date().toDateString()}` - const score = hash(seed) - - return fortuneFromScore(score) -} +import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js' +import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js' +import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js' +import { createSlashShared, parseSlashCommand } from './slash/shared.js' +import { getUiState } from './uiStore.js' export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { - const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer - const { gw, rpc } = ctx.gateway - const { catalog, getHistoryItems, getLastUserMsg, maybeWarn } = ctx.local - - const { - closeSession, - die, - guardBusySessionSwitch, - newSession, - resetVisibleHistory, - resumeById, - setSessionStartedAt - } = ctx.session - - const { page, panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript - const { setVoiceEnabled } = ctx.voice - - const showSlashOutput = (title: string, command: string) => { - gw.request('slash.exec', { command, session_id: getUiState().sid }) - .then(r => { - const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' - const lines = text.split('\n').filter(Boolean) - - if (lines.length > 2 || text.length > 180) { - page(text, title) - } else { - sys(text) - } - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } + const { gw } = ctx.gateway + const { catalog } = ctx.local + const { send, sys } = ctx.transcript + const shared = createSlashShared({ ...ctx.transcript, gw }) + const handleCore = createSlashCoreHandler(ctx) + const handleSession = createSlashSessionHandler(ctx, shared) + const handleOps = createSlashOpsHandler(ctx) const handler = (cmd: string): boolean => { const ui = getUiState() - const detailsMode = ui.detailsMode - const sid = ui.sid - const [rawName, ...rest] = cmd.slice(1).split(/\s+/) - const name = rawName.toLowerCase() - const arg = rest.join(' ') + const parsed = { ...parseSlashCommand(cmd), sid: ui.sid, ui } + const argTail = parsed.arg ? ` ${parsed.arg}` : '' - 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': - die() - - return true - - case 'clear': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - patchUiState({ status: 'forging session…' }) - newSession() - - return true - - case 'new': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - patchUiState({ status: 'forging session…' }) - newSession('new session started') - - return true - - case 'resume': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - if (arg) { - resumeById(arg) - } else { - patchOverlayState({ picker: true }) - } - - return true - - case 'compact': - if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { - sys('usage: /compact [on|off|toggle]') - - return true - } - - { - const mode = arg.trim().toLowerCase() - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact - - patchUiState({ compact: next }) - rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) - } - - return true - - case 'details': - - case 'detail': - if (!arg) { - rpc('config.get', { key: 'details_mode' }) - .then((r: any) => { - const mode = parseDetailsMode(r?.value) ?? detailsMode - patchUiState({ detailsMode: mode }) - sys(`details: ${mode}`) - }) - .catch(() => sys(`details: ${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(detailsMode) : (mode as DetailsMode) - patchUiState({ detailsMode: next }) - 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 - } - } - - const all = getHistoryItems().filter((m: any) => m.role === 'assistant') - - if (arg && Number.isNaN(parseInt(arg, 10))) { - sys('usage: /copy [number]') - - return true - } - - 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 = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) - logText ? page(logText, 'Logs') : sys('no gateway logs') - - return true - } - - case 'statusbar': - - case 'sb': - if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { - sys('usage: /statusbar [on|off|toggle]') - - return true - } - - { - const mode = arg.trim().toLowerCase() - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar - - patchUiState({ statusBar: next }) - 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 - } - - 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) { - 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 - } - - send(lastUserMsg) - - return true - } - - case 'background': - - case 'bg': - if (!arg) { - sys('/background ') - - return true - } - - rpc('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 ') - - 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 }) - } else { - 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}` : ''}`) - - if (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) => { - if (!r?.value) { - return - } - - sys(`skin → ${r.value}`) - }) - } else { - rpc('config.get', { key: 'skin' }).then((r: any) => { - if (!r) { - return - } - - sys(`skin: ${r.value || 'default'}`) - }) - } - - return true - - case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { - if (!r) { - return - } - - sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - }) - - return true - - case 'reasoning': - if (!arg) { - rpc('config.get', { key: 'reasoning' }).then((r: any) => { - if (!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 (!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 (!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 (!r) { - return - } - - if (r.history_reset) { - resetVisibleHistory(r.info ?? null) - } - - sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) - maybeWarn(r) - }) - } else { - 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 - } - - if (Array.isArray(r.messages)) { - const resumed = toTranscriptMessages(r.messages) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - } - - if (r.info) { - patchUiState({ info: r.info }) - } - - if (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 (!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 (r?.session_id) { - 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 (!r) { - return - } - - sys('MCP reloaded') - }) - - return true - - case 'fast': - showSlashOutput('Fast', cmd.slice(1)) - - return true - - case 'debug': - showSlashOutput('Debug', cmd.slice(1)) - - return true - - case 'snapshot': - showSlashOutput('Snapshot', cmd.slice(1)) - - return true - - case 'platforms': - showSlashOutput('Platforms', cmd.slice(1)) - - return true - - case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { - if (!r) { - return - } - - 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)] - ] - - if (cost) { - rows.push(['Cost', cost]) - } - - const sections: PanelSection[] = [{ rows }] - - if (r.context_max) { - sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) - } - - if (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 (!r?.file) { - return - } - - sys(`saved: ${r.file}`) - }) - - return true - - case 'history': - rpc('session.history', { session_id: sid }).then(r => { - if (typeof r?.count !== 'number') { - return - } - - if (!r.messages?.length) { - sys(`${r.count} messages`) - - return - } - - const text = r.messages - .map((msg, index) => { - if (msg.role === 'tool') { - return `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim() - } - - return `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim() - }) - .join('\n\n') - - page(text, `History (${r.count})`) - }) - - return true - - 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) - - if (lines.length <= 2) { - panel('Profile', [{ text }]) - } else { - 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 - case 'rollback': { - const [sub, ...rArgs] = (arg || 'list').split(/\s+/) - - if (!sub || sub === 'list') { - rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - if (!r.checkpoints?.length) { - return sys('no checkpoints') - } - - panel('Checkpoints', [ - { - rows: r.checkpoints.map( - (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] - ) - } - ]) - }) - } else { - const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub - - const filePath = - sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim() - - rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { - session_id: sid, - hash, - ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => { - if (!r) { - return - } - - sys(r.rendered || r.diff || r.message || 'done') - }) - } - - return true - } - - case 'browser': { - const [act, ...bArgs] = (arg || 'status').split(/\s+/) - rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => { - if (!r) { - return - } - - 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) { - return sys('no plugins') - } - - panel('Plugins', [ - { - items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) - } - ]) - }) - - return true - case 'skills': { - const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean) - - if (!sub || sub === 'list') { - rpc('skills.manage', { action: 'list' }).then((r: any) => { - if (!r) { - return - } - - const sk = r.skills as Record | undefined - - if (!sk || !Object.keys(sk).length) { - return sys('no skills installed') - } - - panel( - 'Installed Skills', - Object.entries(sk).map(([cat, names]) => ({ - title: cat, - items: names as string[] - })) - ) - }) - - return true - } - - if (sub === 'browse') { - const pg = parseInt(sArgs[0] ?? '1', 10) || 1 - rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { - if (!r) { - return - } - - if (!r.items?.length) { - return sys('no skills found in the hub') - } - - 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 - } - - 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 procs = r.processes ?? [] - const running = procs.filter((p: any) => p.status === 'running') - const finished = procs.filter((p: any) => p.status !== 'running') - const sections: PanelSection[] = [] - - if (running.length) { - sections.push({ - title: `Running (${running.length})`, - rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } - - if (finished.length) { - sections.push({ - title: `Finished (${finished.length})`, - rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } - - if (!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) { - return sys('no scheduled jobs') - } - - 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 { - 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('tools.show', { session_id: sid }) - .then(r => { - if (!r?.sections?.length) { - return sys('no tools') - } - - panel( - `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, - r.sections.map(section => ({ - title: section.name, - rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]) - })) - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - if (subcommand === 'list') { - rpc('tools.list', { session_id: sid }) - .then(r => { - if (!r?.toolsets?.length) { - return sys('no tools') - } - - panel( - 'Tools', - r.toolsets.map(ts => ({ - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, - items: ts.tools - })) - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - if (subcommand === 'disable' || subcommand === 'enable') { - if (!names.length) { - sys(`usage: /tools ${subcommand} [name ...]`) - sys(`built-in toolset: /tools ${subcommand} web`) - sys(`MCP tool: /tools ${subcommand} github:create_issue`) - - return true - } - - rpc('tools.configure', { - action: subcommand, - names, - session_id: sid - }) - .then(r => { - if (!r) { - return - } - - if (r.info) { - setSessionStartedAt(Date.now()) - resetVisibleHistory(r.info) - } - - if (r.changed?.length) { - sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) - } - - if (r.unknown?.length) { - sys(`unknown toolsets: ${r.unknown.join(', ')}`) - } - - if (r.missing_servers?.length) { - sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) - } - - if (r.reset) { - sys('session reset. new tool configuration is active.') - } - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - sys('usage: /tools [list|disable|enable] ...') - - return true - } - - case 'toolsets': - rpc('toolsets.list', { session_id: sid }) - .then((r: any) => { - if (!r) { - return - } - - if (!r.toolsets?.length) { - return sys('no toolsets') - } - - 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 - - default: - if (catalog?.canon) { - const needle = `/${name}`.toLowerCase() - - const matches = [ - ...new Set( - Object.entries(catalog.canon) - .filter(([alias]) => alias.startsWith(needle)) - .map(([, canon]) => canon) - ) - ] - - if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) { - return handler(`${matches[0]}${arg ? ' ' + arg : ''}`) - } - - if (matches.length > 1) { - sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`) - - return true - } - } - - gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}` - : r?.output || `/${name}: no output` - ) - }) - .catch(() => { - gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) - .then((raw: any) => { - const d = asRpcResult(raw) - - if (!d?.type) { - sys('error: invalid response: command.dispatch') - - return - } - - if (d.type === 'exec') { - sys(d.output || '(no output)') - } else if (d.type === 'alias') { - handler(`/${d.target}${arg ? ' ' + arg : ''}`) - } else if (d.type === 'plugin') { - sys(d.output || '(no output)') - } else if (d.type === 'skill') { - sys(`⚡ loading skill: ${d.name}`) - - if (typeof d.message === 'string' && d.message.trim()) { - send(d.message) - } else { - sys(`/${name}: skill payload missing message`) - } - } - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - }) - - return true + if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) { + return true } + + if (catalog?.canon) { + const needle = `/${parsed.name}`.toLowerCase() + + const matches = [ + ...new Set( + Object.entries(catalog.canon) + .filter(([alias]) => alias.startsWith(needle)) + .map(([, canon]) => canon) + ) + ] + + if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) { + return handler(`${matches[0]}${argTail}`) + } + + if (matches.length > 1) { + sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`) + + return true + } + } + + gw.request('slash.exec', { command: cmd.slice(1), session_id: ui.sid }) + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || `/${parsed.name}: no output`}` + : r?.output || `/${parsed.name}: no output` + ) + }) + .catch(() => { + gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: ui.sid }) + .then((raw: any) => { + const d = asRpcResult(raw) + + if (!d?.type) { + sys('error: invalid response: command.dispatch') + + return + } + + if (d.type === 'exec' || d.type === 'plugin') { + sys(d.output || '(no output)') + } else if (d.type === 'alias') { + handler(`/${d.target}${argTail}`) + } else if (d.type === 'skill') { + sys(`⚡ loading skill: ${d.name}`) + + if (typeof d.message === 'string' && d.message.trim()) { + send(d.message) + } else { + sys(`/${parsed.name}: skill payload missing message`) + } + } + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + + return true } return handler diff --git a/ui-tui/src/app/slash/createSlashCoreHandler.ts b/ui-tui/src/app/slash/createSlashCoreHandler.ts new file mode 100644 index 000000000..cb980248a --- /dev/null +++ b/ui-tui/src/app/slash/createSlashCoreHandler.ts @@ -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 + } +} diff --git a/ui-tui/src/app/slash/createSlashOpsHandler.ts b/ui-tui/src/app/slash/createSlashOpsHandler.ts new file mode 100644 index 000000000..4627244e3 --- /dev/null +++ b/ui-tui/src/app/slash/createSlashOpsHandler.ts @@ -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 | 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('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('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 ...]`) + sys(`built-in toolset: /tools ${subcommand} web`) + sys(`MCP tool: /tools ${subcommand} github:create_issue`) + + return true + } + + rpc('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 +} diff --git a/ui-tui/src/app/slash/createSlashSessionHandler.ts b/ui-tui/src/app/slash/createSlashSessionHandler.ts new file mode 100644 index 000000000..5fa817f5a --- /dev/null +++ b/ui-tui/src/app/slash/createSlashSessionHandler.ts @@ -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 = { + 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 ') + + return true + } + + rpc('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 ') + + 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('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 +} diff --git a/ui-tui/src/app/slash/shared.ts b/ui-tui/src/app/slash/shared.ts new file mode 100644 index 000000000..221a7e5ae --- /dev/null +++ b/ui-tui/src/app/slash/shared.ts @@ -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('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: (method: string, params?: Record) => Promise + } + page: (text: string, title?: string) => void + sys: (text: string) => void +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 0b1d4e95b..ef3a7aba0 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -125,13 +125,7 @@ function TreeNode({ ) } -export function Spinner({ - color, - variant = 'think' -}: { - color: string - variant?: 'think' | 'tool' -}) { +export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const spin = useMemo(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 4f91a386b..de6e71e2e 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -73,6 +73,7 @@ export function useVirtualHistory( }, [items]) const offsets = useMemo(() => { + void ver const out = new Array(items.length + 1).fill(0) for (let i = 0; i < items.length; i++) { diff --git a/ui-tui/vitest.config.ts b/ui-tui/vitest.config.ts new file mode 100644 index 000000000..b3efa48af --- /dev/null +++ b/ui-tui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: ['dist/**', 'node_modules/**'] + } +})