desktop: registry-driven slash commands + first-class /resume & /handoff (#42351)
* desktop: surface /tools, /save, /personality and fix /help skill count Move /tools and /save out of TERMINAL_ONLY_COMMANDS and /personality out of ADVANCED_COMMANDS so they appear in the desktop slash palette and execute via the existing slash.exec → command.dispatch fallback. The backend gateway already accepts these through slash.exec (none are in _PENDING_INPUT_COMMANDS or the skill list), so no backend change is required. Recompute skill_count in filterDesktopCommandsCatalog from the filtered pairs. Previously the /help footer echoed the unfiltered backend total — e.g. "60 skill commands available" while only ~29 actually appeared in the rendered list, because the desktop hides terminal-only, picker-owned, and advanced commands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * desktop: keep slash popover live while typing args The trigger regex `(?:^|[\s])([@/])([^\s@/]*)$` stopped matching the moment the user typed a space after a slash command, so the popover never showed arg completions for `/personality`, `/tools`, etc. — even though the backend's `complete.slash` already returns them with a `replace_from` indicator. Split the trigger detection so `/` allows args (`/cmd arg1 arg2`) while `@` keeps the strict no-space behavior. Restrict the slash command name to `[a-zA-Z][\w-]*` so file paths like `src/foo/bar` don't accidentally trigger the popover. Rewrite arg-completion items in useSlashCompletions to insert the full `/personality alice` token instead of stranding `/alice`: when `replace_from` is past the command base, prepend the existing prefix to each item's text so the chip serializer produces a coherent replacement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * cli: complete toolset names after /tools enable|disable SlashCommandCompleter previously only auto-derived the first subcommand level from args_hint, so `/tools enable <tab>` yielded nothing — the user had to remember every toolset key (web, file, spotify, …) and every MCP server prefix. Add `_tools_completions` that handles both stages: subcommand (list|disable|enable) and tool name. Filter by current enable state so `/tools enable <tab>` only offers disabled toolsets and `/tools disable <tab>` only offers enabled ones — no point suggesting a no-op. MCP server prefixes (server:) come from the saved mcp_servers config; per-tool completion under a server would require runtime MCP introspection and is left as follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * desktop: registry-driven slash commands with first-class pickers Collapse the if/else slash dispatch into one DESKTOP_COMMAND_SPECS table that drives popover suggestions, per-type composer pills, and execution. - /resume, /sessions, /switch: inline session completions (like /skin) plus a "Browse all sessions…" entry that opens a dedicated session picker overlay - /handoff: inline platform completion + handoff.request/handoff.state gateway bridge so desktop reaches CLI parity - colored per-type pills (command/skill/theme) in the composer - strip ANSI and fix width/alignment of slash output in the chat panel * desktop: fold repeated slash session/output boilerplate into one helper runExec, /title, /help and the unavailable case each re-derived the same ensure-session → bail-with-notify → build-renderSlashOutput dance. withSlashOutput() returns {sessionId, render} or null, so each handler is a two-line resolve instead of an eight-line preamble. * desktop: keep backend meta on slash arg completions Arg suggestions (/personality <name>, /tools enable <toolset>, /handoff <platform>) were having their meta overwritten with the parent command's registry description: desktopSlashDescription("/personality none") canonicalizes back to /personality and returns its blurb. Skip the lookup for arg rows so the backend's own display_meta ("clear personality overlay", etc.) survives. * cli: list real personalities in /personality completion _personality_completions resolved load_config().agent.personalities — but that schema has no agent.personalities key, so completion always returned just `none` even though the runtime (load_cli_config().agent.personalities) ships a dozen built-ins (helpful, kawaii, pirate, …). Read from the same source the command actually applies, so `/personality ` surfaces the real options. * desktop: expand bare arg-commands to their options on pick Picking a command like /personality from the slash popover committed it immediately instead of advancing to its argument list. Mark arg-taking commands (/skin, /resume, /handoff, /personality, /tools) in the registry and, when one is picked bare, insert "/cmd " as plain text and re-open the popover on its inline options — mirroring typing "/cmd " by hand. Arg picks (serialized text already contains a space) still commit a single pill. Also realign trigger-popover loading test with the redesigned popover (the /help empty-state hint shows when resolved, not while the spinner is up); the merge from main reintroduced the pre-redesign expectation. * tui_gateway: fold session-db close into a context manager Both handoff RPCs repeated the same `db, close_db = _session_db_handle()` + `finally: if close_db: db.close()` dance. Turn the helper into a `_session_db` contextmanager that owns the close, so callers just `with _session_db(session) as db:`. * desktop: unblock handoff retries and exact resume ids Clear timed-out desktop handoffs through the gateway so retries are not stuck behind a pending row, and let typed /resume session ids bypass the loaded sidebar cache. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
615ad97928
commit
3ffbdfbcc0
@@ -173,3 +173,14 @@ export function hasAnsiCodes(input: string): boolean {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /\x1b\[/.test(input)
|
||||
}
|
||||
|
||||
/** Remove all ANSI escape sequences, returning plain text. Use when output is
|
||||
* rendered as text (e.g. chat system messages) rather than styled segments —
|
||||
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
|
||||
export function stripAnsi(input: string): string {
|
||||
if (!input) {
|
||||
return input
|
||||
}
|
||||
|
||||
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isDesktopSlashSuggestion,
|
||||
isModelPickerCommand
|
||||
isModelPickerCommand,
|
||||
isPickerCommand,
|
||||
resolveDesktopCommand
|
||||
} from './desktop-slash-commands'
|
||||
|
||||
describe('desktop slash command curation', () => {
|
||||
@@ -38,6 +40,18 @@ describe('desktop slash command curation', () => {
|
||||
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces /tools, /save, and /personality on the desktop', () => {
|
||||
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/save')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/tools')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/save')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/personality')).toBe(true)
|
||||
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
|
||||
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
|
||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows aliases to execute without cluttering the popover', () => {
|
||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||
@@ -74,6 +88,24 @@ describe('desktop slash command curation', () => {
|
||||
['/new', 'Start a new desktop chat'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
])
|
||||
// skill_count is recomputed from the filtered output (only /ship-it is an
|
||||
// extension command — /new is a built-in) so the /help footer matches what
|
||||
// the user actually sees rather than echoing the unfiltered backend total.
|
||||
expect(filtered.skill_count).toBe(1)
|
||||
})
|
||||
|
||||
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
|
||||
const filtered = filterDesktopCommandsCatalog({
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/clear', 'Clear terminal screen'],
|
||||
['/gif-search', 'Search for a gif'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
],
|
||||
skill_count: 12
|
||||
})
|
||||
|
||||
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
|
||||
expect(filtered.skill_count).toBe(2)
|
||||
})
|
||||
|
||||
@@ -123,4 +155,26 @@ describe('desktop slash command curation', () => {
|
||||
expect(isModelPickerCommand('/new')).toBe(false)
|
||||
expect(isModelPickerCommand('/skills')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives /resume (and its aliases) a first-class session picker surface', () => {
|
||||
expect(isPickerCommand('/resume', 'session')).toBe(true)
|
||||
expect(isPickerCommand('/sessions', 'session')).toBe(true)
|
||||
expect(isPickerCommand('/switch', 'session')).toBe(true)
|
||||
// Unlike /model, /resume shows in the popover; its aliases stay hidden.
|
||||
expect(isDesktopSlashSuggestion('/resume')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/sessions')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/switch')).toBe(true)
|
||||
// The session picker is distinct from the model picker.
|
||||
expect(isModelPickerCommand('/resume')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves commands and aliases to their declared surface', () => {
|
||||
expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
|
||||
expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
|
||||
expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' })
|
||||
expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' })
|
||||
expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' })
|
||||
// Skill / quick commands aren't in the registry.
|
||||
expect(resolveDesktopCommand('/gif-search')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,110 +22,161 @@ export interface DesktopThemeCommandOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
const DESKTOP_COMMAND_META = [
|
||||
['/agents', 'Show active desktop sessions and running tasks'],
|
||||
['/background', 'Run a prompt in the background'],
|
||||
['/branch', 'Branch the latest message into a new chat'],
|
||||
['/compress', 'Compress this conversation context'],
|
||||
['/debug', 'Create a debug report'],
|
||||
['/goal', 'Manage the standing goal for this session'],
|
||||
['/help', 'Show desktop slash commands'],
|
||||
['/new', 'Start a new desktop chat'],
|
||||
['/profile', 'Switch the active Hermes profile'],
|
||||
['/queue', 'Queue a prompt for the next turn'],
|
||||
['/resume', 'Resume a saved session'],
|
||||
['/retry', 'Retry the last user message'],
|
||||
['/rollback', 'List or restore filesystem checkpoints'],
|
||||
['/skin', 'Switch desktop theme or cycle to the next one'],
|
||||
['/status', 'Show current session status'],
|
||||
['/steer', 'Steer the current run after the next tool call'],
|
||||
['/stop', 'Stop running background processes'],
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session'],
|
||||
['/version', 'Show Hermes Agent version'],
|
||||
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
|
||||
] as const
|
||||
/**
|
||||
* Local client action a command resolves to. Each id maps to exactly one
|
||||
* handler in the dispatcher (`use-prompt-actions`), so adding a command never
|
||||
* means adding a branch to a switch ladder — you add a row here + a handler
|
||||
* keyed by the id.
|
||||
*/
|
||||
export type DesktopActionId =
|
||||
| 'branch'
|
||||
| 'handoff'
|
||||
| 'help'
|
||||
| 'new'
|
||||
| 'profile'
|
||||
| 'skin'
|
||||
| 'title'
|
||||
| 'yolo'
|
||||
|
||||
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
|
||||
/** A command fulfilled by opening a desktop overlay picker. */
|
||||
export type DesktopPickerId = 'model' | 'session'
|
||||
|
||||
const DESKTOP_ALIASES = new Map([
|
||||
['/bg', '/background'],
|
||||
['/btw', '/background'],
|
||||
['/fork', '/branch'],
|
||||
['/q', '/queue'],
|
||||
['/reload_mcp', '/reload-mcp'],
|
||||
['/reload_skills', '/reload-skills'],
|
||||
['/reset', '/new'],
|
||||
['/tasks', '/agents']
|
||||
])
|
||||
/** Why a known Hermes command has no desktop UI surface. */
|
||||
export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal'
|
||||
|
||||
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
|
||||
/**
|
||||
* How the desktop fulfils a command. This is the single discriminator the
|
||||
* dispatcher, popover, pills, and pickers all read — no parallel block-lists.
|
||||
*
|
||||
* - `action` → handled by a local client handler (new chat, branch, …)
|
||||
* - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is
|
||||
* resolved by that picker instead of falling through
|
||||
* - `exec` → runs on the backend via slash.exec / command.dispatch and
|
||||
* renders its text output inline
|
||||
* - `unavailable`→ a known command with genuinely no desktop UI (terminal-only,
|
||||
* messaging-only, …); shows a reason instead of executing
|
||||
*/
|
||||
export type DesktopCommandSurface =
|
||||
| { kind: 'action'; action: DesktopActionId }
|
||||
| { kind: 'picker'; picker: DesktopPickerId }
|
||||
| { kind: 'exec' }
|
||||
| { kind: 'unavailable'; reason: DesktopUnavailableReason }
|
||||
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model'])
|
||||
export interface DesktopCommandSpec {
|
||||
/** Canonical command, leading slash included (e.g. `/resume`). */
|
||||
name: string
|
||||
/** Popover/help label; omitted for unavailable commands (never surfaced). */
|
||||
description?: string
|
||||
aliases?: string[]
|
||||
surface: DesktopCommandSurface
|
||||
/**
|
||||
* Hide from the slash popover / completions while still letting it execute.
|
||||
* Used for picker commands reachable from chrome (the model picker lives on
|
||||
* the status bar), so the popover doesn't dead-end on inline completion.
|
||||
*/
|
||||
hidden?: boolean
|
||||
/**
|
||||
* The command has an inline options "screen" (theme / personality / session /
|
||||
* platform / toolset list). Picking the bare command in the popover expands to
|
||||
* that argument step instead of committing — mirroring typing `/<cmd> ` by hand.
|
||||
*/
|
||||
args?: boolean
|
||||
}
|
||||
|
||||
const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/browser',
|
||||
'/busy',
|
||||
'/clear',
|
||||
'/commands',
|
||||
'/compact',
|
||||
'/config',
|
||||
'/copy',
|
||||
'/cron',
|
||||
'/details',
|
||||
'/exit',
|
||||
'/footer',
|
||||
'/gateway',
|
||||
'/gquota',
|
||||
'/history',
|
||||
'/image',
|
||||
'/indicator',
|
||||
'/logs',
|
||||
'/mouse',
|
||||
'/paste',
|
||||
'/platforms',
|
||||
'/plugins',
|
||||
'/quit',
|
||||
'/redraw',
|
||||
'/reload',
|
||||
'/restart',
|
||||
'/save',
|
||||
'/sb',
|
||||
'/set-home',
|
||||
'/sethome',
|
||||
'/snap',
|
||||
'/snapshot',
|
||||
'/statusbar',
|
||||
'/toolsets',
|
||||
'/tools',
|
||||
'/update',
|
||||
'/verbose'
|
||||
])
|
||||
const exec = (): DesktopCommandSurface => ({ kind: 'exec' })
|
||||
const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id })
|
||||
const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
|
||||
const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
|
||||
|
||||
const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
// Local client actions
|
||||
{ name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') },
|
||||
{ name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') },
|
||||
{ name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
|
||||
{ name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true },
|
||||
{ name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') },
|
||||
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
|
||||
{ name: '/title', description: 'Rename the current session', surface: action('title') },
|
||||
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
|
||||
|
||||
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
|
||||
// Overlay pickers
|
||||
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
|
||||
{
|
||||
name: '/resume',
|
||||
description: 'Resume a saved session',
|
||||
aliases: ['/sessions', '/switch'],
|
||||
surface: picker('session'),
|
||||
args: true
|
||||
},
|
||||
|
||||
const ADVANCED_COMMANDS = new Set([
|
||||
'/curator',
|
||||
'/fast',
|
||||
'/insights',
|
||||
'/kanban',
|
||||
'/personality',
|
||||
'/reasoning',
|
||||
'/reload-mcp',
|
||||
'/reload-skills',
|
||||
'/voice'
|
||||
])
|
||||
// Backend-executed commands that render useful inline output
|
||||
{ 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() },
|
||||
{ name: '/compress', description: 'Compress this conversation context', surface: exec() },
|
||||
{ name: '/debug', description: 'Create a debug report', surface: exec() },
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
|
||||
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
|
||||
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
|
||||
{ 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', surface: exec() },
|
||||
{ name: '/status', description: 'Show current session status', surface: exec() },
|
||||
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() },
|
||||
{ name: '/stop', description: 'Stop running background processes', surface: exec() },
|
||||
{ name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
|
||||
{ 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() },
|
||||
|
||||
const BLOCKED_COMMANDS = new Set([
|
||||
...PICKER_OWNED_COMMANDS,
|
||||
...TERMINAL_ONLY_COMMANDS,
|
||||
...MESSAGING_ONLY_COMMANDS,
|
||||
...SETTINGS_OWNED_COMMANDS,
|
||||
...ADVANCED_COMMANDS
|
||||
])
|
||||
// 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
|
||||
// per reason beats 40 identical object literals.
|
||||
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
|
||||
terminal: [
|
||||
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
|
||||
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
|
||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||
],
|
||||
messaging: ['/approve', '/deny'],
|
||||
settings: ['/skills'],
|
||||
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
|
||||
}
|
||||
|
||||
const ALL_SPECS: readonly DesktopCommandSpec[] = [
|
||||
...DESKTOP_COMMAND_SPECS,
|
||||
...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap(
|
||||
([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) }))
|
||||
)
|
||||
]
|
||||
|
||||
const SPEC_BY_NAME = new Map<string, DesktopCommandSpec>(ALL_SPECS.map(spec => [spec.name, spec]))
|
||||
|
||||
const ALIAS_TO_CANONICAL = new Map<string, string>(
|
||||
ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const))
|
||||
)
|
||||
|
||||
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.`,
|
||||
messaging: command => `${command} is only used from messaging platforms.`,
|
||||
settings: command => `${command} is managed from the desktop sidebar.`,
|
||||
terminal: command => `${command} is only available in the terminal interface.`
|
||||
}
|
||||
|
||||
const PICKER_UNAVAILABLE_MESSAGE: Record<DesktopPickerId, (command: string) => string> = {
|
||||
model: command => `${command} uses the desktop model picker instead of a slash command.`,
|
||||
session: command => `${command} uses the desktop session picker instead of a slash command.`
|
||||
}
|
||||
|
||||
function normalizeCommand(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
@@ -137,27 +188,25 @@ function normalizeCommand(command: string): string {
|
||||
export function canonicalDesktopSlashCommand(command: string): string {
|
||||
const normalized = normalizeCommand(command)
|
||||
|
||||
return DESKTOP_ALIASES.get(normalized) || normalized
|
||||
return ALIAS_TO_CANONICAL.get(normalized) || normalized
|
||||
}
|
||||
|
||||
export function isDesktopSlashCommand(command: string): boolean {
|
||||
/** 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
|
||||
}
|
||||
|
||||
function isKnownHermesSlashCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
|
||||
return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* An "extension" command is anything the backend surfaces that is NOT one of
|
||||
* Hermes' built-in slash commands — i.e. skill commands (`/gif-search`,
|
||||
* `/codex`, …) and user-defined quick commands. These are user-activated, so
|
||||
* they should appear in the desktop slash palette even though they aren't in
|
||||
* the curated `DESKTOP_COMMANDS` allow-list. This mirrors the predicate in
|
||||
* `isDesktopSlashCommand` that already lets them EXECUTE when typed.
|
||||
* they appear in the desktop slash palette and execute when typed.
|
||||
*/
|
||||
export function isDesktopSlashExtensionCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
@@ -169,63 +218,85 @@ export function isDesktopSlashExtensionCommand(command: string): boolean {
|
||||
return !isKnownHermesSlashCommand(normalized)
|
||||
}
|
||||
|
||||
export function isDesktopSlashSuggestion(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
/** Gates execution: true unless the command is a known no-desktop-surface command. */
|
||||
export function isDesktopSlashCommand(command: string): boolean {
|
||||
const spec = resolveDesktopCommand(command)
|
||||
|
||||
// Surface skill / quick commands (extensions the backend provides) alongside
|
||||
// the curated built-ins. Built-in aliases stay hidden so the popover isn't
|
||||
// cluttered with duplicates.
|
||||
if (isDesktopSlashExtensionCommand(normalized)) {
|
||||
return true
|
||||
if (spec) {
|
||||
return spec.surface.kind !== 'unavailable'
|
||||
}
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
|
||||
return isDesktopSlashExtensionCommand(command)
|
||||
}
|
||||
|
||||
/** Gates discovery in the popover/completions. */
|
||||
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)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const spec = SPEC_BY_NAME.get(normalized)
|
||||
|
||||
if (spec) {
|
||||
return spec.surface.kind !== 'unavailable' && !spec.hidden
|
||||
}
|
||||
|
||||
// Skill / quick commands the backend provides.
|
||||
return isDesktopSlashExtensionCommand(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for commands the desktop fulfils by opening the model picker overlay
|
||||
* (e.g. `/model`) rather than executing a slash command. The caller opens the
|
||||
* picker UI instead of printing the "uses the desktop model picker" notice.
|
||||
* True for commands the desktop fulfils by opening an overlay picker
|
||||
* (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker.
|
||||
*/
|
||||
export function isModelPickerCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean {
|
||||
const surface = resolveDesktopCommand(command)?.surface
|
||||
|
||||
return PICKER_OWNED_COMMANDS.has(canonical)
|
||||
if (surface?.kind !== 'picker') {
|
||||
return false
|
||||
}
|
||||
|
||||
return picker ? surface.picker === picker : true
|
||||
}
|
||||
|
||||
/** Back-compat shim for the model picker check. */
|
||||
export function isModelPickerCommand(command: string): boolean {
|
||||
return isPickerCommand(command, 'model')
|
||||
}
|
||||
|
||||
export function desktopSlashUnavailableMessage(command: string): string | null {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
const canonical = canonicalDesktopSlashCommand(command)
|
||||
const surface = SPEC_BY_NAME.get(canonical)?.surface
|
||||
|
||||
if (PICKER_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
|
||||
if (!surface) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
|
||||
if (surface.kind === 'unavailable') {
|
||||
return UNAVAILABLE_MESSAGE[surface.reason](canonical)
|
||||
}
|
||||
|
||||
if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only used from messaging platforms.`
|
||||
}
|
||||
|
||||
if (ADVANCED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
|
||||
}
|
||||
|
||||
if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only available in the terminal interface.`
|
||||
if (surface.kind === 'picker') {
|
||||
return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function desktopSlashDescription(command: string, fallback = ''): string {
|
||||
const canonical = canonicalDesktopSlashCommand(command)
|
||||
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback
|
||||
}
|
||||
|
||||
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
|
||||
/**
|
||||
* True when picking the bare command should expand to its inline argument
|
||||
* options (theme / personality / session / platform / toolset) rather than
|
||||
* committing immediately. Lets the popover act as a two-step picker.
|
||||
*/
|
||||
export function desktopSlashCommandTakesArgs(command: string): boolean {
|
||||
return resolveDesktopCommand(command)?.args ?? false
|
||||
}
|
||||
|
||||
export function desktopSkinSlashCompletions(
|
||||
@@ -274,13 +345,36 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
|
||||
?.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||
|
||||
// Recount skill commands from the filtered output so /help's footer reflects
|
||||
// what the user actually sees. Backend's skill_count includes commands the
|
||||
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
|
||||
// like "60 skill commands available" while only ~29 appear in the list.
|
||||
const filteredCommands = new Set<string>()
|
||||
|
||||
for (const section of categories ?? []) {
|
||||
for (const [command] of section.pairs) {
|
||||
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [command] of pairs ?? []) {
|
||||
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||
}
|
||||
|
||||
let skillCount = 0
|
||||
|
||||
for (const command of filteredCommands) {
|
||||
if (isDesktopSlashExtensionCommand(command)) {
|
||||
skillCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
|
||||
|
||||
return {
|
||||
...catalog,
|
||||
...(categories ? { categories } : {}),
|
||||
...(pairs ? { pairs } : {})
|
||||
...(pairs ? { pairs } : {}),
|
||||
...(hasSkillCount ? { skill_count: skillCount } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownHermesSlashCommand(command: string): boolean {
|
||||
return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user