diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx index c16cc7dec6..b4b2c3ec5e 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx @@ -49,6 +49,7 @@ vi.mock('@/store/profile', () => ({ $profileScope: atom('default'), ALL_PROFILES: '*', normalizeProfileKey: (name: string) => name, + profileLabel: (profile: { display_name?: string; name: string }) => (profile.display_name ?? '').trim() || profile.name, refreshActiveProfile: vi.fn().mockResolvedValue(undefined), selectProfile: vi.fn(), setProfileColor: vi.fn(), diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 95f73cebc0..4a98d23cbb 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -53,6 +53,7 @@ import { $profileScope, ALL_PROFILES, normalizeProfileKey, + profileLabel, refreshActiveProfile, selectProfile, setProfileColor, @@ -240,7 +241,7 @@ export function ProfileRail() { (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))} /> ) : ( @@ -252,7 +253,7 @@ export function ProfileRail() { selectProfile(defaultProfile.name)} /> )} @@ -294,7 +295,7 @@ export function ProfileRail() { active={!isAll && normalizeProfileKey(profile.name) === activeKey} color={resolveProfileColor(profile.name, colors)} key={profile.name} - label={profile.name} + label={profileLabel(profile)} onDelete={() => setPendingDelete(profile)} onEditSoul={() => setPendingSoul(profile.name)} onRecolor={color => setProfileColor(profile.name, color)} @@ -345,6 +346,7 @@ export function ProfileRail() { setPendingRename(null)} onRenamed={refreshActiveProfile} open={pendingRename !== null} @@ -502,6 +504,7 @@ function ProfileDropdown({ ))} @@ -512,14 +515,14 @@ function ProfileDropdown({ // One dropdown row per profile — its own component so each row can own a // hover-intent prewarm timer (see useProfilePrewarm). -function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) { +function ProfileDropdownItem({ color, label, name }: { color: null | string; label: string; name: string }) { const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name) return ( ) diff --git a/apps/desktop/src/app/profiles/index.test.tsx b/apps/desktop/src/app/profiles/index.test.tsx index a87da7486b..b9dc87234f 100644 --- a/apps/desktop/src/app/profiles/index.test.tsx +++ b/apps/desktop/src/app/profiles/index.test.tsx @@ -57,6 +57,7 @@ vi.mock('@/store/profile', () => ({ $activeGatewayProfile: activeGateway, $profileColors, normalizeProfileKey: (name: null | string | undefined) => (name ?? '').trim() || 'default', + profileLabel: (profile: { display_name?: string; name: string }) => (profile.display_name ?? '').trim() || profile.name, refreshProfiles: vi.fn(async () => [] as ProfileInfo[]), selectProfile: vi.fn(), setActiveProfile: vi.fn() diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index a27cb261a9..af654e9d20 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -13,7 +13,7 @@ import { AlertTriangle, Save } from '@/lib/icons' import { resolveProfileColor } from '@/lib/profile-color' import { normalize } from '@/lib/text' import { notify, notifyError } from '@/store/notifications' -import { $profileColors, refreshProfiles } from '@/store/profile' +import { $profileColors, profileLabel, refreshProfiles } from '@/store/profile' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { @@ -133,7 +133,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { key={profile.name} menuItems={ profile.is_default - ? [] + ? // Renaming the default profile sets a presentation-only + // display name (the canonical id stays "default"). + [{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) }] : [ { icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) }, { @@ -162,6 +164,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { setPendingRename(null)} onRenamed={selectAndRefresh} open={pendingRename !== null} @@ -212,10 +215,10 @@ function ProfileRow({ /> } menuItems={menuItems} - menuLabel={profile.name} + menuLabel={profileLabel(profile)} onSelect={onSelect} rowKey={profile.name} - title={profile.name} + title={profileLabel(profile)} /> ) } @@ -229,7 +232,7 @@ function ProfileDetail({ profile }: { profile: ProfileInfo }) {
-

{profile.name}

+

{profileLabel(profile)}

{profile.is_default && {p.defaultBadge}} {profile.has_env && .env}
diff --git a/apps/desktop/src/app/profiles/rename-profile-dialog.tsx b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx index c6cb6f83e3..9cb72359f1 100644 --- a/apps/desktop/src/app/profiles/rename-profile-dialog.tsx +++ b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx @@ -20,15 +20,22 @@ import { retireLocalProfileGateways } from '@/store/gateway' import { isValidProfileName } from './create-profile-dialog' +// Display names are free text (Unicode fine) — no slug sanitizing. +const identity = (raw: string) => raw + // Self-contained rename (owns the renameProfile call) so every caller just // reacts via onRenamed. Unchanged name is a no-op close. export function RenameProfileDialog({ currentName, + isDefault = false, onClose, onRenamed, open }: { currentName: string + /** Default profile: sets a presentation-only display name (Unicode ok); + * the canonical id stays "default" and no backend teardown is needed. */ + isDefault?: boolean onClose: () => void onRenamed?: (name: string) => Promise | void open: boolean @@ -44,14 +51,15 @@ export function RenameProfileDialog({ return } - setName(currentName) + // Display-name mode starts blank — "default" is the id, not a name. + setName(isDefault ? '' : currentName) setError(null) setStatus('idle') - }, [currentName, open]) + }, [currentName, isDefault, open]) const trimmed = name.trim() - const unchanged = trimmed === currentName - const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed) + const unchanged = !isDefault && trimmed === currentName + const invalid = trimmed !== '' && !unchanged && !isDefault && !isValidProfileName(trimmed) const busy = status === 'saving' || status === 'done' async function handleSubmit(event: React.FormEvent) { @@ -77,7 +85,10 @@ export function RenameProfileDialog({ // backend teardown as a transient drop and redial, resurrecting the // old-name backend whose ensure_hermes_home() recreates the directory // the rename just moved (same class as the delete path, #88638). - retireLocalProfileGateways(currentName) + if (!isDefault) { + retireLocalProfileGateways(currentName) + } + await renameProfile(currentName, trimmed) await onRenamed?.(trimmed) setStatus('done') @@ -92,25 +103,31 @@ export function RenameProfileDialog({ !value && !busy && onClose()} open={open}> - {p.renameTitle} + {isDefault ? p.displayNameTitle : p.renameTitle} - {p.renameDescPrefix} - ~/.local/bin - {p.renameDescSuffix} + {isDefault ? ( + p.displayNameDesc + ) : ( + <> + {p.renameDescPrefix} + ~/.local/bin + {p.renameDescSuffix} + + )}
- + - {p.nameHint} + {!isDefault && {p.nameHint}} {error && ( diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 81df9663f1..32567ffd68 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1774,6 +1774,9 @@ export const en: Translations = { renameTitle: 'Rename profile', renameDescPrefix: 'Renaming updates the profile directory and any wrapper scripts in ', renameDescSuffix: '.', + displayNameTitle: 'Name this agent', + displayNameDesc: 'Sets a display name shown across the app. The internal profile ID stays "default".', + displayNameLabel: 'Display name', newNameLabel: 'New name', renaming: 'Renaming...', created: 'Profile created', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 053c1f0599..48d57b6cc1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1499,6 +1499,9 @@ export interface Translations { creating: string createAction: string renameTitle: string + displayNameTitle: string + displayNameDesc: string + displayNameLabel: string renameDescPrefix: string renameDescSuffix: string newNameLabel: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 70d2582aaa..9b977df494 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1959,6 +1959,9 @@ export const zh: Translations = { creating: '创建中…', createAction: '创建配置档案', renameTitle: '重命名配置档案', + displayNameTitle: '为智能体命名', + displayNameDesc: '设置一个在整个应用中显示的名称。内部配置档案 ID 仍为 "default"。', + displayNameLabel: '显示名称', renameDescPrefix: '重命名会更新配置档案目录以及 ', renameDescSuffix: ' 中的所有包装脚本。', newNameLabel: '新名称', diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index e8dbc52250..8c2cfca413 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -3103,6 +3103,13 @@ function displayName(bot, meta) { return meta.title.trim() } + // Core-profile display name (profile.yaml, set via `hermes profile rename + // default ` or the dashboard) — the CLI-level equivalent of a Bot + // Mode title. Rides the profiles.list row; presentation-only. + if (typeof bot?.display_name === 'string' && bot.display_name.trim()) { + return bot.display_name.trim() + } + // The primary profile is literally named "default" — as a bot identity // that reads like nobody bothered. Present it as Hermes (the agent it is) // unless the user gives it a real title. diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 99fd3dd705..2e67f54cd2 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -25,6 +25,13 @@ export function normalizeProfileKey(name: string | null | undefined): string { return value || 'default' } +// Presentation-only label: the display_name from profile.yaml when set (e.g. a +// renamed default profile), else the canonical name. Never used for +// comparison or routing — canonical `name` remains the identity everywhere. +export function profileLabel(profile: Pick): string { + return (profile.display_name ?? '').trim() || profile.name +} + // The profile the running local backend is actually scoped to (mirrors // /api/profiles/active `current`). "default" is the root ~/.hermes. This is the // display source of truth for the statusbar pill; the desktop's *stored* diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index c180cb08a5..ce407ddc49 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -920,6 +920,8 @@ export interface ProfileCreatePayload { } export interface ProfileInfo { + /** Presentation-only label override (profile.yaml display_name). */ + display_name?: string has_env: boolean is_default: boolean model: null | string diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1e8a83b18c..c2556b0049 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10068,31 +10068,47 @@ def cmd_profile(args): if action is None: # Bare `hermes profile` — show current profile status + from hermes_cli.profiles import format_profile_label + profile_name = get_active_profile_name() dhh = display_hermes_home() - print(f"\nActive profile: {profile_name}") - print(f"Path: {dhh}") profiles = list_profiles() - for p in profiles: - if p.name == profile_name or (profile_name == "default" and p.is_default): - if p.model: - print( - f"Model: {p.model}" - + (f" ({p.provider})" if p.provider else "") - ) + current = next( + ( + p + for p in profiles + if p.name == profile_name + or (profile_name == "default" and p.is_default) + ), + None, + ) + label = format_profile_label( + profile_name, current.display_name if current else "" + ) + print(f"\nActive profile: {label}") + print(f"Path: {dhh}") + + if current is not None: + p = current + if p.model: print( - f"Gateway: {'running' if p.gateway_running else 'stopped'}" + f"Model: {p.model}" + + (f" ({p.provider})" if p.provider else "") ) - print(f"Skills: {p.skill_count} installed") - if p.alias_path: - alias_display = p.alias_name or p.name - print(f"Alias: {alias_display} → hermes -p {p.name}") - break + print( + f"Gateway: {'running' if p.gateway_running else 'stopped'}" + ) + print(f"Skills: {p.skill_count} installed") + if p.alias_path: + alias_display = p.alias_name or p.name + print(f"Alias: {alias_display} → hermes -p {p.name}") print() return if action == "list": + from hermes_cli.profiles import format_profile_label + profiles = list_profiles() active = get_active_profile_name() @@ -10116,7 +10132,7 @@ def cmd_profile(args): if (p.name == active or (active == "default" and p.is_default)) else " " ) - name = p.name + name = format_profile_label(p.name, p.display_name) model = (p.model or "—")[:26] gw = "running" if p.gateway_running else "stopped" alias = (p.alias_name or p.name) if p.alias_path else "—" @@ -10375,6 +10391,8 @@ def cmd_profile(args): _read_distribution_meta, _get_wrapper_dir, find_alias_for_profile, + format_profile_label, + read_profile_meta, ) if not profile_exists(name): @@ -10386,8 +10404,9 @@ def cmd_profile(args): skills = _count_skills(profile_dir) dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir) alias_name = find_alias_for_profile(name) + display = read_profile_meta(profile_dir).get("display_name", "") - print(f"\nProfile: {name}") + print(f"\nProfile: {format_profile_label(name, display)}") print(f"Path: {profile_dir}") if model: print(f"Model: {model}" + (f" ({provider})" if provider else "")) @@ -10448,12 +10467,13 @@ def cmd_profile(args): print(f"⚠ {_get_wrapper_dir()} is not in your PATH.") elif action == "rename": - from hermes_cli.profiles import rename_profile + from hermes_cli.profiles import normalize_profile_name, rename_profile try: new_dir = rename_profile(args.old_name, args.new_name) - print(f"\nProfile renamed: {args.old_name} → {args.new_name}") - print(f"Path: {new_dir}\n") + if normalize_profile_name(args.old_name) != "default": + print(f"\nProfile renamed: {args.old_name} → {args.new_name}") + print(f"Path: {new_dir}\n") except (ValueError, FileExistsError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index ad52b7b865..f6e3c79f30 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -657,6 +657,9 @@ class ProfileInfo: # surfaces a "review" badge in this case so the user can edit or # accept. description_auto: bool = False + # Optional user-facing display name from profile.yaml. Presentation + # only — resolution/comparison/spawn paths always use ``name``. + display_name: str = "" def _read_distribution_meta(profile_dir: Path) -> tuple: @@ -823,25 +826,27 @@ def _profile_yaml_path(profile_dir: Path) -> Path: def read_profile_meta(profile_dir: Path) -> dict: """Read ``/profile.yaml`` and return a dict. - Returns ``{"description": "", "description_auto": False}`` when the - file is missing or unreadable. Never raises — a corrupt - profile.yaml on an unrelated profile must not break - ``hermes profile list``. + Returns ``{"description": "", "description_auto": False, + "display_name": ""}`` when the file is missing or unreadable. Never + raises — a corrupt profile.yaml on an unrelated profile must not + break ``hermes profile list``. """ + empty = {"description": "", "description_auto": False, "display_name": ""} path = _profile_yaml_path(profile_dir) if not path.is_file(): - return {"description": "", "description_auto": False} + return empty try: import yaml with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} except Exception: - return {"description": "", "description_auto": False} + return empty if not isinstance(data, dict): - return {"description": "", "description_auto": False} + return empty return { "description": str(data.get("description") or "").strip(), "description_auto": bool(data.get("description_auto", False)), + "display_name": str(data.get("display_name") or "").strip(), } @@ -850,6 +855,7 @@ def write_profile_meta( *, description: Optional[str] = None, description_auto: Optional[bool] = None, + display_name: Optional[str] = None, ) -> None: """Update ``/profile.yaml`` in place. @@ -874,6 +880,12 @@ def write_profile_meta( existing["description"] = description.strip() if description_auto is not None: existing["description_auto"] = bool(description_auto) + if display_name is not None: + # Empty string clears the key (falls back to the canonical id). + if display_name.strip(): + existing["display_name"] = display_name.strip() + else: + existing.pop("display_name", None) # Atomic write: bare open("w") truncates before the dump, and the read # path above swallows parse errors as {}, so a crashed write would # silently drop unspecified fields on the next call (#51356, #16743). @@ -882,6 +894,36 @@ def write_profile_meta( atomic_yaml_write(path, existing, sort_keys=False) +def format_profile_label(name: str, display_name: Optional[str]) -> str: + """Render a profile for display: ``display_name (canonical_id)``. + + Falls back to the bare canonical id when no display name is set (or it + equals the id) — byte-for-byte the pre-feature rendering. Display names + are presentation-only free text (Unicode fine); they are never a + directory name, wrapper filename, or argv token. + """ + dn = (display_name or "").strip() + return f"{dn} ({name})" if dn and dn != name else name + + +def set_profile_display_name(profile_name: str, display_name: str) -> str: + """Set (or clear, with ``""``) a profile's user-facing display name. + + Presentation-only: the canonical profile id is untouched. Returns the + stored value. Raises ``ValueError`` for names over 64 chars. + """ + canon = normalize_profile_name(profile_name) + validate_profile_name(canon) + profile_dir = get_profile_dir(canon) + if not profile_dir.is_dir(): + raise FileNotFoundError(f"Profile '{canon}' does not exist.") + cleaned = (display_name or "").strip() + if len(cleaned) > 64: + raise ValueError(f"Display name too long ({len(cleaned)} chars, max 64).") + write_profile_meta(profile_dir, display_name=cleaned) + return cleaned + + # --------------------------------------------------------------------------- # CRUD operations # --------------------------------------------------------------------------- @@ -911,6 +953,7 @@ def list_profiles() -> List[ProfileInfo]: distribution_source=dist_source, description=meta.get("description", ""), description_auto=meta.get("description_auto", False), + display_name=meta.get("display_name", ""), )) # Named profiles @@ -953,6 +996,7 @@ def list_profiles() -> List[ProfileInfo]: distribution_source=dist_source, description=meta.get("description", ""), description_auto=meta.get("description_auto", False), + display_name=meta.get("display_name", ""), )) return profiles @@ -2344,15 +2388,26 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> def rename_profile(old_name: str, new_name: str) -> Path: """Rename a profile: directory, wrapper script, service, active_profile. - Returns the new profile directory. + The default profile's home IS the installation root, so "renaming" it + sets a presentation-only ``display_name`` in profile.yaml instead — + the canonical id stays ``default`` and every resolution path is + untouched. + + Returns the (new) profile directory. """ old_canon = normalize_profile_name(old_name) - new_canon = normalize_profile_name(new_name) validate_profile_name(old_canon) - validate_profile_name(new_canon) if old_canon == "default": - raise ValueError("Cannot rename the default profile.") + if not (new_name or "").strip(): + raise ValueError("Display name cannot be empty.") + cleaned = set_profile_display_name("default", new_name) + print(f"✓ Display name set: {cleaned} (canonical id remains 'default')") + return _get_default_hermes_home() + + new_canon = normalize_profile_name(new_name) + validate_profile_name(new_canon) + if new_canon == "default": raise ValueError("Cannot rename to 'default' — it is reserved.") diff --git a/hermes_cli/slash_exec.py b/hermes_cli/slash_exec.py index 85c49bb8e2..38b31f3c30 100644 --- a/hermes_cli/slash_exec.py +++ b/hermes_cli/slash_exec.py @@ -100,8 +100,23 @@ def _exec_profile(ctx: CommandContext) -> CommandReply: home_display = display_hermes_home() + # Presentation-only display name (profile.yaml). `data.profile` stays + # the canonical id — consumers route on it; only the text gets the label. + label = profile_name + try: + from hermes_cli.profiles import ( + format_profile_label, + get_profile_dir, + read_profile_meta, + ) + + display = read_profile_meta(get_profile_dir(profile_name)).get("display_name", "") + label = format_profile_label(profile_name, display) + except Exception: + pass + return CommandReply( - f"Profile: {profile_name}\nHome: {home_display}", + f"Profile: {label}\nHome: {home_display}", data={"profile": profile_name, "home": home_display}, ) diff --git a/hermes_cli/subcommands/profile.py b/hermes_cli/subcommands/profile.py index d812fadf97..abcc53cd41 100644 --- a/hermes_cli/subcommands/profile.py +++ b/hermes_cli/subcommands/profile.py @@ -120,9 +120,15 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None: help="Custom alias name (default: profile name)", ) - profile_rename = profile_subparsers.add_parser("rename", help="Rename a profile") + profile_rename = profile_subparsers.add_parser( + "rename", + help="Rename a profile ('default': sets a display name; id unchanged)", + ) profile_rename.add_argument("old_name", help="Current profile name") - profile_rename.add_argument("new_name", help="New profile name") + profile_rename.add_argument( + "new_name", + help="New profile name (for 'default': a display name — the canonical id stays 'default')", + ) profile_export = profile_subparsers.add_parser( "export", help="Export a profile to archive" diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 4d44e9e366..77ff1ad50a 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -1010,7 +1010,25 @@ async def rename_profile_endpoint(name: str, body: ProfileRename): except Exception as e: _log.exception("PATCH /api/profiles/%s failed", name) raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "name": body.new_name, "path": str(path)} + # For the default profile the rename lands as a presentation-only + # display_name; the canonical id ("default") is unchanged. Always + # return the canonical id so callers keying on `name` stay correct. + try: + is_default = profiles_mod.normalize_profile_name(name) == "default" + except ValueError: + is_default = False + if is_default: + return { + "ok": True, + "name": "default", + "display_name": body.new_name.strip(), + "path": str(path), + } + return { + "ok": True, + "name": profiles_mod.normalize_profile_name(body.new_name), + "path": str(path), + } @router.delete("/api/profiles/{name}") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d5693c72fc..edc62c8c52 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -14476,6 +14476,7 @@ def _profile_to_dict(info) -> Dict[str, Any]: "gateway_running": bool(_profile_attr(info, "gateway_running", False)), "description": _profile_attr(info, "description", "") or "", "description_auto": bool(_profile_attr(info, "description_auto", False)), + "display_name": _profile_attr(info, "display_name", "") or "", "distribution_name": _profile_attr(info, "distribution_name"), "distribution_version": _profile_attr(info, "distribution_version"), "distribution_source": _profile_attr(info, "distribution_source"), diff --git a/tests/hermes_cli/test_profile_display_name.py b/tests/hermes_cli/test_profile_display_name.py new file mode 100644 index 0000000000..27dbd6e77c --- /dev/null +++ b/tests/hermes_cli/test_profile_display_name.py @@ -0,0 +1,122 @@ +"""Profile display_name (#45624): presentation-only label in profile.yaml. + +The canonical profile id ("default" for ~/.hermes) is never touched — +resolution, comparison, and spawn paths must be provably unaffected. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from hermes_cli.profiles import ( + create_profile, + format_profile_label, + get_profile_dir, + list_profiles, + profile_exists, + read_profile_meta, + rename_profile, + resolve_profile_env, + set_profile_display_name, + write_profile_meta, +) + + +@pytest.fixture() +def profile_env(tmp_path, monkeypatch): + """Isolated environment: Path.home() and HERMES_HOME under tmp_path.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + default_home = tmp_path / ".hermes" + default_home.mkdir(exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + return default_home + + +class TestMetaAndValidation: + def test_round_trip_preserves_other_fields(self, profile_env): + write_profile_meta(profile_env, description="ops agent") + write_profile_meta(profile_env, display_name="小助手") + meta = read_profile_meta(profile_env) + assert meta["display_name"] == "小助手" + assert meta["description"] == "ops agent" + + def test_missing_file_defaults_empty(self, profile_env): + assert read_profile_meta(profile_env)["display_name"] == "" + + def test_empty_clears_key_from_file(self, profile_env): + write_profile_meta(profile_env, display_name="Harumesu") + write_profile_meta(profile_env, display_name="") + data = yaml.safe_load((profile_env / "profile.yaml").read_text()) + assert "display_name" not in data + + def test_setter_strips_and_caps_length(self, profile_env): + assert set_profile_display_name("default", " Harumesu ") == "Harumesu" + with pytest.raises(ValueError): + set_profile_display_name("default", "x" * 65) + + def test_setter_missing_profile_raises(self, profile_env): + with pytest.raises(FileNotFoundError): + set_profile_display_name("ghost", "Boo") + + +class TestFormatProfileLabel: + def test_shapes(self): + assert format_profile_label("default", "Harumesu") == "Harumesu (default)" + # Unset/None/id-equal → byte-for-byte the pre-feature rendering. + assert format_profile_label("default", "") == "default" + assert format_profile_label("default", None) == "default" + assert format_profile_label("worker", "worker") == "worker" + + +class TestRenameDefault: + def test_sets_display_name_only(self, profile_env, capsys): + assert rename_profile("default", "Harumesu") == profile_env + assert profile_env.is_dir() # directory untouched + assert read_profile_meta(profile_env)["display_name"] == "Harumesu" + assert "canonical id remains 'default'" in capsys.readouterr().out + + def test_reflected_in_list_profiles(self, profile_env): + rename_profile("default", "Harumesu") + info = next(p for p in list_profiles() if p.is_default) + assert info.name == "default" + assert info.is_default is True + assert info.display_name == "Harumesu" + + def test_rejects_empty_and_preserves_existing(self, profile_env): + rename_profile("default", "Harumesu") + with pytest.raises(ValueError): + rename_profile("default", " ") + # Failed rename must not clear the existing display name. + assert read_profile_meta(profile_env)["display_name"] == "Harumesu" + + def test_rename_to_default_still_reserved(self, profile_env): + create_profile("worker", no_alias=True) + with pytest.raises(ValueError, match="reserved"): + rename_profile("worker", "default") + + def test_named_rename_still_real_and_keeps_display_name( + self, profile_env, monkeypatch + ): + monkeypatch.setattr( + "hermes_cli.profiles.check_alias_collision", lambda name: "skip" + ) + create_profile("oldname", no_alias=True) + write_profile_meta(get_profile_dir("oldname"), display_name="Old Friend") + new_dir = rename_profile("oldname", "newname") + assert not (profile_env / "profiles" / "oldname").is_dir() + assert new_dir == profile_env / "profiles" / "newname" + assert read_profile_meta(new_dir)["display_name"] == "Old Friend" + + +class TestResolutionUnaffected: + def test_display_name_is_not_a_resolvable_id(self, profile_env): + rename_profile("default", "Harumesu") + assert get_profile_dir("default") == profile_env + assert profile_exists("default") is True + assert profile_exists("harumesu") is False + assert resolve_profile_env("default") == str(profile_env) + with pytest.raises(FileNotFoundError): + resolve_profile_env("harumesu") diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 29032d6dea..8352c83d54 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -200,6 +200,7 @@ def _(rid, params: dict) -> dict: "model": p.model, "provider": p.provider, "description": getattr(p, "description", "") or "", + "display_name": getattr(p, "display_name", "") or "", "skill_count": getattr(p, "skill_count", 0) or 0, } if include_sessions: diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a3dc7f5e2b..0d277ef589 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2180,6 +2180,7 @@ export interface ProfileInfo { gateway_running: boolean; description: string; description_auto: boolean; + display_name?: string; distribution_name: string | null; distribution_version: string | null; distribution_source: string | null; diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index 8c50fed333..cf39634ca2 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -1092,7 +1092,7 @@ export default function ProfilesPage() {
- {p.name} + {p.display_name?.trim() ? `${p.display_name.trim()} (${p.name})` : p.name} {active && ( diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index cd68f1c4b7..ae4ad7055f 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -247,6 +247,24 @@ hermes profile import coder.tar.gz # install an archive as a new profile In chat, the same two live as `/export` and `/import` — and in the desktop app as **⌘K → Export/Import profile…**. See [Sharing a profile](#sharing-a-profile). +### Naming the default profile + +The default profile's internal ID is always `default` — it can't be truly +renamed because `~/.hermes` is the installation root. Renaming it instead +sets a **display name**, which UI surfaces show in place of the bare ID: + +```bash +hermes profile rename default Harumesu # Unicode fine: 小助手 +``` + +The display name appears in `hermes profile list`/`show`, the `/profile` +chat command, the dashboard, and the desktop app (including the Bot Mode +roster). It is presentation-only: `-p default`, service names, cron jobs, +and every other reference keep using the canonical `default` ID. It is +stored as `display_name` in `~/.hermes/profile.yaml`; remove that line to +revert. Named profiles can carry a `display_name` too (it survives a real +rename), but `rename` for them still renames the profile itself. + ## Deleting a profile ```bash