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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett
2026-06-04 14:01:15 -04:00
committed by GitHub
parent a3fb48b2ce
commit acce1a2452
20 changed files with 826 additions and 668 deletions

View File

@ -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' }

View File

@ -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<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function CronJobActionsMenu({
align = 'end',
busy = false,
children,
isPaused,
onDelete,
onEdit,
onPauseResume,
onTrigger,
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${title}`}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onPauseResume()
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onTrigger()
}}
>
<Codicon name="zap" size="0.875rem" />
<span>Trigger now</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('warning')
onDelete()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
title: string
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${title}`}
className={className}
size="icon-sm"
title="Cron job actions"
variant="ghost"
{...props}
>
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@ -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({
)}
</button>
<div className="flex shrink-0 items-center gap-0.5">
<IconAction
aria-label={isPaused ? 'Resume cron' : 'Pause cron'}
disabled={busy}
onClick={onPauseResume}
title={isPaused ? 'Resume' : 'Pause'}
<div className="flex shrink-0 items-center">
<CronJobActionsMenu
busy={busy}
isPaused={isPaused}
onDelete={onDelete}
onEdit={onEdit}
onPauseResume={onPauseResume}
onTrigger={onTrigger}
title={jobTitle(job)}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
</IconAction>
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
<Codicon name="zap" size="0.875rem" />
</IconAction>
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
<Codicon name="edit" size="0.875rem" />
</IconAction>
<IconAction
aria-label="Delete cron"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
>
<Codicon name="trash" size="0.875rem" />
</IconAction>
<CronJobActionsTrigger
className="text-muted-foreground hover:text-foreground"
onClick={event => event.stopPropagation()}
title={jobTitle(job)}
/>
</CronJobActionsMenu>
</div>
</div>
)
}
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
return (
<Button
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
variant="ghost"
{...props}
>
{children}
</Button>
)
}
function EmptyState({
actionLabel,
description,

View File

@ -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<string, { advanced?: boolean; help?: string; label: str
help: 'first, all, or off.',
advanced: true
},
DISCORD_ALLOW_ALL_USERS: {
label: 'Allow all Discord users',
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
advanced: true
},
DISCORD_HOME_CHANNEL: {
label: 'Home channel ID',
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
advanced: true
},
DISCORD_HOME_CHANNEL_NAME: {
label: 'Home channel name',
help: 'Display name for the home channel in logs and status output.',
advanced: true
},
BLUEBUBBLES_ALLOW_ALL_USERS: {
label: 'Allow all iMessage users',
help: 'When true, skip the BlueBubbles allowlist.',
advanced: true
},
MATTERMOST_ALLOW_ALL_USERS: {
label: 'Allow all Mattermost users',
advanced: true
},
MATTERMOST_HOME_CHANNEL: {
label: 'Home channel',
advanced: true
},
QQ_ALLOW_ALL_USERS: {
label: 'Allow all QQ users',
advanced: true
},
QQBOT_HOME_CHANNEL: {
label: 'QQ home channel',
help: 'Default channel or group for cron delivery.',
advanced: true
},
QQBOT_HOME_CHANNEL_NAME: {
label: 'QQ home channel name',
advanced: true
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
@ -497,7 +540,7 @@ function PlatformDetail({
<section>
<SectionTitle>Required</SectionTitle>
<div className="mt-3 space-y-4">
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
@ -520,7 +563,7 @@ function PlatformDetail({
{optionalFields.length > 0 && (
<section>
<SectionTitle>Recommended</SectionTitle>
<div className="mt-3 space-y-4">
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
edits={edits}
@ -546,7 +589,7 @@ function PlatformDetail({
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 space-y-4">
<div className="mt-3 grid gap-1">
{advancedFields.map(field => (
<MessagingField
edits={edits}
@ -640,45 +683,48 @@ function MessagingField({
saving: string | null
}) {
const copy = fieldCopy(field)
const fieldId = `messaging-field-${field.key}`
return (
<div className="space-y-1.5">
<div className="flex flex-wrap items-baseline gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}>
{copy.label}
</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</div>
<div className="flex items-center gap-2">
<Input
className="font-mono"
id={`messaging-field-${field.key}`}
onChange={event => 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 && (
<Button asChild size="icon-sm" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
size="icon-sm"
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
</div>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => 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 && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
}
description={copy.help}
title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</span>
}
/>
)
}

View File

@ -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<EnvRowProps, 'info' | 'varKey'>
/** 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<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
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 (
<Input
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
onFocus={startEdit}
readOnly
value={masked}
/>
)
}
return (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
type={editType}
value={draft}
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
}
function CredentialDocsLink({ href }: { href: string }) {
return (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={href}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)
}
/** 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 (
<ListRow
action={<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />}
below={docsUrl ? <CredentialDocsLink href={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 ? <CredentialDocsLink href={docsUrl} /> : undefined
return (
<>
<ListRow
action={
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
}
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 (
<ListRow
action={
<KeyField
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
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]
}

View File

@ -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 (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="outline">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
}
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 (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
}
return (
<div className="grid gap-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/20 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => 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]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<Codicon name="close" />
Cancel
</Button>
</div>
)}
</div>
)
}
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
return (
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
@ -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<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>

View File

@ -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<React.ComponentProps<typeof DropdownMenuContent>, '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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${label}`}
className="w-44"
sideOffset={sideOffset}
>
{hasDocs && (
<DropdownMenuItem
onSelect={event => {
event.preventDefault()
triggerHaptic('selection')
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
}}
>
<ExternalLink className="size-3.5" />
<span>Docs</span>
</DropdownMenuItem>
)}
{hasReveal && (
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onReveal()
}}
>
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
<span>{isRevealed ? 'Hide value' : 'Reveal value'}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{isSet ? 'Replace' : 'Set'}</span>
</DropdownMenuItem>
{hasClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={clearDisabled}
onSelect={() => {
triggerHaptic('warning')
onClear()
}}
variant="destructive"
>
<Trash2 className="size-3.5" />
<span>Clear</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface EnvVarActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
label: string
}
export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${label}`}
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
title="Credential actions"
variant="ghost"
{...props}
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@ -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<string, unknown>).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')

View File

@ -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<ToolsetInfo, 'label' | 'name'>): 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 = <T>(record: Record<string, T>, key: string) => {

View File

@ -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<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
const openProviderView = (view: ProviderView) => {
setActiveView('providers')
setProviderView(view)
}
const openKeysView = (view: KeysView) => {
setActiveView('keys')
setKeysView(view)
}
const importInputRef = useRef<HTMLInputElement | null>(null)
const exportConfig = async () => {
@ -129,6 +135,24 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
label="Tools & Keys"
onClick={() => setActiveView('keys')}
/>
{activeView === 'keys' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={keysView === 'tools'}
icon={Wrench}
label="Tools"
nested
onClick={() => openKeysView('tools')}
/>
<OverlayNavItem
active={keysView === 'settings'}
icon={Settings2}
label="Settings"
nested
onClick={() => openKeysView('settings')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
@ -191,7 +215,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings />
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : (

View File

@ -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<KeyCategoryId, string> = {
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<KeysView, readonly string[]> = {
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<KeyCategoryId, readonly string[]> = {
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<KeyCategoryId, number>
onSelect: (id: KeyCategoryId) => void
}) {
return (
<div className="mb-4 inline-flex w-full gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/30 p-1">
{KEY_TABS.map(tab => {
const isActive = active === tab.id
const count = counts[tab.id]
return (
<button
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[length:var(--conversation-text-font-size)] font-medium transition-colors',
isActive
? 'bg-(--ui-chat-surface-background) text-foreground shadow-sm'
: 'text-(--ui-text-secondary) hover:text-foreground'
)}
key={tab.id}
onClick={() => onSelect(tab.id)}
type="button"
>
<tab.icon className="size-3.5 shrink-0" />
<span className="truncate">{tab.label}</span>
{count > 0 && (
<span
className={cn(
'rounded-full px-1.5 text-[0.6875rem] tabular-nums',
isActive ? 'bg-primary/12 text-primary' : 'bg-(--ui-bg-tertiary)/60 text-muted-foreground'
)}
>
{count}
</span>
)}
</button>
)
})}
</div>
)
}
export function KeysSettings() {
export function KeysSettings({ view }: KeysSettingsProps) {
const { rowProps, vars } = useEnvCredentials()
const [activeCategory, setActiveCategory] = useState<KeyCategoryId>('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<Record<KeyCategoryId, number>>(() => {
const counts: Record<KeyCategoryId, number> = { 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 <LoadingState label="Loading API keys and credentials..." />
}
const visible = groups.filter(g => g.category === activeCategory)
const visible = groups.filter(g => g.category === view)
return (
<SettingsContent>
<CategoryTabs active={activeCategory} counts={categoryCounts} onSelect={setActiveCategory} />
{visible.map(group => (
<section className="mb-6" key={group.category}>
<div className="grid gap-2">
{group.entries.map(([key, info]: [string, EnvVarInfo]) => (
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
</section>
<div className="grid gap-1" key={group.category}>
{group.entries.map(([key, info]: [string, EnvVarInfo]) => {
const label = credentialRowLabel(key, info)
return (
<CredentialKeyCard
info={info}
key={key}
label={label}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
)
})}
</div>
))}
{visible.length === 0 && (
@ -160,3 +80,7 @@ export function KeysSettings() {
</SettingsContent>
)
}
interface KeysSettingsProps {
view: KeysView
}

View File

@ -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<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
@ -94,228 +76,6 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): 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<HTMLInputElement>) => 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<HTMLInputElement>) => {
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 ? (
<Input
className="cursor-pointer font-mono text-muted-foreground"
onFocus={startEdit}
readOnly
size={inputSize}
value={masked}
/>
) : (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className="min-w-0 flex-1 font-mono"
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
size={inputSize}
type={editType}
value={draft}
/>
{dirty && (
<Button disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
// 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 (
<div className="grid gap-1.5">
{label && (
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</label>
)}
{dim ? (
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
) : (
control
)}
</div>
)
}
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 (
<div
className={cn(
'group/card rounded-[6px] px-2 py-2 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
<span
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="w-full sm:w-80 sm:shrink-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="mt-3 grid gap-2.5 pl-4" onClick={e => e.stopPropagation()}>
{group.advanced.map(([key, info]) => (
<KeyField
compact
info={info}
key={key}
label={isKeyVar(key, info) ? key : friendlyFieldLabel(key, info)}
placeholder={advancedPlaceholder(key, info)}
rowProps={rowProps}
varKey={key}
/>
))}
{group.docsUrl && (
<a
className="inline-flex w-fit items-center gap-1 justify-self-end text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={group.docsUrl}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)}
</div>
)}
</div>
)
}
// 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<OAuthProvider[]>([])
// Single-open accordion for the per-provider "advanced options" panels.
const [openProvider, setOpenProvider] = useState<null | string>(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 (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-2">
<div className="grid gap-1">
{keyGroups.map(group => (
<ProviderKeyCard
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
<ProviderKeyRows group={group} key={group.name} rowProps={rowProps} />
))}
</div>
) : (
@ -476,8 +227,6 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
)
}
type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
interface ProviderKeyGroup {
advanced: [string, EnvVarInfo][]
description?: string

View File

@ -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) {
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{envVar.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={envVar.url} rel="noreferrer" target="_blank">
Docs
<ExternalLink className="size-3" />
</a>
</Button>
)}
{isSet && (
<Button onClick={() => void handleReveal()} size="icon-xs" title="Reveal value" variant="ghost">
{revealed !== null ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
<Button
disabled={busy}
onClick={() => void handleClear()}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
{!editing && (
<EnvVarActionsMenu
clearDisabled={busy}
docsUrl={envVar.url}
isRevealed={revealed !== null}
isSet={isSet}
label={envVar.key}
onClear={() => void handleClear()}
onEdit={() => setEditing(true)}
onReveal={() => void handleReveal()}
>
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
</EnvVarActionsMenu>
)}
</div>
{isSet && revealed !== null && (

View File

@ -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()

View File

@ -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
<div>
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
const label = toolsetDisplayLabel(toolset)
const expanded = expandedToolset === toolset.name
return (

View File

@ -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 ``<emoji> <title>``; 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.

View File

@ -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),

View File

@ -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)

View File

@ -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,

View File

@ -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">