Files
hermes-agent/apps/desktop/src/lib/desktop-slash-commands.ts
T
TekniumandBrooklyn Nicholson e8c837c921 feat(desktop): surface every provider + models from hermes model in the GUI menus (#40563)
* feat(desktop): surface every provider + models from `hermes model` in the GUI

The desktop GUI's model/provider choices were starved relative to the
`hermes model` CLI. Onboarding listed ~8 providers, Settings → Model only
showed authenticated ones, because the global `/api/model/options` endpoint
called build_models_payload() without the full-universe flags the TUI's
model.options JSON-RPC already used.

- web_server.py: `/api/model/options` now passes include_unconfigured +
  picker_hints + canonical_order (matching the TUI handler), so every GUI
  surface fed by it sees all 37 canonical providers with auth hints.
- Settings → Model: provider dropdown lists every provider; picking an
  unconfigured api_key provider shows an inline 'paste key → Activate' flow
  (auto-selects the recommended default); OAuth/external route to onboarding.
- Onboarding: the API-key form is now driven by the full provider catalog
  (curated five first, then the rest), not a hand-maintained list of five.
- types/hermes.ts: ModelOptionProvider gains authenticated/auth_type/key_env.
- Tests: model-settings covers the full-universe list + inline activation;
  fixed a pre-existing stale assertion (nous / hermes-4 was never rendered).

* feat(desktop): /model in GUI chat opens the model picker instead of a dead-end notice

Typing /model in a desktop chat session printed "/model uses the desktop
model picker instead of a slash command" and did nothing — it never opened
the picker. (The slash worker can't render the prompt_toolkit modal /model
opens in the CLI, so the desktop just showed the unavailable-notice.)

- use-prompt-actions.ts: intercept /model client-side. No args → open the
  desktop model picker overlay (setModelPickerOpen) — the same full
  provider+model picker as the status-bar button. With args (/model <name>
  [--provider ...]) → run the switch directly via slash.exec so power users
  can still type it.
- desktop-slash-commands.ts: export isModelPickerCommand() so the hook can
  detect picker-owned commands without duplicating the PICKER_OWNED_COMMANDS set.
- Test: covers isModelPickerCommand for /model (+ args) vs non-picker commands.

* fix(desktop): make onboarding provider lists scrollable + clean up card styling

The full-catalog onboarding picker could overflow the modal with no way to
scroll — the OAuth provider list and the api-key grid both grew past the
viewport, hiding the key input and the bottom action row (overflow-hidden card,
no scroll container).

- Scope a `max-h-[60dvh] overflow-y-auto` region to just the provider list /
  api-key card grid; the "other providers" disclosure, key input, and action
  row stay pinned and reachable.
- Inner `p-1` so card borders / focus rings aren't clipped by the scroll viewport.
- Flatter card styling: drop the persistent border, the redundant selected-state
  checkmark, and the modal shadow — selection now reads from the ring alone (the
  muted "already configured" check stays).
- Remove the " — set up" suffix from the Settings → Model provider dropdown; the
  inline setup flow already signals unconfigured providers.

* fix(desktop): identify api-key onboarding cards by env var, not id

Selecting "Google Gemini" also highlighted "Google AI Studio": the curated
catalog and the backend-derived providers can collide on `id` (a provider slug
can equal a curated id like `gemini`), so `option.id === o.id` matched two
cards at once. Key selection (and the React key + snap-back effect) on `envKey`
instead, which the catalog dedups and is therefore unique per card.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-06 16:31:34 +00:00

287 lines
8.3 KiB
TypeScript

export interface CommandsCatalogSection {
name: string
pairs: [string, string][]
}
export interface CommandsCatalogLike {
categories?: CommandsCatalogSection[]
pairs?: [string, string][]
skill_count?: number
warning?: string
}
export interface DesktopSlashCompletion {
display: string
meta: string
text: string
}
export interface DesktopThemeCommandOption {
description: string
label: string
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
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
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']
])
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
const PICKER_OWNED_COMMANDS = new Set(['/model'])
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 MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
const ADVANCED_COMMANDS = new Set([
'/curator',
'/fast',
'/insights',
'/kanban',
'/personality',
'/reasoning',
'/reload-mcp',
'/reload-skills',
'/voice'
])
const BLOCKED_COMMANDS = new Set([
...PICKER_OWNED_COMMANDS,
...TERMINAL_ONLY_COMMANDS,
...MESSAGING_ONLY_COMMANDS,
...SETTINGS_OWNED_COMMANDS,
...ADVANCED_COMMANDS
])
function normalizeCommand(command: string): string {
const trimmed = command.trim()
const base = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`).split(/\s+/, 1)[0]?.toLowerCase() || ''
return base
}
export function canonicalDesktopSlashCommand(command: string): string {
const normalized = normalizeCommand(command)
return DESKTOP_ALIASES.get(normalized) || normalized
}
export function isDesktopSlashCommand(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)
}
/**
* 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.
*/
export function isDesktopSlashExtensionCommand(command: string): boolean {
const normalized = normalizeCommand(command)
if (!normalized || normalized === '/') {
return false
}
return !isKnownHermesSlashCommand(normalized)
}
export function isDesktopSlashSuggestion(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
// 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
}
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(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.
*/
export function isModelPickerCommand(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
return PICKER_OWNED_COMMANDS.has(canonical)
}
export function desktopSlashUnavailableMessage(command: string): string | null {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
if (PICKER_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
}
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
}
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.`
}
return null
}
export function desktopSlashDescription(command: string, fallback = ''): string {
const canonical = canonicalDesktopSlashCommand(command)
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
}
export function desktopSkinSlashCompletions(
themes: DesktopThemeCommandOption[],
activeThemeName: string,
argPrefix: string
): DesktopSlashCompletion[] {
const prefix = argPrefix.trim().toLowerCase()
const commands: DesktopSlashCompletion[] = [
{
text: '/skin list',
display: '/skin list',
meta: 'Show available desktop themes'
},
{
text: '/skin next',
display: '/skin next',
meta: 'Cycle to the next desktop theme'
},
...themes.map(theme => ({
text: `/skin ${theme.name}`,
display: `/skin ${theme.name}`,
meta: `${theme.label}${theme.name === activeThemeName ? ' (current)' : ''} - ${theme.description}`
}))
]
if (!prefix) {
return commands
}
return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix))
}
export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike {
const categories = catalog.categories
?.map(section => ({
...section,
pairs: section.pairs
.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
}))
.filter(section => section.pairs.length > 0)
const pairs = catalog.pairs
?.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
return {
...catalog,
...(categories ? { categories } : {}),
...(pairs ? { pairs } : {})
}
}
function isKnownHermesSlashCommand(command: string): boolean {
return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command)
}