diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts index d35c78ea7b..cf497e6737 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -5,6 +5,9 @@ export interface CompletionEntry { text: string display?: unknown meta?: unknown + /** From `complete.slash`: registry command vs skill. The popover groups on + * this instead of re-deriving kind from the desktop command table. */ + kind?: string /** Optional section label (e.g. "Commands", "Skills"). The popover renders a * header whenever this changes between consecutive items, so the fetcher must * emit entries already grouped contiguously. */ diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx index a1e87ca53f..2e56281a1d 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -130,17 +130,23 @@ describe('useSlashCompletions', () => { expect(skills).toEqual(['/work', '/research', '/docx']) }) - // Typing is a search, and a search that hides a match is broken — the - // never-used built-in still shows, just below the one she actually uses. - it('ranks a typed query by use without hiding anything', async () => { + // Typing is a search, and a search that hides a match is broken. Order + // stays the backend's (fuzzy score, then usage) — a second client usage + // sort is what buried `/review` under skills. + it('keeps backend order on a typed query and does not hide matches', async () => { const request = vi.fn().mockImplementation((method: string) => Promise.resolve( method === 'commands.catalog' ? RANKED_CATALOG : { items: [ - { text: '/research-paper-writing', display: '/research-paper-writing', meta: 'Write a paper' }, - { text: '/research', display: '/research', meta: 'Look it up' } + { + text: '/research-paper-writing', + display: '/research-paper-writing', + kind: 'skill', + meta: 'Write a paper' + }, + { text: '/research', display: '/research', kind: 'skill', meta: 'Look it up' } ] } ) @@ -148,10 +154,33 @@ describe('useSlashCompletions', () => { const api = harness({ request } as unknown as HermesGateway) - // Warm the catalog first: the popover always opens on a bare `/` before a - // query is typed, which is where the usage map comes from. - await completions(api, '') + expect(commandsOf(await completions(api, 'research'))).toEqual(['/research-paper-writing', '/research']) + }) - expect(commandsOf(await completions(api, 'research'))).toEqual(['/research', '/research-paper-writing']) + it('keeps a registry command in Commands even when the desktop table has no row', async () => { + const request = vi.fn().mockImplementation((method: string) => + Promise.resolve( + method === 'commands.catalog' + ? CATALOG + : { + items: [ + { text: '/refine', display: '/refine', kind: 'command', meta: 'Review this conversation' }, + { text: '/docx', display: '/docx', kind: 'skill', meta: 'Edit Word documents' }, + { text: '/compress', display: '/compress', kind: 'command', meta: 'Compress context' } + ] + } + ) + ) + + const api = harness({ request } as unknown as HermesGateway) + const items = await completions(api, 're') + const groupOf = (command: string) => + (items.find(item => (item.metadata as { command?: string })?.command === command)?.metadata as { group?: string }) + ?.group + + expect(commandsOf(items)).toEqual(['/refine', '/compress', '/docx']) + expect(groupOf('/refine')).toBe('Commands') + expect(groupOf('/compress')).toBe('Commands') + expect(groupOf('/docx')).toBe('Skills') }) }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index 32f0038fe2..cde76d9874 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -1,6 +1,6 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import { useStore } from '@nanostores/react' -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import type { HermesGateway } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' @@ -12,13 +12,13 @@ import { filterDesktopCommandsCatalog, isDesktopSlashExtensionCommand, isDesktopSlashSuggestion, - rankSkillCommands + rankSkillCommands, + slashCompletionGroup } from '@/lib/desktop-slash-commands' import { $slashCompletionsEpoch, cachedSlashCompletion, - hasCachedSlashCompletion, - peekCachedSlashCompletion + hasCachedSlashCompletion } from '@/lib/slash-completion-cache' import { normalize } from '@/lib/text' import { $sessions } from '@/store/session' @@ -73,6 +73,21 @@ export function useSlashCompletions(options: { const enabled = Boolean(gateway) const epoch = useStore($slashCompletionsEpoch) + // Warm argument_mode before the first `/` so Space treats /review as text. + useEffect(() => { + if (!gateway) { + return + } + + void cachedSlashCompletion('catalog', () => gateway.request('commands.catalog')) + .then(catalog => { + filterDesktopCommandsCatalog(catalog) + }) + .catch(() => { + // Next keystroke retries; don't block the composer on a warm-up miss. + }) + }, [gateway, epoch]) + const fetcher = useCallback( async (query: string): Promise => { if (!gateway) { @@ -210,7 +225,9 @@ export function useSlashCompletions(options: { ...item, // Arg suggestions (e.g. `/handoff `) live under one // header; otherwise split skills out from built-in commands. - group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands', + // Kind comes from the backend — the desktop table is a visibility + // gate (`isDesktopSlashSuggestion`), not a classifier. + group: isArgCompletion ? 'Options' : slashCompletionGroup(item.text, item.kind), // Arg items carry their own meta (the personality/toolset/platform // blurb). Only command rows get the registry description — looking // one up for `/personality none` would clobber it with the parent @@ -220,29 +237,18 @@ export function useSlashCompletions(options: { // Keep each group contiguous so headers render once: Commands before // Skills (stable within a group, preserving backend relevance order). + // Do not re-sort skills by usage here — complete.slash already ranked + // by fuzzy score, then usage. A second usage pass buried exact name + // matches that the table had mis-filed as skills. const groupOrder = ['Commands', 'Skills', 'Options'] if (isArgCompletion) { return { items: decorated, query } } - // Rank the matched skills by use — `/re` should lead with the /research - // the user lives in, not the /research-paper-writing they've never - // opened. Nothing is pruned here: a typed query is a search, and a - // search that hides a match is broken. Usage rides along on the catalog - // response, which the popover has already fetched by the time anyone - // types; if it somehow hasn't, order falls back to the backend's. - const catalogSkills = peekCachedSlashCompletion('catalog')?.skills - - const ranked = [ - ...decorated.filter(item => item.group !== 'Skills'), - ...rankSkillCommands( - decorated.filter(item => item.group === 'Skills'), - catalogSkills - ) - ] - - const items = [...ranked].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group)) + const items = [...decorated].sort( + (a, b) => groupOrder.indexOf(a.group ?? '') - groupOrder.indexOf(b.group ?? '') + ) return { items, query } } catch { diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 29a5820392..6ba07a5721 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + type CommandCatalogMeta, + type CommandsCatalogLike, + type DesktopSlashArgumentMode, desktopSkinSlashCompletions, desktopSlashCommandArgumentMode, desktopSlashDescription, @@ -11,10 +14,65 @@ import { isModelPickerCommand, isPickerCommand, rankSkillCommands, - resolveDesktopCommand + rememberDesktopCommandsCatalog, + resolveDesktopCommand, + slashCompletionGroup } from './desktop-slash-commands' +function registryCatalog( + modes: Record, + aliases: Record = {} +): CommandsCatalogLike { + const commands: Record = {} + const canon: Record = {} + + for (const [name, argument_mode] of Object.entries(modes)) { + commands[name] = { argument_mode, desktop: null } + canon[name] = name + } + + for (const [alias, target] of Object.entries(aliases)) { + commands[alias] = commands[target] + canon[alias] = target + } + + return { commands, canon } +} + +const REGISTRY_CATALOG = registryCatalog( + { + '/approvals': 'options', + '/review': 'text', + '/refine': 'text', + '/usage': null, + '/version': null, + '/agents': null, + '/steer': 'text', + '/stop': null, + '/background': 'text', + '/debug': null, + '/goal': 'mixed', + '/personality': 'options', + '/queue': 'text', + '/retry': null, + '/rollback': null, + '/tools': 'options', + '/undo': null, + '/loop': 'mixed', + '/lcm': 'text' + }, + { '/tasks': '/agents', '/bg': '/background', '/q': '/queue', '/proactive': '/loop' } +) + describe('desktop slash command curation', () => { + beforeEach(() => { + rememberDesktopCommandsCatalog(REGISTRY_CATALOG) + }) + + afterEach(() => { + rememberDesktopCommandsCatalog(undefined) + }) + it('keeps core desktop chat commands in suggestions', () => { expect(isDesktopSlashSuggestion('/new')).toBe(true) expect(isDesktopSlashSuggestion('/branch')).toBe(true) @@ -26,6 +84,29 @@ describe('desktop slash command curation', () => { expect(isDesktopSlashSuggestion('/approvals')).toBe(true) expect(isDesktopSlashCommand('/approvals')).toBe(true) expect(resolveDesktopCommand('/approvals')?.surface).toEqual({ kind: 'exec' }) + expect(isDesktopSlashSuggestion('/review')).toBe(true) + expect(isDesktopSlashCommand('/review')).toBe(true) + expect(resolveDesktopCommand('/review')?.surface).toEqual({ kind: 'exec' }) + expect(resolveDesktopCommand('/review')?.argumentMode).toBe('text') + }) + + it('treats registry and plugin commands as exec when the catalog says so', () => { + expect(resolveDesktopCommand('/refine')?.argumentMode).toBe('text') + expect(isDesktopSlashSuggestion('/refine')).toBe(true) + expect(isDesktopSlashSuggestion('/bg')).toBe(false) + expect(isDesktopSlashCommand('/background')).toBe(true) + expect(desktopSlashCommandArgumentMode('/background')).toBe('text') + expect(resolveDesktopCommand('/lcm')?.surface).toEqual({ kind: 'exec' }) + expect(desktopSlashCommandArgumentMode('/lcm')).toBe('text') + }) + + it('groups complete.slash rows by backend kind, not the desktop table', () => { + // A registry command the table has never heard of is still a command. + expect(slashCompletionGroup('/refine', 'command')).toBe('Commands') + expect(slashCompletionGroup('/docx', 'skill')).toBe('Skills') + // Older backends omit kind — fall back to the table. + expect(slashCompletionGroup('/new')).toBe('Commands') + expect(slashCompletionGroup('/docx')).toBe('Skills') }) it('surfaces skill and quick commands (extensions) in suggestions and lets them run', () => { diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index eca8e729cf..4d81231df8 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -1,10 +1,19 @@ +import { peekCachedSlashCompletion } from '@/lib/slash-completion-cache' + export interface CommandsCatalogSection { name: string pairs: [string, string][] } +export interface CommandCatalogMeta { + argument_mode?: 'mixed' | 'options' | 'text' | null + desktop?: string | null +} + export interface CommandsCatalogLike { + canon?: Record categories?: CommandsCatalogSection[] + commands?: Record pairs?: [string, string][] skill_count?: number skills?: SkillCatalogMap @@ -155,9 +164,10 @@ const rpc = ( ): DesktopCommandSurface => ({ kind: 'rpc', rpc: rpcName, timeoutMs, buildParams }) /** - * THE source of truth for desktop slash commands. Everything below — execution - * gating, popover suggestions, catalog filtering, pill grouping, and the - * dispatcher's behavior — derives from this one table. + * Local desktop overlay — actions, pickers, and dedicated RPCs the Electron + * client owns. Registry commands without a row here are `exec` unless the + * catalog marks them unavailable/hidden. New commands and plugins declare + * `argument_mode` / `desktop` on the Python registry instead of adding a row. */ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ // Local client actions @@ -219,35 +229,6 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ argumentMode: 'mixed' }, - // Backend-executed commands that render useful inline output. - // Commands with a dedicated gateway RPC (@method in tui_gateway/server.py) - // route to it directly via `rpc(...)` — bypassing slash.exec avoids the - // slash-worker pipe timeout and the "not a quick/plugin/skill command" - // fallback noise for commands the dispatcher doesn't handle inline. - // These commands have gateway RPCs, but their established desktop behavior - // carries richer CLI semantics: /agents includes delegations, /stop cancels - // them, /steer falls back to a next-turn prompt, and /usage is a formatted - // live report. Keep them on slash.exec until their RPC contracts are fully - // equivalent. - { - name: '/approvals', - description: 'Show or set approval mode [manual|smart|off]', - surface: exec(), - argumentMode: 'options' - }, - { - name: '/agents', - description: 'Show active desktop sessions and running tasks', - aliases: ['/tasks'], - surface: exec() - }, - { - name: '/background', - description: 'Run a prompt in the background', - aliases: ['/bg', '/btw'], - surface: exec(), - argumentMode: 'text' - }, // /compress must be an action (session.compress RPC), not exec: the slash // worker route times out on large sessions (30s WS / 45s pipe) before the // LLM summarise call finishes, then command.dispatch surfaces a bogus @@ -259,26 +240,6 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ surface: action('compress'), argumentMode: 'text' }, - { name: '/debug', description: 'Create a debug report', surface: exec() }, - { - name: '/goal', - description: 'Manage the standing goal for this session', - surface: exec(), - argumentMode: 'mixed' - }, - { - name: '/loop', - description: 'Re-run a prompt on a recurring interval in this session', - aliases: ['/proactive'], - surface: exec(), - argumentMode: 'mixed' - }, - { - name: '/personality', - description: 'Switch personality for this session', - surface: exec(), - argumentMode: 'options' - }, { name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', @@ -291,15 +252,6 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ aliases: ['/generate-pet'], surface: action('hatch') }, - { - name: '/queue', - description: 'Queue a prompt for the next turn', - aliases: ['/q'], - surface: exec(), - argumentMode: 'text' - }, - { name: '/retry', description: 'Retry the last user message', surface: exec() }, - { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() }, { name: '/save', description: 'Save the current transcript to JSON', @@ -309,27 +261,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ name: '/status', description: 'Show current session status', surface: rpc('session.status', ctx => ({ session_id: ctx.sessionId })) - }, - { - name: '/steer', - description: 'Steer the current run after the next tool call', - surface: exec(), - argumentMode: 'text' - }, - { name: '/stop', description: 'Stop running background processes', surface: exec() }, - { - name: '/tools', - description: 'List or toggle tools available to the agent', - surface: exec(), - argumentMode: 'options' - }, - { name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() }, - { name: '/usage', description: 'Show token usage for this session', surface: exec() }, - { name: '/version', description: 'Show Hermes Agent version', surface: exec() }, - - // No desktop surface, but carry an alias (underscore spelling variants). - { name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') }, - { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') } + } ] // Known commands with no desktop surface (and no alias) — a flat name list @@ -370,7 +302,17 @@ const NO_DESKTOP_SURFACE: Record = ], messaging: ['/approve', '/deny'], settings: ['/skills', '/pets'], - advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning'], + advanced: [ + '/curator', + '/fast', + '/insights', + '/kanban', + '/reasoning', + '/reload-mcp', + '/reload_mcp', + '/reload-skills', + '/reload_skills' + ], // /voice arms SERVER-side capture (voice.record → PortAudio on the backend // host) — meaningless on desktop, which has its own composer-native voice // conversation (mic menu / Ctrl+B) with client-side capture and playback. @@ -391,6 +333,94 @@ const ALIAS_TO_CANONICAL = new Map( ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const)) ) +let rememberedCatalog: CommandsCatalogLike | undefined + +/** Last catalog the composer saw — used so Space/Enter know argument mode + * without waiting for another `/` keystroke. */ +export function rememberDesktopCommandsCatalog(catalog: CommandsCatalogLike | undefined): void { + rememberedCatalog = catalog +} + +function liveCatalog(): CommandsCatalogLike | undefined { + return rememberedCatalog ?? peekCachedSlashCompletion('catalog') +} + +function catalogMeta(command: string): CommandCatalogMeta | undefined { + const commands = liveCatalog()?.commands + if (!commands) { + return undefined + } + + const normalized = normalizeCommand(command) + const canonical = ALIAS_TO_CANONICAL.get(normalized) || catalogCanonical(normalized) || normalized + + return commands[canonical] ?? commands[normalized] +} + +function catalogCanonical(normalized: string): string | undefined { + const canon = liveCatalog()?.canon + if (!canon) { + return undefined + } + + return canon[normalized] ?? canon[normalized.toLowerCase()] +} + +function asUnavailableReason(value: string | null | undefined): DesktopUnavailableReason | null { + if ( + value === 'advanced' || + value === 'composer-voice' || + value === 'messaging' || + value === 'settings' || + value === 'terminal' + ) { + return value + } + + return null +} + +function asArgumentMode(value: string | null | undefined): DesktopSlashArgumentMode | undefined { + if (value === 'options' || value === 'text' || value === 'mixed') { + return value + } + + return undefined +} + +function specFromCatalog(command: string): DesktopCommandSpec | null { + const entry = catalogMeta(command) + if (!entry) { + return null + } + + const name = canonicalDesktopSlashCommand(command) + const reason = asUnavailableReason(entry.desktop) + + if (reason) { + return { name, surface: unavailable(reason) } + } + + return { + name, + surface: exec(), + hidden: entry.desktop === 'hidden', + argumentMode: asArgumentMode(entry.argument_mode) + } +} + +function isAliasCommand(command: string): boolean { + const normalized = normalizeCommand(command) + + if (ALIAS_TO_CANONICAL.has(normalized)) { + return true + } + + const resolved = catalogCanonical(normalized) + + return Boolean(resolved && resolved.toLowerCase() !== normalized) +} + const UNAVAILABLE_MESSAGE: Record string> = { advanced: command => `${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`, @@ -416,18 +446,22 @@ function normalizeCommand(command: string): string { export function canonicalDesktopSlashCommand(command: string): string { const normalized = normalizeCommand(command) - return ALIAS_TO_CANONICAL.get(normalized) || normalized + return ALIAS_TO_CANONICAL.get(normalized) || catalogCanonical(normalized) || normalized } /** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */ export function resolveDesktopCommand(command: string): DesktopCommandSpec | null { - return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null + return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? specFromCatalog(command) } function isKnownHermesSlashCommand(command: string): boolean { const normalized = normalizeCommand(command) - return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized) + if (SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized)) { + return true + } + + return catalogMeta(normalized) !== undefined } /** @@ -446,6 +480,24 @@ export function isDesktopSlashExtensionCommand(command: string): boolean { return !isKnownHermesSlashCommand(normalized) } +/** + * Popover group for a `complete.slash` row. The backend already tags each + * item `skill` | `command`; trust that so a new registry command isn't + * dumped into Skills just because this table has no row yet. Older backends + * omit `kind` — then the table is the fallback. + */ +export function slashCompletionGroup(command: string, kind?: string | null): 'Commands' | 'Skills' { + if (kind === 'skill') { + return 'Skills' + } + + if (kind === 'command') { + return 'Commands' + } + + return isDesktopSlashExtensionCommand(command) ? 'Skills' : 'Commands' +} + /** Gates execution: true unless the command is a known no-desktop-surface command. */ export function isDesktopSlashCommand(command: string): boolean { const spec = resolveDesktopCommand(command) @@ -462,11 +514,11 @@ export function isDesktopSlashSuggestion(command: string): boolean { const normalized = normalizeCommand(command) // Aliases stay hidden so the popover isn't cluttered with duplicates. - if (ALIAS_TO_CANONICAL.has(normalized)) { + if (isAliasCommand(normalized)) { return false } - const spec = SPEC_BY_NAME.get(normalized) + const spec = resolveDesktopCommand(normalized) if (spec) { return spec.surface.kind !== 'unavailable' && !spec.hidden @@ -497,7 +549,7 @@ export function isModelPickerCommand(command: string): boolean { export function desktopSlashUnavailableMessage(command: string): string | null { const canonical = canonicalDesktopSlashCommand(command) - const surface = SPEC_BY_NAME.get(canonical)?.surface + const surface = resolveDesktopCommand(canonical)?.surface if (!surface) { return null @@ -519,7 +571,7 @@ export function desktopSlashDescription(command: string, fallback = ''): string } export function desktopSlashCommandArgumentMode(command: string): DesktopSlashArgumentMode | null { - return resolveDesktopCommand(command)?.argumentMode ?? null + return resolveDesktopCommand(command)?.argumentMode ?? asArgumentMode(catalogMeta(command)?.argument_mode) ?? null } export function desktopSkinSlashCompletions( @@ -592,6 +644,8 @@ export function rankSkillCommands( } export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike { + rememberDesktopCommandsCatalog(catalog) + const categories = catalog.categories ?.map(section => ({ ...section,