diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index c54a659b94..67aa27f768 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -26,17 +26,55 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) - it('falls through /skills with args to slash.exec without opening overlay', () => { + it('routes /skills install to skills.manage without opening overlay', () => { const ctx = buildCtx() expect(createSlashHandler(ctx)('/skills install foo')).toBe(true) expect(getOverlayState().skillsHub).toBe(false) - expect(ctx.gateway.rpc).toHaveBeenCalledWith('slash.exec', { - command: 'skills install foo', - session_id: null + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'install', + query: 'foo' }) }) + it('routes /skills inspect to skills.manage', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills inspect my-skill') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'inspect', + query: 'my-skill' + }) + }) + + it('routes /skills search to skills.manage', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills search vibe') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'search', + query: 'vibe' + }) + }) + + it('routes /skills browse [page] to skills.manage with a numeric page', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills browse 3') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'browse', + page: 3 + }) + }) + + it('shows usage for an unknown /skills subcommand', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills zzz') + expect(ctx.gateway.rpc).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills')) + }) + it('cycles details mode and persists it', async () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index aa02fa6cbb..d941c5af41 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -1,26 +1,158 @@ -import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js' +import type { ToolsConfigureResponse } from '../../../gatewayTypes.js' +import type { PanelSection } from '../../../types.js' import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand } from '../types.js' +interface SkillInfo { + category?: string + description?: string + name?: string + path?: string +} + +interface SkillsListResponse { + skills?: Record +} + +interface SkillsInspectResponse { + info?: SkillInfo +} + +interface SkillsSearchResponse { + results?: { description?: string; name: string }[] +} + +interface SkillsInstallResponse { + installed?: boolean + name?: string +} + export const opsCommands: SlashCommand[] = [ { - help: 'browse, inspect, and install skills', + help: 'browse, inspect, install skills', name: 'skills', run: (arg, ctx) => { - if (!arg.trim()) { + const text = arg.trim() + + if (!text) { return patchOverlayState({ skillsHub: true }) } - ctx.gateway - .rpc('slash.exec', { command: `skills ${arg}`, session_id: ctx.sid }) - .then( - ctx.guarded(r => { - if (r.output) { - ctx.transcript.page(r.output, 'Skills') - } - }) - ) - .catch(ctx.guardedErr) + const [sub, ...rest] = text.split(/\s+/) + const query = rest.join(' ').trim() + const { rpc } = ctx.gateway + const { page, panel, sys } = ctx.transcript + + if (sub === 'list') { + rpc('skills.manage', { action: 'list' }) + .then( + ctx.guarded(r => { + const cats = Object.entries(r.skills ?? {}).sort() + + if (!cats.length) { + return sys('no skills available') + } + + panel( + 'Skills', + cats.map(([title, items]) => ({ items, title })) + ) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'inspect') { + if (!query) { + return sys('usage: /skills inspect ') + } + + rpc('skills.manage', { action: 'inspect', query }) + .then( + ctx.guarded(r => { + const info = r.info ?? {} + + if (!info.name) { + return sys(`unknown skill: ${query}`) + } + + const rows: [string, string][] = [ + ['Name', String(info.name)], + ['Category', String(info.category ?? '')], + ['Path', String(info.path ?? '')] + ] + + const sections: PanelSection[] = [{ rows }] + + if (info.description) { + sections.push({ text: String(info.description) }) + } + + panel('Skill', sections) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'search') { + if (!query) { + return sys('usage: /skills search ') + } + + rpc('skills.manage', { action: 'search', query }) + .then( + ctx.guarded(r => { + const results = r.results ?? [] + + if (!results.length) { + return sys(`no results for: ${query}`) + } + + panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }]) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'install') { + if (!query) { + return sys('usage: /skills install ') + } + + sys(`installing ${query}…`) + + rpc('skills.manage', { action: 'install', query }) + .then( + ctx.guarded(r => + sys(r.installed ? `installed ${r.name ?? query}` : 'install failed') + ) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'browse') { + const pageNum = parseInt(query, 10) || 1 + + rpc>('skills.manage', { action: 'browse', page: pageNum }) + .then( + ctx.guarded>(r => + page(JSON.stringify(r, null, 2).slice(0, 4000), `Browse Skills — p${pageNum}`) + ) + ) + .catch(ctx.guardedErr) + + return + } + + sys('usage: /skills [list | inspect | install | search | browse [page]]') } }, diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 03ed3d92f3..877bb0ef38 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -89,10 +89,16 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { } if (stage === 'actions') { - if (key.return || ch.toLowerCase() === 'x') { - if (skillName) { - install(skillName) - } + if (key.return) { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (ch.toLowerCase() === 'x' && skillName) { + install(skillName) return } @@ -271,7 +277,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { {err ? error: {err} : null} {installing ? installing… : null} - Enter install · i inspect · x install · Esc back + i reinspect · x reinstall · Enter/Esc back ) }