fix(desktop): resolve slash commands from the catalog
The overlay table is only actions, pickers, and RPCs. Completions group by backend kind, and argument mode comes from the warmed catalog so /review and plugin commands stay typeable.
This commit is contained in:
committed by
brooklyn!
parent
b61408e95e
commit
60f58249e5
@@ -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. */
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<CommandsCatalogLike>('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<CompletionPayload> => {
|
||||
if (!gateway) {
|
||||
@@ -210,7 +225,9 @@ export function useSlashCompletions(options: {
|
||||
...item,
|
||||
// Arg suggestions (e.g. `/handoff <platform>`) 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<CommandsCatalogLike>('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 {
|
||||
|
||||
@@ -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<string, DesktopSlashArgumentMode | null>,
|
||||
aliases: Record<string, string> = {}
|
||||
): CommandsCatalogLike {
|
||||
const commands: Record<string, CommandCatalogMeta> = {}
|
||||
const canon: Record<string, string> = {}
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<string, string>
|
||||
categories?: CommandsCatalogSection[]
|
||||
commands?: Record<string, CommandCatalogMeta>
|
||||
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<DesktopUnavailableReason, readonly string[]> =
|
||||
],
|
||||
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<string, string>(
|
||||
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<CommandsCatalogLike>('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<DesktopUnavailableReason, (command: string) => 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<T extends { text: string }>(
|
||||
}
|
||||
|
||||
export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike {
|
||||
rememberDesktopCommandsCatalog(catalog)
|
||||
|
||||
const categories = catalog.categories
|
||||
?.map(section => ({
|
||||
...section,
|
||||
|
||||
Reference in New Issue
Block a user