import { useCallback, useEffect, useRef, useState } from "react"; import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react"; import { H2 } from "@/components/NouiTypography"; import { api } from "@/lib/api"; import type { ProfileInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@/hooks/useToast"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { Toast } from "@/components/Toast"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useI18n } from "@/i18n"; // Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously // invalid names (uppercase, spaces, …) before round-tripping a doomed POST. const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; export default function ProfilesPage() { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t } = useI18n(); // Create form const [newName, setNewName] = useState(""); const [cloneFromDefault, setCloneFromDefault] = useState(true); const [creating, setCreating] = useState(false); // Inline rename state const [renamingFrom, setRenamingFrom] = useState(null); const [renameTo, setRenameTo] = useState(""); // Inline SOUL editor state const [editingSoulFor, setEditingSoulFor] = useState(null); const [soulText, setSoulText] = useState(""); const [soulSaving, setSoulSaving] = useState(false); // Tracks the latest SOUL request so out-of-order responses don't overwrite // newer state when the user switches profiles or closes the editor. const activeSoulRequest = useRef(null); const load = useCallback(() => { api .getProfiles() .then((res) => setProfiles(res.profiles)) .catch((e) => showToast(`${t.status.error}: ${e}`, "error")) .finally(() => setLoading(false)); }, [showToast, t.status.error]); useEffect(() => { load(); }, [load]); const handleCreate = async () => { const name = newName.trim(); if (!name) { showToast(t.profiles.nameRequired, "error"); return; } if (!PROFILE_NAME_RE.test(name)) { showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); return; } setCreating(true); try { await api.createProfile({ name, clone_from_default: cloneFromDefault }); showToast(`${t.profiles.created}: ${name}`, "success"); setNewName(""); load(); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { setCreating(false); } }; const handleRenameSubmit = async () => { if (!renamingFrom) return; const target = renameTo.trim(); if (!target || target === renamingFrom) { setRenamingFrom(null); setRenameTo(""); return; } if (!PROFILE_NAME_RE.test(target)) { showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); return; } try { await api.renameProfile(renamingFrom, target); showToast(`${t.profiles.renamed}: ${renamingFrom} → ${target}`, "success"); setRenamingFrom(null); setRenameTo(""); load(); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } }; const openSoulEditor = useCallback( async (name: string) => { if (editingSoulFor === name) { activeSoulRequest.current = null; setEditingSoulFor(null); return; } setEditingSoulFor(name); setSoulText(""); activeSoulRequest.current = name; try { const soul = await api.getProfileSoul(name); if (activeSoulRequest.current === name) { setSoulText(soul.content); } } catch (e) { if (activeSoulRequest.current === name) { showToast(`${t.status.error}: ${e}`, "error"); } } }, [editingSoulFor, showToast, t.status.error], ); const handleSaveSoul = async (name: string) => { setSoulSaving(true); try { await api.updateProfileSoul(name, soulText); showToast(`${t.profiles.soulSaved}: ${name}`, "success"); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { setSoulSaving(false); } }; const handleCopyTerminalCommand = async (name: string) => { let cmd: string; try { const res = await api.getProfileSetupCommand(name); cmd = res.command; } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); return; } try { await navigator.clipboard.writeText(cmd); showToast(`${t.profiles.commandCopied}: ${cmd}`, "success"); } catch { showToast(`${t.profiles.copyFailed}: ${cmd}`, "error"); } }; const profileDelete = useConfirmDelete({ onDelete: useCallback( async (name: string) => { try { await api.deleteProfile(name); showToast(`${t.profiles.deleted}: ${name}`, "success"); load(); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); throw e; } }, [load, showToast, t.profiles.deleted, t.status.error], ), }); const pendingName = profileDelete.pendingId; if (loading) { return (
); } return ( // Profile names, model slugs, and paths are case-sensitive; opt out of // the app shell's global ``uppercase`` so they render as the user typed. // Children that explicitly opt back in (Badges, etc.) keep their casing.
{/* Create new profile */} {t.profiles.newProfile}
setNewName(e.target.value)} aria-invalid={ newName.trim() !== "" && !PROFILE_NAME_RE.test(newName.trim()) } />

{t.profiles.nameRule}

{/* List */}

{t.profiles.allProfiles} ({profiles.length})

{profiles.length === 0 && ( {t.profiles.noProfiles} )} {profiles.map((p) => { const isRenaming = renamingFrom === p.name; const isEditingSoul = editingSoulFor === p.name; return (
{isRenaming ? ( setRenameTo(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleRenameSubmit(); if (e.key === "Escape") setRenamingFrom(null); }} aria-invalid={ renameTo.trim() !== "" && renameTo.trim() !== p.name && !PROFILE_NAME_RE.test(renameTo.trim()) } className="max-w-xs" /> ) : ( {p.name} )} {p.is_default && ( {t.profiles.defaultBadge} )} {p.has_env && ( {t.profiles.hasEnv} )}
{isRenaming && (() => { const trimmed = renameTo.trim(); const invalid = trimmed !== "" && trimmed !== p.name && !PROFILE_NAME_RE.test(trimmed); return (

{invalid ? `${t.profiles.invalidName}: ${t.profiles.nameRule}` : t.profiles.nameRule}

); })()}
{p.model && ( {t.profiles.model}: {p.model} {p.provider ? ` (${p.provider})` : ""} )} {t.profiles.skills}: {p.skill_count} {p.path}
{isRenaming ? ( <> ) : ( <> {!p.is_default && ( )} {!p.is_default && ( )} )}
{isEditingSoul && (