mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
chore: uptick
This commit is contained in:
parent
097702c8a7
commit
cb31732c4f
10 changed files with 1344 additions and 1237 deletions
123
ui-tui/src/__tests__/createSlashHandler.test.ts
Normal file
123
ui-tui/src/__tests__/createSlashHandler.test.ts
Normal 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>
|
||||||
|
}
|
||||||
|
|
@ -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
328
ui-tui/src/app/slash/createSlashCoreHandler.ts
Normal file
328
ui-tui/src/app/slash/createSlashCoreHandler.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
372
ui-tui/src/app/slash/createSlashOpsHandler.ts
Normal file
372
ui-tui/src/app/slash/createSlashOpsHandler.ts
Normal 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
|
||||||
|
}
|
||||||
382
ui-tui/src/app/slash/createSlashSessionHandler.ts
Normal file
382
ui-tui/src/app/slash/createSlashSessionHandler.ts
Normal 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
|
||||||
|
}
|
||||||
48
ui-tui/src/app/slash/shared.ts
Normal file
48
ui-tui/src/app/slash/shared.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
7
ui-tui/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
exclude: ['dist/**', 'node_modules/**']
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue