From acce1a2452f8b85343db1b057c1d98717c421522 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 4 Jun 2026 14:01:15 -0400 Subject: [PATCH] feat(desktop): polish credentials settings and messaging env routing (#39217) * feat(desktop): polish credentials settings and messaging env routing Align Provider API Keys and Tools & Keys with Advanced ListRow inputs, add Tools & Keys sidebar subnav, move platform env vars to Messaging via channel_managed discovery, strip toolset emojis, and condense cron actions. Co-authored-by: Cursor * fix(desktop): align Messaging credential inputs with settings ListRow style Remove monospace inputs and use CREDENTIAL_CONTROL_CLASS + ListRow layout to match Provider API Keys and Tools & Keys. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../desktop/src/app/command-palette/index.tsx | 14 +- .../src/app/cron/cron-job-actions-menu.tsx | 108 +++++++ apps/desktop/src/app/cron/index.tsx | 53 ++-- apps/desktop/src/app/messaging/index.tsx | 126 +++++--- .../src/app/settings/credential-key-ui.tsx | 226 +++++++++++++++ .../src/app/settings/env-credentials.tsx | 162 +---------- .../src/app/settings/env-var-actions-menu.tsx | 130 +++++++++ apps/desktop/src/app/settings/helpers.test.ts | 22 +- apps/desktop/src/app/settings/helpers.ts | 7 + apps/desktop/src/app/settings/index.tsx | 30 +- .../src/app/settings/keys-settings.tsx | 158 +++------- .../src/app/settings/providers-settings.tsx | 269 +----------------- .../src/app/settings/toolset-config-panel.tsx | 46 ++- apps/desktop/src/app/skills/index.test.tsx | 11 + apps/desktop/src/app/skills/index.tsx | 13 +- hermes_cli/tools_config.py | 16 ++ hermes_cli/web_server.py | 67 ++++- tests/hermes_cli/test_tools_config.py | 8 + tests/hermes_cli/test_web_server.py | 24 +- web/src/pages/SkillsPage.tsx | 4 +- 20 files changed, 826 insertions(+), 668 deletions(-) create mode 100644 apps/desktop/src/app/cron/cron-job-actions-menu.tsx create mode 100644 apps/desktop/src/app/settings/credential-key-ui.tsx create mode 100644 apps/desktop/src/app/settings/env-var-actions-menu.tsx diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 5875f1eb3..7fd015efe 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -27,6 +27,7 @@ import { Palette, Plus, Settings, + Settings2, Sun, Users, Wrench, @@ -105,7 +106,18 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: strin tab: 'providers&pview=keys' }, { icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' }, - { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' }, + { + icon: KeyRound, + keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'], + label: 'Tools & Keys', + tab: 'keys&kview=tools' + }, + { + icon: Settings2, + keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'], + label: 'Tools & Keys settings', + tab: 'keys&kview=settings' + }, { icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' }, { icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' }, { icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' } diff --git a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx new file mode 100644 index 000000000..9e576c9ea --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx @@ -0,0 +1,108 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { triggerHaptic } from '@/lib/haptics' + +interface CronJobActions { + busy?: boolean + isPaused: boolean + title: string + onDelete: () => void + onEdit: () => void + onPauseResume: () => void + onTrigger: () => void +} + +interface CronJobActionsMenuProps + extends CronJobActions, Pick, 'align' | 'sideOffset'> { + children: React.ReactNode +} + +export function CronJobActionsMenu({ + align = 'end', + busy = false, + children, + isPaused, + onDelete, + onEdit, + onPauseResume, + onTrigger, + sideOffset = 6, + title +}: CronJobActionsMenuProps) { + return ( + + {children} + + { + triggerHaptic('selection') + onPauseResume() + }} + > + + {isPaused ? 'Resume' : 'Pause'} + + + { + triggerHaptic('selection') + onTrigger() + }} + > + + Trigger now + + + { + triggerHaptic('selection') + onEdit() + }} + > + + Edit + + + { + triggerHaptic('warning') + onDelete() + }} + variant="destructive" + > + + Delete + + + + ) +} + +interface CronJobActionsTriggerProps extends Omit, 'size' | 'variant'> { + title: string +} + +export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) { + return ( + + ) +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index fe5ef0d5c..40e06e237 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -27,12 +27,13 @@ import { updateCronJob } from '@/hermes' import { AlertTriangle, Clock } from '@/lib/icons' -import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { OverlayView } from '../overlays/overlay-view' +import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu' + const DEFAULT_DELIVER = 'local' const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ @@ -563,47 +564,27 @@ function CronJobRow({ )} -
- + - - - - - - - - - - - + event.stopPropagation()} + title={jobTitle(job)} + /> +
) } -function IconAction({ children, className, ...props }: Omit, 'size' | 'variant'>) { - return ( - - ) -} - function EmptyState({ actionLabel, description, diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 6a2dbdcee..b667c852b 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -18,6 +18,8 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' +import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui' +import { ListRow } from '../settings/primitives' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PageSearchShell } from '../page-search-shell' @@ -108,6 +110,47 @@ const FIELD_COPY: Record Required -
+
{requiredFields.length > 0 ? ( requiredFields.map(field => ( 0 && (
Recommended -
+
{optionalFields.map(field => ( {showAdvanced && ( -
+
{advancedFields.map(field => ( -
- - {field.is_set && Saved} -
-
- onEdit(field.key, event.target.value)} - placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} - type={field.is_password ? 'password' : 'text'} - value={edits[field.key] || ''} - /> - {field.url && ( - - )} - {field.is_set && ( - - )} -
- {copy.help &&

{copy.help}

} -
+ + onEdit(field.key, event.target.value)} + placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} + type={field.is_password ? 'password' : 'text'} + value={edits[field.key] || ''} + /> + {field.url && ( + + )} + {field.is_set && ( + + )} +
+ } + description={copy.help} + title={ + + + {field.is_set && Saved} + + } + /> ) } diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx new file mode 100644 index 000000000..4c916b3c0 --- /dev/null +++ b/apps/desktop/src/app/settings/credential-key-ui.tsx @@ -0,0 +1,226 @@ +import { type ChangeEvent, type KeyboardEvent } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ExternalLink, Loader2, Save } from '@/lib/icons' +import { cn } from '@/lib/utils' +import type { EnvVarInfo } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' +import { prettyName, withoutKey } from './helpers' +import { ListRow } from './primitives' +import type { EnvRowProps } from './types' + +export type KeyRowProps = Omit + +/** Matches Advanced / config field controls (ListRow + Input). */ +export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT) + +export const isKeyVar = (key: string, info: EnvVarInfo) => + info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) + +export const friendlyFieldLabel = (key: string, info: EnvVarInfo) => + info.description?.trim() || + key + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, c => c.toUpperCase()) + +export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string => + isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional' + +// A single credential field: a set key shows as a filled read-only input +// (redacted value) that edits in place on click. Save appears once typed; a set +// key also offers Remove, and Esc cancels without closing the overlay. +export function KeyField({ + info, + placeholder, + rowProps, + varKey +}: { + info: EnvVarInfo + placeholder?: string + rowProps: KeyRowProps + varKey: string +}) { + const { edits, onClear, onSave, saving, setEdits } = rowProps + const editing = edits[varKey] !== undefined + const draft = edits[varKey] ?? '' + const dirty = draft.trim().length > 0 + const busy = saving === varKey + const masked = info.redacted_value ?? '••••••••' + const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) + const cancel = () => setEdits(c => withoutKey(c, varKey)) + const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value })) + + const keydown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && dirty) { + void onSave(varKey) + } else if (e.key === 'Escape' && editing) { + e.preventDefault() + e.stopPropagation() + cancel() + } + } + + const editType = info.is_password ? 'password' : 'text' + + if (info.is_set && !editing) { + return ( + + ) + } + + return ( +
+
+ + {dirty && ( + + )} +
+ {editing && ( +
+ {info.is_set && ( + <> + + or + + )} + esc to cancel +
+ )} +
+ ) +} + +function CredentialDocsLink({ href }: { href: string }) { + return ( + e.stopPropagation()} + rel="noreferrer" + target="_blank" + > + Get a key + + + ) +} + +/** One credential row — same ListRow layout as Advanced config fields. */ +export function CredentialKeyCard({ + info, + label, + placeholder, + rowProps, + varKey +}: { + info: EnvVarInfo + label: string + placeholder: string + rowProps: KeyRowProps + varKey: string +}) { + const docsUrl = info.url?.trim() + const description = info.description?.trim() + + return ( + } + below={docsUrl ? : undefined} + description={description} + title={label} + /> + ) +} + +/** Provider API key group — primary + optional advanced fields as ListRows. */ +export function ProviderKeyRows({ + group, + rowProps +}: { + group: ProviderKeyRowGroup + rowProps: KeyRowProps +}) { + const docsUrl = group.docsUrl?.trim() + const description = group.description?.trim() + const docsBelow = docsUrl ? : undefined + + return ( + <> + + } + below={docsBelow} + description={description} + title={group.name} + /> + {group.advanced.map(([key, info]) => { + const fieldLabel = isKeyVar(key, info) ? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) : friendlyFieldLabel(key, info) + + return ( + + } + key={key} + title={fieldLabel} + /> + ) + })} + + ) +} + +export function credentialRowLabel(varKey: string, info: EnvVarInfo): string { + if (isKeyVar(varKey, info)) { + return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) + } + + return prettyName(varKey) +} + +export interface ProviderKeyRowGroup { + advanced: [string, EnvVarInfo][] + description?: string + docsUrl?: string + name: string + primary: [string, EnvVarInfo] +} diff --git a/apps/desktop/src/app/settings/env-credentials.tsx b/apps/desktop/src/app/settings/env-credentials.tsx index 5bcfd8f9b..f0ea858ad 100644 --- a/apps/desktop/src/app/settings/env-credentials.tsx +++ b/apps/desktop/src/app/settings/env-credentials.tsx @@ -1,15 +1,10 @@ import { useEffect, useState } from 'react' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' -import { Input } from '@/components/ui/input' import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' -import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons' -import { cn } from '@/lib/utils' +import { type IconComponent } from '@/lib/icons' import { notify, notifyError } from '@/store/notifications' import type { EnvVarInfo } from '@/types/hermes' -import { CONTROL_TEXT } from './constants' import { asText, includesQuery, redactedValue, withoutKey } from './helpers' import { Pill } from './primitives' import type { EnvRowProps } from './types' @@ -32,150 +27,6 @@ export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, ) } -function EnvActions({ - varKey, - info, - saving, - onEdit, - onClear, - onReveal, - isRevealed, - showReveal = true -}: EnvActionsProps) { - return ( -
- {info.url && ( - - )} - {info.is_set && showReveal && ( - - )} - - {info.is_set && ( - - )} -
- ) -} - -export function EnvVarRow({ - varKey, - info, - edits, - revealed, - saving, - setEdits, - onSave, - onClear, - onReveal, - compact = false -}: EnvRowProps) { - const isEditing = edits[varKey] !== undefined - const isRevealed = revealed[varKey] !== undefined - const value = isRevealed ? revealed[varKey] : info.redacted_value - const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) - - if (compact && !isEditing) { - return ( -
-
-
{varKey}
-
{info.description}
-
- -
- ) - } - - return ( -
-
-
-
- {varKey} - - {info.is_set && } - {info.is_set ? 'Set' : 'Not set'} - -
-

{info.description}

-
- -
- - {!isEditing && info.is_set && ( -
- {value || '---'} -
- )} - - {isEditing && ( -
- setEdits(c => ({ ...c, [varKey]: e.target.value }))} - placeholder={info.is_set ? 'Replace current value' : 'Enter value'} - type={info.is_password ? 'password' : 'text'} - value={edits[varKey]} - /> - - -
- )} -
- ) -} - export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) { return (
@@ -336,17 +187,6 @@ interface CategoryHeadingProps { title: string } -interface EnvActionsProps { - varKey: string - info: EnvVarInfo - saving: string | null - onEdit: () => void - onClear: (key: string) => void - onReveal: (key: string) => void - isRevealed: boolean - showReveal?: boolean -} - interface UseEnvCredentials { rowProps: Omit saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }> diff --git a/apps/desktop/src/app/settings/env-var-actions-menu.tsx b/apps/desktop/src/app/settings/env-var-actions-menu.tsx new file mode 100644 index 000000000..709d3aee9 --- /dev/null +++ b/apps/desktop/src/app/settings/env-var-actions-menu.tsx @@ -0,0 +1,130 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' + +interface EnvVarActionsMenuProps + extends Pick, 'align' | 'sideOffset'> { + children: React.ReactNode + clearDisabled?: boolean + docsUrl?: string | null + isRevealed?: boolean + isSet: boolean + label: string + onClear?: () => void + onEdit: () => void + onReveal?: () => void + showReveal?: boolean +} + +export function EnvVarActionsMenu({ + align = 'end', + children, + clearDisabled = false, + docsUrl, + isRevealed = false, + isSet, + label, + onClear, + onEdit, + onReveal, + showReveal = true, + sideOffset = 6 +}: EnvVarActionsMenuProps) { + const hasClear = isSet && onClear + const hasReveal = isSet && showReveal && onReveal + const hasDocs = Boolean(docsUrl?.trim()) + + return ( + + {children} + + {hasDocs && ( + { + event.preventDefault() + triggerHaptic('selection') + window.open(docsUrl!, '_blank', 'noopener,noreferrer') + }} + > + + Docs + + )} + + {hasReveal && ( + { + triggerHaptic('selection') + onReveal() + }} + > + {isRevealed ? : } + {isRevealed ? 'Hide value' : 'Reveal value'} + + )} + + { + triggerHaptic('selection') + onEdit() + }} + > + + {isSet ? 'Replace' : 'Set'} + + + {hasClear && ( + <> + + { + triggerHaptic('warning') + onClear() + }} + variant="destructive" + > + + Clear + + + )} + + + ) +} + +interface EnvVarActionsTriggerProps extends Omit, 'size' | 'variant'> { + label: string +} + +export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) { + return ( + + ) +} diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 097b9cfed..ff793e4a0 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' -import { getNested, providerGroup, setNested } from './helpers' +import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' describe('settings helpers', () => { it('reads and writes nested config paths', () => { @@ -21,6 +21,26 @@ describe('settings helpers', () => { expect(({} as Record).polluted).toBeUndefined() }) + describe('stripToolsetLabel', () => { + it('removes leading emoji prefixes from registry labels', () => { + expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs') + expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution') + expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions') + expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation') + expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation') + }) + + it('leaves plain titles unchanged', () => { + expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes') + }) + }) + + describe('toolsetDisplayLabel', () => { + it('strips emoji from toolset rows', () => { + expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs') + }) + }) + describe('providerGroup', () => { it('maps a provider env var to its labeled group', () => { expect(providerGroup('XAI_API_KEY')).toBe('xAI') diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 1c4f61f9a..d08bc5a60 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -8,6 +8,13 @@ export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase(). export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */ +export const stripToolsetLabel = (label: string): string => + label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label + +export const toolsetDisplayLabel = (toolset: Pick): string => + stripToolsetLabel(asText(toolset.label || toolset.name)) + export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : []) export const withoutKey = (record: Record, key: string) => { diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index a2580723c..24b989ab8 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' -import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons' +import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -16,7 +16,7 @@ import { AppearanceSettings } from './appearance-settings' import { ConfigSettings } from './config-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' -import { KeysSettings } from './keys-settings' +import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' import { McpSettings } from './mcp-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' @@ -37,12 +37,18 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang // Providers subnav (Accounts vs API keys) lives in its own param so each // sub-view is deep-linkable and survives a refresh. const [providerView, setProviderView] = useRouteEnumParam('pview', PROVIDER_VIEWS, 'accounts') + const [keysView, setKeysView] = useRouteEnumParam('kview', KEYS_VIEWS, 'tools') const openProviderView = (view: ProviderView) => { setActiveView('providers') setProviderView(view) } + const openKeysView = (view: KeysView) => { + setActiveView('keys') + setKeysView(view) + } + const importInputRef = useRef(null) const exportConfig = async () => { @@ -129,6 +135,24 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang label="Tools & Keys" onClick={() => setActiveView('keys')} /> + {activeView === 'keys' && ( +
+ openKeysView('tools')} + /> + openKeysView('settings')} + /> +
+ )} ) : activeView === 'keys' ? ( - + ) : activeView === 'mcp' ? ( ) : ( diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index a09950f1c..9918451f3 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -1,155 +1,75 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' -import { Settings2, Wrench } from '@/lib/icons' -import { cn } from '@/lib/utils' import type { EnvVarInfo } from '@/types/hermes' -import { EnvVarRow, useEnvCredentials } from './env-credentials' +import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui' +import { useEnvCredentials } from './env-credentials' import { asText } from './helpers' import { LoadingState, SettingsContent } from './primitives' +// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx). +export const KEYS_VIEWS = ['tools', 'settings'] as const + +export type KeysView = (typeof KEYS_VIEWS)[number] + // Providers live on their own page; messaging-platform credentials live on the // dedicated Messaging page (and are hidden here via `channel_managed`). This // view covers tool API keys plus server/setting env vars (API server, webhook, -// gateway), which fold into the Settings tab. -const KEY_TABS = [ - { icon: Wrench, id: 'tool', label: 'Tools' }, - { icon: Settings2, id: 'setting', label: 'Settings' } -] as const +// gateway), which fold into the Settings subnav. -type KeyCategoryId = (typeof KEY_TABS)[number]['id'] - -const CATEGORY_LABELS: Record = { - setting: 'Settings', - tool: 'Tools' +// Backend categories that surface under each subnav. Platform credentials use the +// `messaging` category but are flagged ``channel_managed`` and configured on +// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY) +// appear here alongside ``setting``. +const VIEW_CATEGORIES: Record = { + settings: ['setting', 'messaging'], + tools: ['tool'] } -// Backend categories that surface under each tab. Server/gateway vars carry the -// `messaging` category server-side but belong with general settings here, since -// the platform-credential half of `messaging` is owned by the Messaging page. -const TAB_CATEGORIES: Record = { - setting: ['setting', 'messaging'], - tool: ['tool'] -} - -function tabForCategory(category: string): KeyCategoryId | null { - for (const tab of KEY_TABS) { - if (TAB_CATEGORIES[tab.id].includes(category)) { - return tab.id - } - } - - return null -} - -function CategoryTabs({ - active, - counts, - onSelect -}: { - active: KeyCategoryId - counts: Record - onSelect: (id: KeyCategoryId) => void -}) { - return ( -
- {KEY_TABS.map(tab => { - const isActive = active === tab.id - const count = counts[tab.id] - - return ( - - ) - })} -
- ) -} - -export function KeysSettings() { +export function KeysSettings({ view }: KeysSettingsProps) { const { rowProps, vars } = useEnvCredentials() - const [activeCategory, setActiveCategory] = useState('tool') const groups = useMemo(() => { if (!vars) { return [] } - return KEY_TABS.map(t => t.id).flatMap(tab => { - const cats = TAB_CATEGORIES[tab] + return KEYS_VIEWS.flatMap(v => { + const cats = VIEW_CATEGORIES[v] const entries = Object.entries(vars) .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category))) .sort(([a], [b]) => a.localeCompare(b)) - return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }] + return entries.length === 0 ? [] : [{ category: v, entries }] }) }, [vars]) - // Tab badge counts reflect how many keys are set per tab. Channel-managed - // credentials are owned by the Messaging page and excluded here. - const categoryCounts = useMemo>(() => { - const counts: Record = { setting: 0, tool: 0 } - - if (!vars) { - return counts - } - - for (const info of Object.values(vars)) { - if (!info.is_set || info.channel_managed) { - continue - } - - const tab = tabForCategory(asText(info.category)) - - if (tab) { - counts[tab] += 1 - } - } - - return counts - }, [vars]) - if (!vars) { return } - const visible = groups.filter(g => g.category === activeCategory) + const visible = groups.filter(g => g.category === view) return ( - - {visible.map(group => ( -
-
- {group.entries.map(([key, info]: [string, EnvVarInfo]) => ( - - ))} -
-
+
+ {group.entries.map(([key, info]: [string, EnvVarInfo]) => { + const label = credentialRowLabel(key, info) + + return ( + + ) + })} +
))} {visible.length === 0 && ( @@ -160,3 +80,7 @@ export function KeysSettings() {
) } + +interface KeysSettingsProps { + view: KeysView +} diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index a29f5440a..759d61a44 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { FEATURED_ID, @@ -9,43 +9,25 @@ import { sortProviders } from '@/components/desktop-onboarding-overlay' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { listOAuthProviders } from '@/hermes' -import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons' +import { ChevronDown, KeyRound } from '@/lib/icons' import { cn } from '@/lib/utils' import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' +import { isKeyVar, ProviderKeyRows } from './credential-key-ui' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' -import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers' +import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' -import type { EnvRowProps } from './types' // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys'] as const export type ProviderView = (typeof PROVIDER_VIEWS)[number] -const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) - -const friendlyFieldLabel = (key: string, info: EnvVarInfo) => - info.description?.trim() || - key - .replace(/_/g, ' ') - .toLowerCase() - .replace(/\b\w/g, c => c.toUpperCase()) - -// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not -// keys — so don't reuse the "Paste key" placeholder that makes them read as a -// duplicate key input. URL-ish vars get a URL hint; everything else stays optional. -const advancedPlaceholder = (key: string, info: EnvVarInfo): string => - isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional' - -// Group the env catalog by provider so the keys view can render one collapsible -// row per vendor: a primary key field inline, with any secondary / advanced vars -// (base URL overrides, alt tokens) revealed when the row is focused/expanded. -// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g. -// Nous Portal's lone base-URL override) and the "Other" bucket are skipped. +// Group the env catalog by provider — one ListRow per vendor plus optional +// advanced overrides (base URL, region, etc.). Groups without a key field and +// the "Other" bucket are skipped. function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] { const buckets = new Map() @@ -94,228 +76,6 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) } -// A single credential field: a set key shows as a filled read-only input -// (redacted value) that edits in place on click. Save appears once typed; a set -// key also offers Remove, and Esc cancels without closing the overlay. -function KeyField({ - compact = false, - info, - label, - placeholder, - rowProps, - varKey -}: { - compact?: boolean - info: EnvVarInfo - label?: string - placeholder?: string - rowProps: KeyRowProps - varKey: string -}) { - const { edits, onClear, onSave, saving, setEdits } = rowProps - const editing = edits[varKey] !== undefined - const draft = edits[varKey] ?? '' - const dirty = draft.trim().length > 0 - const busy = saving === varKey - const masked = info.redacted_value ?? '••••••••' - const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) - const cancel = () => setEdits(c => withoutKey(c, varKey)) - const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value })) - - // Enter saves; Esc cancels in place without bubbling to the overlay's window - // Escape listener (which would otherwise close the whole settings panel). - const keydown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && dirty) { - void onSave(varKey) - } else if (e.key === 'Escape' && editing) { - e.preventDefault() - e.stopPropagation() - cancel() - } - } - - // Advanced overrides render quieter (xs) than the primary key field so the key - // stays the visual anchor. Padding-driven sizing — no fixed heights. - const inputSize = compact ? 'xs' : 'sm' - const editType = info.is_password ? 'password' : 'text' - - // A set value reads as a single filled, read-only field (showing the redacted - // value). Clicking it drops into edit mode in place — no Replace/Cancel chrome. - const control = - info.is_set && !editing ? ( - - ) : ( -
-
- - {dirty && ( - - )} -
- {editing && ( -
- {info.is_set && ( - <> - - or - - )} - esc to cancel -
- )} -
- ) - - // Standard stacked form field: small muted label above, input below. Same shape - // for the primary key and every advanced override — just smaller when compact. - // Empty advanced inputs (not labels) fade back, brightening on hover/focus/set. - const dim = compact && !info.is_set - - return ( -
- {label && ( - - )} - {dim ? ( -
{control}
- ) : ( - control - )} -
- ) -} - -function ProviderKeyCard({ - expanded, - group, - onExpand, - onToggle, - rowProps -}: { - expanded: boolean - group: ProviderKeyGroup - onExpand: () => void - onToggle: () => void - rowProps: KeyRowProps -}) { - // Expandable when there's anything to reveal — advanced overrides and/or a - // "Get a key" docs link (which lives at the bottom of the expanded panel). - const expandable = group.advanced.length > 0 || Boolean(group.docsUrl) - - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onToggle() - } - } - : undefined - } - role={expandable ? 'button' : undefined} - tabIndex={expandable ? 0 : undefined} - > -
-
- - {group.name} - {expandable && ( - - )} -
-
e.stopPropagation()} - onFocus={() => { - if (expandable && !expanded) { - onExpand() - } - }} - > - -
-
- {expandable && expanded && ( -
e.stopPropagation()}> - {group.advanced.map(([key, info]) => ( - - ))} - {group.docsUrl && ( - e.stopPropagation()} - rel="noreferrer" - target="_blank" - > - Get a key - - - )} -
- )} -
- ) -} - // Deliberately a near-1:1 replica of the first-run onboarding picker // (`Picker` in desktop-onboarding-overlay): same recommended card, same // provider rows, same "Other providers" disclosure, same OpenRouter quick-key @@ -405,8 +165,6 @@ function NoProviderKeys() { export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) - // Single-open accordion for the per-provider "advanced options" panels. - const [openProvider, setOpenProvider] = useState(null) // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // re-read connection state when the user finishes (or dismisses) a sign-in // they launched from this page — otherwise the cards keep their stale status. @@ -450,16 +208,9 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return ( {keyGroups.length > 0 ? ( -
+
{keyGroups.map(group => ( - setOpenProvider(group.name)} - onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} - rowProps={rowProps} - /> + ))}
) : ( @@ -476,8 +227,6 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps ) } -type KeyRowProps = Omit - interface ProviderKeyGroup { advanced: [string, EnvVarInfo][] description?: string diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 7ec34e5de..d766f9267 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -4,11 +4,12 @@ import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes' -import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons' +import { Check, Loader2, Save } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes' +import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu' import { Pill } from './primitives' interface ToolsetConfigPanelProps { @@ -108,35 +109,20 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {

{envVar.prompt}

)}
-
- {envVar.url && ( - - )} - {isSet && ( - - )} - - {isSet && ( - - )} -
+ {!editing && ( + void handleClear()} + onEdit={() => setEditing(true)} + onReveal={() => void handleReveal()} + > + event.stopPropagation()} /> + + )}
{isSet && revealed !== null && ( diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index 1243cc1d8..9f195f786 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -74,6 +74,17 @@ describe('SkillsView toolset management', () => { await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false)) }) + it('renders toolset titles without leading emoji', async () => { + getToolsets.mockResolvedValue([ + toolset({ name: 'cronjob', label: '⏰ Cron Jobs', description: 'cron tools' }) + ]) + + await renderSkills() + + expect(screen.getByText('Cron Jobs')).toBeTruthy() + expect(screen.queryByText(/⏰/)).toBeNull() + }) + it('keeps the configured pill alongside the switch', async () => { await renderSkills() diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 74cbc53d7..7661efef9 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -14,7 +14,7 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PAGE_INSET_X } from '../layout-constants' import { PageSearchShell } from '../page-search-shell' -import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers' +import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers' import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' @@ -52,14 +52,17 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[] return true } + const label = toolsetDisplayLabel(toolset) + return ( includesQuery(toolset.name, q) || + includesQuery(label, q) || includesQuery(toolset.label, q) || includesQuery(toolset.description, q) || toolNames(toolset).some(name => includesQuery(name, q)) ) }) - .sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name))) + .sort((a, b) => toolsetDisplayLabel(a).localeCompare(toolsetDisplayLabel(b))) } interface SkillsViewProps extends React.ComponentProps<'section'> { @@ -167,10 +170,10 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p notify({ kind: 'success', title: enabled ? 'Toolset enabled' : 'Toolset disabled', - message: `${asText(toolset.label || toolset.name)} applies to new sessions.` + message: `${toolsetDisplayLabel(toolset)} applies to new sessions.` }) } catch (err) { - notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`) + notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`) } finally { setSavingToolset(null) } @@ -264,7 +267,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
{visibleToolsets.map(toolset => { const tools = toolNames(toolset) - const label = asText(toolset.label || toolset.name) + const label = toolsetDisplayLabel(toolset) const expanded = expandedToolset === toolset.name return ( diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 57b1adb75..50f1f9f86 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -82,6 +82,22 @@ CONFIGURABLE_TOOLSETS = [ ("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"), ] + +def gui_toolset_label(label: str) -> str: + """Strip leading emoji/icons from toolset titles for GUI surfaces. + + Registry labels use `` ``; plugin toolsets prefix with ``🔌``. + CLI/TUI keeps the raw ``label`` — only HTTP APIs call this helper. + """ + text = (label or "").strip() + if not text: + return text + parts = text.split(None, 1) + if len(parts) == 2 and parts[0] and not any(ch.isascii() and ch.isalnum() for ch in parts[0]): + return parts[1].strip() + return text + + # Toolsets that are OFF by default for new installs. # They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), # but the setup checklist won't pre-select them for first-time users. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fcd97cc76..233245de3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2833,18 +2833,66 @@ def _channel_managed_env_keys() -> frozenset[str]: return frozenset() +# Cross-cutting gateway / relay knobs stay on the Keys → Settings tab even though +# they use the ``messaging`` category in OPTIONAL_ENV_VARS. Platform-scoped vars +# (``DISCORD_*``, ``MATRIX_*``, …) are owned by the Messaging UI instead. +_MESSAGING_KEYS_PAGE_KEYS = frozenset({ + "GATEWAY_ALLOW_ALL_USERS", + "GATEWAY_PROXY_KEY", + "GATEWAY_PROXY_URL", +}) + + +def _platform_env_prefixes(platform_id: str) -> tuple[str, ...]: + """Env-var prefixes owned by a messaging platform card.""" + aliases: dict[str, tuple[str, ...]] = { + "email": ("EMAIL_",), + "homeassistant": ("HASS_",), + "qqbot": ("QQ_", "QQBOT_"), + "sms": ("TWILIO_",), + "wecom": ("WECOM_BOT_", "WECOM_SECRET"), + "wecom_callback": ("WECOM_CALLBACK_",), + } + if platform_id in aliases: + return aliases[platform_id] + return (platform_id.upper().replace("-", "_") + "_",) + + +def _discover_platform_env_vars(platform_id: str) -> tuple[str, ...]: + """All messaging-category env vars for a platform (override + plugin + prefix).""" + prefixes = _platform_env_prefixes(platform_id) + keys: list[str] = [] + for name, info in OPTIONAL_ENV_VARS.items(): + if info.get("category") != "messaging": + continue + if name in _MESSAGING_KEYS_PAGE_KEYS: + continue + if not any(name.startswith(prefix) for prefix in prefixes): + continue + keys.append(name) + return tuple(sorted(set(keys))) + + +def _merge_platform_env_vars( + platform_id: str, + override: dict[str, Any], + plugin_entry: Any | None, +) -> tuple[str, ...]: + """Canonical env-var list for a messaging platform card.""" + discovered = _discover_platform_env_vars(platform_id) + if "env_vars" in override: + return tuple(dict.fromkeys((*override["env_vars"], *discovered))) + if plugin_entry is not None and plugin_entry.required_env: + return tuple(dict.fromkeys((*tuple(plugin_entry.required_env), *discovered))) + return discovered + + def _build_catalog_entry( platform_id: str, plugin_entry: Any | None = None ) -> dict[str, Any]: override = _PLATFORM_OVERRIDES.get(platform_id, {}) - if "env_vars" in override: - env_vars: tuple[str, ...] = tuple(override["env_vars"]) - elif plugin_entry is not None and plugin_entry.required_env: - env_vars = tuple(plugin_entry.required_env) - else: - prefix = platform_id.upper() + "_" - env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix)) + env_vars = _merge_platform_env_vars(platform_id, override, plugin_entry) if "required_env" in override: required_env = tuple(override["required_env"]) @@ -6663,6 +6711,7 @@ async def get_toolsets(): _get_effective_configurable_toolsets, _get_platform_tools, _toolset_has_keys, + gui_toolset_label, ) from toolsets import resolve_toolset @@ -6680,7 +6729,9 @@ async def get_toolsets(): tools = [] is_enabled = name in enabled_toolsets result.append({ - "name": name, "label": label, "description": desc, + "name": name, + "label": gui_toolset_label(label), + "description": desc, "enabled": is_enabled, "available": is_enabled, "configured": _toolset_has_keys(name, config), diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 008ffe1fd..5b24d2b6e 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -21,6 +21,7 @@ from hermes_cli.tools_config import ( _toolset_needs_configuration_prompt, CONFIGURABLE_TOOLSETS, TOOL_CATEGORIES, + gui_toolset_label, _visible_providers, tools_command, ) @@ -79,6 +80,13 @@ def test_get_platform_tools_uses_default_when_platform_not_configured(): assert enabled.isdisjoint(_DEFAULT_OFF_TOOLSETS) +def test_gui_toolset_label_strips_leading_emoji(): + assert gui_toolset_label("🔍 Web Search & Scraping") == "Web Search & Scraping" + assert gui_toolset_label("👁️ Vision / Image Analysis") == "Vision / Image Analysis" + assert gui_toolset_label("🔌 My Plugin") == "My Plugin" + assert gui_toolset_label("Terminal & Processes") == "Terminal & Processes" + + def test_configurable_toolsets_include_messaging(): assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1b898526d..592d62c44 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -791,6 +791,24 @@ class TestWebServerEndpoints: for key, info in data.items(): assert info["channel_managed"] is (key in channel_keys) + def test_platform_scoped_messaging_env_vars_are_channel_managed(self): + from hermes_cli.web_server import ( + _MESSAGING_KEYS_PAGE_KEYS, + _build_catalog_entry, + _channel_managed_env_keys, + ) + + discord = _build_catalog_entry("discord") + assert "DISCORD_HOME_CHANNEL" in discord["env_vars"] + assert "DISCORD_ALLOW_ALL_USERS" in discord["env_vars"] + + managed = _channel_managed_env_keys() + assert "DISCORD_HOME_CHANNEL" in managed + assert "BLUEBUBBLES_ALLOW_ALL_USERS" in managed + assert "MATTERMOST_ALLOW_ALL_USERS" in managed + assert "GATEWAY_PROXY_URL" not in managed + assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS + def test_reveal_env_var(self, tmp_path): """POST /api/env/reveal should return the real unredacted value.""" from hermes_cli.config import save_env_value @@ -1919,7 +1937,7 @@ class TestNewEndpoints: assert resp.json() == [ { "name": "web", - "label": "🔍 Web Search & Scraping", + "label": "Web Search & Scraping", "description": "web_search, web_extract", "enabled": True, "available": True, @@ -1928,7 +1946,7 @@ class TestNewEndpoints: }, { "name": "skills", - "label": "📚 Skills", + "label": "Skills", "description": "list, view, manage", "enabled": True, "available": True, @@ -1937,7 +1955,7 @@ class TestNewEndpoints: }, { "name": "memory", - "label": "💾 Memory", + "label": "Memory", "description": "persistent memory across sessions", "enabled": False, "available": False, diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index d7a9ef669..e26c807fe 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -432,9 +432,7 @@ export default function SkillsPage() { <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> {filteredToolsets.map((ts) => { const TsIcon = toolsetIcon(ts.name); - const labelText = - ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || - ts.name; + const labelText = ts.label.trim() || ts.name; return ( <Card key={ts.name} className="relative rounded-none">