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
cb31732c4f
commit
8e06db56fd
14 changed files with 1712 additions and 1362 deletions
21
ui-tui/src/__tests__/asCommandDispatch.test.ts
Normal file
21
ui-tui/src/__tests__/asCommandDispatch.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { asCommandDispatch } from '../lib/rpc.js'
|
||||
|
||||
describe('asCommandDispatch', () => {
|
||||
it('parses exec, alias, and skill', () => {
|
||||
expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' })
|
||||
expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' })
|
||||
expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({
|
||||
type: 'skill',
|
||||
name: 'x',
|
||||
message: 'do'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed payloads', () => {
|
||||
expect(asCommandDispatch(null)).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'alias' })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -39,6 +39,73 @@ describe('createSlashHandler', () => {
|
|||
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue')
|
||||
})
|
||||
|
||||
it('drops stale slash.exec output after a newer slash', async () => {
|
||||
let resolveLate: (v: { output?: string }) => void
|
||||
let slashExecCalls = 0
|
||||
|
||||
const ctx = buildCtx({
|
||||
gateway: {
|
||||
gw: {
|
||||
getLogTail: vi.fn(() => ''),
|
||||
request: vi.fn((method: string) => {
|
||||
if (method === 'slash.exec') {
|
||||
slashExecCalls += 1
|
||||
|
||||
if (slashExecCalls === 1) {
|
||||
return new Promise<{ output?: string }>(res => {
|
||||
resolveLate = res
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({ output: 'fresh' })
|
||||
}
|
||||
|
||||
return Promise.resolve({})
|
||||
})
|
||||
},
|
||||
rpc: vi.fn(() => Promise.resolve({}))
|
||||
}
|
||||
})
|
||||
|
||||
const h = createSlashHandler(ctx)
|
||||
expect(h('/slow')).toBe(true)
|
||||
expect(h('/fast')).toBe(true)
|
||||
resolveLate!({ output: 'too late' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.sys).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('too late')
|
||||
})
|
||||
|
||||
it('dispatches command.dispatch with typed alias', async () => {
|
||||
const ctx = buildCtx({
|
||||
gateway: {
|
||||
gw: {
|
||||
getLogTail: vi.fn(() => ''),
|
||||
request: vi.fn((method: string) => {
|
||||
if (method === 'slash.exec') {
|
||||
return Promise.reject(new Error('no'))
|
||||
}
|
||||
|
||||
if (method === 'command.dispatch') {
|
||||
return Promise.resolve({ type: 'alias', target: 'help' })
|
||||
}
|
||||
|
||||
return Promise.resolve({})
|
||||
})
|
||||
},
|
||||
rpc: vi.fn(() => Promise.resolve({}))
|
||||
}
|
||||
})
|
||||
|
||||
const h = createSlashHandler(ctx)
|
||||
expect(h('/zzz')).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array))
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves unique local aliases through the catalog', () => {
|
||||
const ctx = buildCtx({
|
||||
local: {
|
||||
|
|
@ -58,6 +125,7 @@ describe('createSlashHandler', () => {
|
|||
|
||||
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
|
||||
...overrides,
|
||||
slashFlightRef: overrides.slashFlightRef ?? { current: 0 },
|
||||
composer: { ...buildComposer(), ...overrides.composer },
|
||||
gateway: { ...buildGateway(), ...overrides.gateway },
|
||||
local: { ...buildLocal(), ...overrides.local },
|
||||
|
|
@ -114,6 +182,7 @@ const buildVoice = () => ({
|
|||
})
|
||||
|
||||
interface Ctx {
|
||||
slashFlightRef: { current: number }
|
||||
composer: ReturnType<typeof buildComposer>
|
||||
gateway: ReturnType<typeof buildGateway>
|
||||
local: ReturnType<typeof buildLocal>
|
||||
|
|
|
|||
1283
ui-tui/src/app.tsx
1283
ui-tui/src/app.tsx
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,11 @@
|
|||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { SlashExecResponse } from '../gatewayTypes.js'
|
||||
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
|
||||
|
||||
import type { SlashHandlerContext } from './interfaces.js'
|
||||
import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js'
|
||||
import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js'
|
||||
import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js'
|
||||
import { isStaleSlash } from './slash/isStaleSlash.js'
|
||||
import { createSlashShared, parseSlashCommand } from './slash/shared.js'
|
||||
import { getUiState } from './uiStore.js'
|
||||
|
||||
|
|
@ -11,14 +13,16 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
const { gw } = ctx.gateway
|
||||
const { catalog } = ctx.local
|
||||
const { send, sys } = ctx.transcript
|
||||
const shared = createSlashShared({ ...ctx.transcript, gw })
|
||||
const shared = createSlashShared({ ...ctx.transcript, gw, slashFlightRef: ctx.slashFlightRef })
|
||||
const handleCore = createSlashCoreHandler(ctx)
|
||||
const handleSession = createSlashSessionHandler(ctx, shared)
|
||||
const handleOps = createSlashOpsHandler(ctx)
|
||||
|
||||
const handler = (cmd: string): boolean => {
|
||||
const flight = ++ctx.slashFlightRef.current
|
||||
const ui = getUiState()
|
||||
const parsed = { ...parseSlashCommand(cmd), sid: ui.sid, ui }
|
||||
const sidAtSend = ui.sid
|
||||
const parsed = { ...parseSlashCommand(cmd), flight, sid: sidAtSend, ui }
|
||||
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
|
||||
|
||||
if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) {
|
||||
|
|
@ -47,8 +51,12 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
}
|
||||
}
|
||||
|
||||
gw.request('slash.exec', { command: cmd.slice(1), session_id: ui.sid })
|
||||
.then((r: any) => {
|
||||
gw.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sidAtSend })
|
||||
.then(r => {
|
||||
if (isStaleSlash(ctx, flight, sidAtSend)) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(
|
||||
r?.warning
|
||||
? `warning: ${r.warning}\n${r?.output || `/${parsed.name}: no output`}`
|
||||
|
|
@ -56,11 +64,15 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
)
|
||||
})
|
||||
.catch(() => {
|
||||
gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: ui.sid })
|
||||
.then((raw: any) => {
|
||||
const d = asRpcResult(raw)
|
||||
gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: sidAtSend })
|
||||
.then((raw: unknown) => {
|
||||
if (isStaleSlash(ctx, flight, sidAtSend)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!d?.type) {
|
||||
const d = asCommandDispatch(raw)
|
||||
|
||||
if (!d) {
|
||||
sys('error: invalid response: command.dispatch')
|
||||
|
||||
return
|
||||
|
|
@ -80,7 +92,13 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
}
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (isStaleSlash(ctx, flight, sidAtSend)) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ export interface GatewayEventHandlerContext {
|
|||
}
|
||||
|
||||
export interface SlashHandlerContext {
|
||||
slashFlightRef: MutableRefObject<number>
|
||||
composer: {
|
||||
enqueue: (text: string) => void
|
||||
hasSelection: boolean
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type { SlashHandlerContext } from '../interfaces.js'
|
|||
import { patchOverlayState } from '../overlayStore.js'
|
||||
import { patchUiState } from '../uiStore.js'
|
||||
|
||||
import { isStaleSlash } from './isStaleSlash.js'
|
||||
|
||||
const FORTUNES = [
|
||||
'you are one clean refactor away from clarity',
|
||||
'a tiny rename today prevents a huge bug tomorrow',
|
||||
|
|
@ -53,7 +55,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) {
|
|||
const { guardBusySessionSwitch, newSession, resumeById } = ctx.session
|
||||
const { panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript
|
||||
|
||||
return ({ arg, name, sid, ui }: SlashCommand) => {
|
||||
return ({ arg, flight, name, sid, ui }: SlashCommand) => {
|
||||
switch (name) {
|
||||
case 'help': {
|
||||
const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({
|
||||
|
|
@ -132,12 +134,22 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) {
|
|||
ctx.gateway
|
||||
.rpc('config.get', { key: 'details_mode' })
|
||||
.then((r: any) => {
|
||||
if (isStaleSlash(ctx, flight, sid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
|
||||
|
||||
patchUiState({ detailsMode: mode })
|
||||
sys(`details: ${mode}`)
|
||||
})
|
||||
.catch(() => sys(`details: ${ui.detailsMode}`))
|
||||
.catch(() => {
|
||||
if (isStaleSlash(ctx, flight, sid)) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`details: ${ui.detailsMode}`)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -265,7 +277,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) {
|
|||
}
|
||||
|
||||
ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (isStaleSlash(ctx, flight, sid) || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -294,7 +306,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) {
|
|||
}
|
||||
|
||||
ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (isStaleSlash(ctx, flight, sid) || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -318,6 +330,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) {
|
|||
|
||||
interface SlashCommand {
|
||||
arg: string
|
||||
flight: number
|
||||
name: string
|
||||
sid: null | string
|
||||
ui: {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { rpcErrorMessage } from '../../lib/rpc.js'
|
|||
import type { PanelSection } from '../../types.js'
|
||||
import type { SlashHandlerContext } from '../interfaces.js'
|
||||
|
||||
import { isStaleSlash } from './isStaleSlash.js'
|
||||
import type { ParsedSlashCommand } from './shared.js'
|
||||
|
||||
export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
||||
|
|
@ -10,14 +11,16 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
const { resetVisibleHistory, setSessionStartedAt } = ctx.session
|
||||
const { panel, sys } = ctx.transcript
|
||||
|
||||
return ({ arg, cmd, name, sid }: OpsSlashCommand) => {
|
||||
return ({ arg, cmd, flight, name, sid }: OpsSlashCommand) => {
|
||||
const stale = () => isStaleSlash(ctx, flight, sid)
|
||||
|
||||
switch (name) {
|
||||
case 'rollback': {
|
||||
const [sub, ...rest] = (arg || 'list').split(/\s+/)
|
||||
|
||||
if (!sub || sub === 'list') {
|
||||
rpc('rollback.list', { session_id: sid }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +49,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
session_id: sid,
|
||||
hash,
|
||||
...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
|
||||
}).then((r: any) => r && sys(r.rendered || r.diff || r.message || 'done'))
|
||||
}).then((r: any) => {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(r.rendered || r.diff || r.message || 'done')
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -54,16 +63,20 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
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')
|
||||
)
|
||||
rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then((r: any) => {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case 'plugins':
|
||||
rpc('plugins.list', {}).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +99,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
|
||||
if (!sub || sub === 'list') {
|
||||
rpc('skills.manage', { action: 'list' }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +124,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
const pageNumber = parseInt(rest[0] ?? '1', 10) || 1
|
||||
|
||||
rpc('skills.manage', { action: 'browse', page: pageNumber }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -149,14 +162,24 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
|
||||
ctx.gateway.gw
|
||||
.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
||||
.then((r: any) =>
|
||||
.then((r: any) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(
|
||||
r?.warning
|
||||
? `warning: ${r.warning}\n${r?.output || '/skills: no output'}`
|
||||
: r?.output || '/skills: no output'
|
||||
)
|
||||
)
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -166,7 +189,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
case 'tasks':
|
||||
rpc('agents.list', {})
|
||||
.then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +211,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
!sections.length && sections.push({ text: 'No active processes' })
|
||||
panel('Agents', sections)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
|
|
@ -196,7 +225,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
if (!arg || arg === 'list') {
|
||||
rpc('cron.manage', { action: 'list' })
|
||||
.then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -217,14 +246,30 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
}
|
||||
])
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
} else {
|
||||
ctx.gateway.gw
|
||||
.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
||||
.then((r: any) =>
|
||||
.then((r: any) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)')
|
||||
)
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -232,7 +277,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
case 'config':
|
||||
rpc('config.show', {})
|
||||
.then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -244,7 +289,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
case 'tools': {
|
||||
|
|
@ -253,6 +304,10 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
if (!subcommand) {
|
||||
rpc<ToolsShowResponse>('tools.show', { session_id: sid })
|
||||
.then(r => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!r?.sections?.length) {
|
||||
sys('no tools')
|
||||
|
||||
|
|
@ -267,7 +322,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -275,6 +336,10 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
if (subcommand === 'list') {
|
||||
rpc<ToolsListResponse>('tools.list', { session_id: sid })
|
||||
.then(r => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!r?.toolsets?.length) {
|
||||
sys('no tools')
|
||||
|
||||
|
|
@ -289,7 +354,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -309,7 +380,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
session_id: sid
|
||||
})
|
||||
.then(r => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -323,7 +394,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
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)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -336,7 +413,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
case 'toolsets':
|
||||
rpc('toolsets.list', { session_id: sid })
|
||||
.then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +435,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
}
|
||||
])
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -368,5 +451,6 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) {
|
|||
}
|
||||
|
||||
interface OpsSlashCommand extends ParsedSlashCommand {
|
||||
flight: number
|
||||
sid: null | string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { SlashHandlerContext } from '../interfaces.js'
|
|||
import { patchOverlayState } from '../overlayStore.js'
|
||||
import { patchUiState } from '../uiStore.js'
|
||||
|
||||
import { isStaleSlash } from './isStaleSlash.js'
|
||||
import type { ParsedSlashCommand, SlashShared } from './shared.js'
|
||||
|
||||
const SLASH_OUTPUT_PAGE: Record<string, string> = {
|
||||
|
|
@ -24,11 +25,12 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
const { page, panel, setHistoryItems, sys } = ctx.transcript
|
||||
const { setVoiceEnabled } = ctx.voice
|
||||
|
||||
return ({ arg, cmd, name, sid }: SessionSlashCommand) => {
|
||||
return ({ arg, cmd, flight, name, sid }: SessionSlashCommand) => {
|
||||
const stale = () => isStaleSlash(ctx, flight, sid)
|
||||
const pageTitle = SLASH_OUTPUT_PAGE[name]
|
||||
|
||||
if (pageTitle) {
|
||||
shared.showSlashOutput(pageTitle, cmd.slice(1), sid)
|
||||
shared.showSlashOutput({ command: cmd.slice(1), flight, sid, title: pageTitle })
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -44,6 +46,10 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
}
|
||||
|
||||
rpc<BackgroundStartResponse>('prompt.background', { session_id: sid, text: arg }).then(r => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = r?.task_id
|
||||
|
||||
if (!taskId) {
|
||||
|
|
@ -64,7 +70,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
}
|
||||
|
||||
rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +92,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
}
|
||||
|
||||
rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +114,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
|
||||
case 'image':
|
||||
rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -122,56 +128,94 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
|
||||
case 'provider':
|
||||
gw.request('slash.exec', { command: 'provider', session_id: sid })
|
||||
.then((r: any) =>
|
||||
.then((r: any) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
page(
|
||||
r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)',
|
||||
'Provider'
|
||||
)
|
||||
)
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
case 'skin':
|
||||
if (arg) {
|
||||
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => r?.value && sys(`skin → ${r.value}`))
|
||||
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => {
|
||||
if (stale() || !r?.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`skin → ${r.value}`)
|
||||
})
|
||||
} else {
|
||||
rpc('config.get', { key: 'skin' }).then((r: any) => r && sys(`skin: ${r.value || 'default'}`))
|
||||
rpc('config.get', { key: 'skin' }).then((r: any) => {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`skin: ${r.value || 'default'}`)
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case 'yolo':
|
||||
rpc('config.set', { session_id: sid, key: 'yolo' }).then(
|
||||
(r: any) => r && sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
|
||||
)
|
||||
rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
case 'reasoning':
|
||||
if (!arg) {
|
||||
rpc('config.get', { key: 'reasoning' }).then(
|
||||
(r: any) => r?.value && sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
|
||||
)
|
||||
rpc('config.get', { key: 'reasoning' }).then((r: any) => {
|
||||
if (stale() || !r?.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
|
||||
})
|
||||
} else {
|
||||
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then(
|
||||
(r: any) => r?.value && sys(`reasoning: ${r.value}`)
|
||||
)
|
||||
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => {
|
||||
if (stale() || !r?.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`reasoning: ${r.value}`)
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case 'verbose':
|
||||
rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then(
|
||||
(r: any) => r?.value && sys(`verbose: ${r.value}`)
|
||||
)
|
||||
rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => {
|
||||
if (stale() || !r?.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`verbose: ${r.value}`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
case 'personality':
|
||||
if (arg) {
|
||||
rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -184,20 +228,30 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
}
|
||||
|
||||
gw.request('slash.exec', { command: 'personality', session_id: sid })
|
||||
.then((r: any) =>
|
||||
.then((r: any) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
panel('Personality', [
|
||||
{
|
||||
text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)'
|
||||
}
|
||||
])
|
||||
)
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
case 'compress':
|
||||
rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -220,7 +274,13 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
return true
|
||||
|
||||
case 'stop':
|
||||
rpc('process.stop', {}).then((r: any) => r && sys(`killed ${r.killed ?? 0} registered process(es)`))
|
||||
rpc('process.stop', {}).then((r: any) => {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`killed ${r.killed ?? 0} registered process(es)`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
|
|
@ -229,7 +289,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
const prevSid = sid
|
||||
|
||||
rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => {
|
||||
if (!r?.session_id) {
|
||||
if (stale() || !r?.session_id) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -246,19 +306,33 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
case 'reload-mcp':
|
||||
|
||||
case 'reload_mcp':
|
||||
rpc('reload.mcp', { session_id: sid }).then((r: any) => r && sys('MCP reloaded'))
|
||||
rpc('reload.mcp', { session_id: sid }).then((r: any) => {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
sys('MCP reloaded')
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
case 'title':
|
||||
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then(
|
||||
(r: any) => r && sys(`title: ${r.title || '(none)'}`)
|
||||
)
|
||||
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`title: ${r.title || '(none)'}`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
case 'usage':
|
||||
rpc('session.usage', { session_id: sid }).then((r: any) => {
|
||||
if (stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (r) {
|
||||
patchUiState({
|
||||
usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }
|
||||
|
|
@ -272,6 +346,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
}
|
||||
|
||||
const f = (v: number) => (v ?? 0).toLocaleString()
|
||||
|
||||
const cost =
|
||||
r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
|
||||
|
||||
|
|
@ -297,13 +372,19 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
return true
|
||||
|
||||
case 'save':
|
||||
rpc('session.save', { session_id: sid }).then((r: any) => r?.file && sys(`saved: ${r.file}`))
|
||||
rpc('session.save', { session_id: sid }).then((r: any) => {
|
||||
if (stale() || !r?.file) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`saved: ${r.file}`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
case 'history':
|
||||
rpc<SessionHistoryResponse>('session.history', { session_id: sid }).then(r => {
|
||||
if (typeof r?.count !== 'number') {
|
||||
if (stale() || typeof r?.count !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +410,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
|
||||
case 'profile':
|
||||
rpc('config.get', { key: 'profile' }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -343,7 +424,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
|
||||
case 'voice':
|
||||
rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -355,7 +436,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
|
||||
case 'insights':
|
||||
rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => {
|
||||
if (!r) {
|
||||
if (stale() || !r) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -378,5 +459,6 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas
|
|||
}
|
||||
|
||||
interface SessionSlashCommand extends ParsedSlashCommand {
|
||||
flight: number
|
||||
sid: null | string
|
||||
}
|
||||
|
|
|
|||
10
ui-tui/src/app/slash/isStaleSlash.ts
Normal file
10
ui-tui/src/app/slash/isStaleSlash.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { SlashHandlerContext } from '../interfaces.js'
|
||||
import { getUiState } from '../uiStore.js'
|
||||
|
||||
export function isStaleSlash(
|
||||
ctx: Pick<SlashHandlerContext, 'slashFlightRef'>,
|
||||
flight: number,
|
||||
sid: null | string
|
||||
): boolean {
|
||||
return flight !== ctx.slashFlightRef.current || getUiState().sid !== sid
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import type { MutableRefObject } from 'react'
|
||||
|
||||
import type { SlashExecResponse } from '../../gatewayTypes.js'
|
||||
import { rpcErrorMessage } from '../../lib/rpc.js'
|
||||
import { getUiState } from '../uiStore.js'
|
||||
|
||||
export const parseSlashCommand = (cmd: string): ParsedSlashCommand => {
|
||||
const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/)
|
||||
|
|
@ -11,10 +14,14 @@ export const parseSlashCommand = (cmd: string): ParsedSlashCommand => {
|
|||
}
|
||||
}
|
||||
|
||||
export const createSlashShared = ({ gw, page, sys }: SlashSharedDeps): SlashShared => ({
|
||||
showSlashOutput: (title, command, sid) => {
|
||||
export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({
|
||||
showSlashOutput: ({ command, flight, sid, title }) => {
|
||||
gw.request<SlashExecResponse>('slash.exec', { command, session_id: sid })
|
||||
.then(r => {
|
||||
if (flight !== slashFlightRef.current || getUiState().sid !== sid) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)'
|
||||
|
||||
const lines = text.split('\n').filter(Boolean)
|
||||
|
|
@ -25,7 +32,13 @@ export const createSlashShared = ({ gw, page, sys }: SlashSharedDeps): SlashShar
|
|||
sys(text)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.catch((e: unknown) => {
|
||||
if (flight !== slashFlightRef.current || getUiState().sid !== sid) {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`error: ${rpcErrorMessage(e)}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -36,7 +49,7 @@ export interface ParsedSlashCommand {
|
|||
}
|
||||
|
||||
export interface SlashShared {
|
||||
showSlashOutput: (title: string, command: string, sid: null | string) => void
|
||||
showSlashOutput: (opts: { command: string; flight: number; sid: null | string; title: string }) => void
|
||||
}
|
||||
|
||||
interface SlashSharedDeps {
|
||||
|
|
@ -44,5 +57,6 @@ interface SlashSharedDeps {
|
|||
request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
page: (text: string, title?: string) => void
|
||||
slashFlightRef: MutableRefObject<number>
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
|
|
|||
62
ui-tui/src/app/useLongRunToolCharms.ts
Normal file
62
ui-tui/src/app/useLongRunToolCharms.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { toolTrailLabel } from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem } from '../types.js'
|
||||
|
||||
const DELAY_MS = 8_000
|
||||
const INTERVAL_MS = 10_000
|
||||
const MAX = 2
|
||||
const CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…']
|
||||
|
||||
export function useLongRunToolCharms(
|
||||
busy: boolean,
|
||||
tools: ActiveTool[],
|
||||
pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
|
||||
) {
|
||||
const slotRef = useRef(new Map<string, { count: number; lastAt: number }>())
|
||||
|
||||
useEffect(() => {
|
||||
if (!busy || !tools.length) {
|
||||
slotRef.current.clear()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const now = Date.now()
|
||||
const liveIds = new Set(tools.map(t => t.id))
|
||||
|
||||
for (const key of [...slotRef.current.keys()]) {
|
||||
if (!liveIds.has(key)) {
|
||||
slotRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool.startedAt || now - tool.startedAt < DELAY_MS) {
|
||||
continue
|
||||
}
|
||||
|
||||
const slot = slotRef.current.get(tool.id) ?? { count: 0, lastAt: 0 }
|
||||
|
||||
if (slot.count >= MAX || now - slot.lastAt < INTERVAL_MS) {
|
||||
continue
|
||||
}
|
||||
|
||||
slot.count += 1
|
||||
slot.lastAt = now
|
||||
slotRef.current.set(tool.id, slot)
|
||||
|
||||
const charm = CHARMS[Math.floor(Math.random() * CHARMS.length)]!
|
||||
const sec = Math.round((now - tool.startedAt) / 1000)
|
||||
|
||||
pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`)
|
||||
}
|
||||
}
|
||||
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [busy, pushActivity, tools])
|
||||
}
|
||||
1216
ui-tui/src/app/useMainApp.ts
Normal file
1216
ui-tui/src/app/useMainApp.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -147,6 +147,11 @@ export interface SlashExecResponse {
|
|||
warning?: string
|
||||
}
|
||||
|
||||
export type CommandDispatchResponse =
|
||||
| { output?: string; type: 'exec' | 'plugin' }
|
||||
| { target: string; type: 'alias' }
|
||||
| { message?: string; name: string; type: 'skill' }
|
||||
|
||||
export interface SubagentEventPayload {
|
||||
duration_seconds?: number
|
||||
goal: string
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { CommandDispatchResponse } from '../gatewayTypes.js'
|
||||
|
||||
export type RpcResult = Record<string, any>
|
||||
|
||||
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null => {
|
||||
|
|
@ -8,6 +10,34 @@ export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T
|
|||
return value as T
|
||||
}
|
||||
|
||||
export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => {
|
||||
const o = asRpcResult(value)
|
||||
|
||||
if (!o || typeof o.type !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const t = o.type
|
||||
|
||||
if (t === 'exec' || t === 'plugin') {
|
||||
return { type: t, output: typeof o.output === 'string' ? o.output : undefined }
|
||||
}
|
||||
|
||||
if (t === 'alias' && typeof o.target === 'string') {
|
||||
return { type: 'alias', target: o.target }
|
||||
}
|
||||
|
||||
if (t === 'skill' && typeof o.name === 'string') {
|
||||
return {
|
||||
type: 'skill',
|
||||
name: o.name,
|
||||
message: typeof o.message === 'string' ? o.message : undefined
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const rpcErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error && err.message) {
|
||||
return err.message
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue