chore: uptick

This commit is contained in:
Brooklyn Nicholson 2026-04-16 01:04:35 -05:00
parent cb31732c4f
commit 8e06db56fd
14 changed files with 1712 additions and 1362 deletions

View 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()
})
})

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -320,6 +320,7 @@ export interface GatewayEventHandlerContext {
}
export interface SlashHandlerContext {
slashFlightRef: MutableRefObject<number>
composer: {
enqueue: (text: string) => void
hasSelection: boolean

View file

@ -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: {

View file

@ -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
}

View file

@ -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
}

View 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
}

View file

@ -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
}

View 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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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