* 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>
287 lines
8.3 KiB
TypeScript
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)
|
|
}
|