feat(memory): per-field info tooltips + name the profile in the full-config modal

Fields can declare a longer 'info' text rendered as an (i) tooltip next to
the label in both the panel and the modal; honcho uses it to spell out the
session strategy, write frequency, and recall mode semantics. The modal
description claimed 'the active profile' without saying which — show the
active gateway profile name.
This commit is contained in:
Erosika 2026-07-07 11:52:25 -04:00
parent 8333b04483
commit a182ddbf9f
8 changed files with 40 additions and 6 deletions

View file

@ -9,6 +9,11 @@ vi.mock('@/hermes', () => ({
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()

View file

@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
@ -15,7 +16,9 @@ import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { FieldControl } from './field-control'
import { $activeGatewayProfile } from '@/store/profile'
import { FieldControl, FieldTitle } from './field-control'
import { ListRow } from '../primitives'
// Secrets seed blank: values are write-only and blank keeps the stored one.
@ -51,6 +54,7 @@ export function ProviderConfigModal({
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void> | void
}) {
const activeProfile = useStore($activeGatewayProfile)
const [values, setValues] = useState<Record<string, string>>({})
const [seeded, setSeeded] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
@ -87,8 +91,8 @@ export function ProviderConfigModal({
<DialogHeader>
<DialogTitle icon={SlidersHorizontal}>{config.label} full configuration</DialogTitle>
<DialogDescription>
Every {config.label} option for the active profile. Blank fields fall back to the resolved host or
built-in default.
Every {config.label} option for the <span className="font-medium">{activeProfile}</span> profile. Blank
fields fall back to the resolved host or built-in default.
</DialogDescription>
{config.docs_url && (
<a
@ -125,7 +129,7 @@ export function ProviderConfigModal({
/>
}
description={field.description}
title={field.label}
title={<FieldTitle field={field} />}
/>
</div>
))}

View file

@ -14,6 +14,11 @@ vi.mock('@/hermes', () => ({
runMemoryProviderAction(provider, action, values)
}))
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()

View file

@ -7,7 +7,7 @@ import { Loader2, Save, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig } from '@/types/hermes'
import { FieldControl } from './field-control'
import { FieldControl, FieldTitle } from './field-control'
import { ListRow, LoadingState, Pill } from '../primitives'
import { ProviderConfigModal } from './provider-config-modal'
@ -145,7 +145,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
/>
}
description={field.description}
title={field.label}
title={<FieldTitle field={field} />}
/>
</div>
))}

View file

@ -131,6 +131,7 @@ export interface MemoryProviderFieldOption {
export interface MemoryProviderField {
description: string
group: string
info?: string
inline: boolean
is_set: boolean
key: string

View file

@ -3891,6 +3891,7 @@ def _provider_field_entry(field: ProviderField) -> Dict[str, Any]:
"label": field.label,
"kind": field.kind,
"description": field.description,
"info": field.info,
"placeholder": field.placeholder,
"inline": field.inline,
"group": field.group,

View file

@ -78,6 +78,8 @@ class ProviderField:
env_fallbacks: tuple[str, ...] = ()
inline: bool = False
group: str = ""
# Longer help text surfaced as an info tooltip next to the field label.
info: str = ""
# Host-block placement: "host" (per-profile) or "root"; flat-json ignores it.
scope: str = "host"

View file

@ -100,6 +100,12 @@ CONFIG_SCHEMA = ProviderConfigSchema(
kind=KIND_SELECT,
default="per-directory",
description="How conversations map to Honcho sessions.",
info=(
"Per session: every conversation gets its own Honcho session. "
"Per directory: conversations from the same working directory share one. "
"Per repo: conversations from the same git repo share one. "
"Global: everything shares a single session."
),
options=(
ProviderFieldOption("per-session", "Per session"),
ProviderFieldOption("per-directory", "Per directory"),
@ -181,6 +187,11 @@ CONFIG_SCHEMA = ProviderConfigSchema(
kind=KIND_TEXT,
default="async",
description="When to flush messages: async, turn, session, or every N turns.",
info=(
"async: write in the background as messages arrive. "
"turn: flush after each turn. session: flush when the session ends. "
"A number N flushes every N turns."
),
placeholder="async | turn | session | N",
group="Message writing",
),
@ -259,6 +270,11 @@ CONFIG_SCHEMA = ProviderConfigSchema(
kind=KIND_SELECT,
default="hybrid",
description="How memory retrieval works: hybrid, context-only, or tools-only.",
info=(
"Hybrid: auto-injected context plus on-demand memory tools. "
"Context only: injection without tools. "
"Tools only: the model queries memory explicitly, nothing is injected."
),
options=(
ProviderFieldOption("hybrid", "Hybrid"),
ProviderFieldOption("context", "Context only"),