/** * @file CcConfig.tsx * @description Claude Code configuration explorer. Surfaces every plugin, * skill, subagent, slash command, MCP server, hook, settings file, memory * file, marketplace, keybinding, and statusline script Claude Code knows * about. Read access for all surfaces; create / edit / delete for the * low-risk text-file surfaces (skills, agents, commands, output styles, * CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP, * hooks-in-settings, and settings.json files stay read-only - those have * concurrent-write races with the live CLI. Its Overview also holds the * dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path * list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`). * The toolbar's Switch profile button links to Settings → AI tools → Profiles * (`ToolProfilesSettings`), where every AI tool's login profiles live. * Every card with action buttons (plugins, marketplaces, MCP servers, memory * files, and the `MdItemList` artifacts) also opens a * right-click menu that repeats those buttons — same handlers, same * confirm path — plus Copy name/path. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/eventBus` * - `../lib/api` * * ## Public surface * - `CcConfig` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **CcConfig** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Link, useSearchParams } from "react-router-dom"; import { useSettingsHref } from "../lib/settingsLink"; import { eventBus } from "../lib/eventBus"; import { Boxes, RefreshCw, Search, Sparkles, UserRound, FolderTree, Wrench, Slash, Palette, PlugZap, Server, Webhook, Settings as SettingsIcon, BookOpen, FileText, Copy, Check, AlertCircle, ExternalLink, X, Info, Pencil, Trash2, Plus, Save, Lock, History, Terminal, Store, Keyboard, CircleDot, CircleSlash, ChevronDown, UserCog, ArrowUpCircle, RotateCw, CheckCircle2, XCircle, MinusCircle, } from "lucide-react"; import { api } from "../lib/api"; import { FileViewer } from "../components/config/FileViewer"; import { EditorModal, type EditorState } from "../components/config/EditorModal"; import { ConfirmDeleteModal, type ConfirmDeleteState, } from "../components/config/ConfirmDeleteModal"; import { showToast, type Toast } from "../components/config/ToastNotice"; import { confirmDialog, confirmDiscard } from "../lib/confirm"; import { copyText } from "../lib/clipboard"; import { useDialog } from "../hooks/useDialog"; import { useUnsavedGuard } from "../hooks/useUnsavedGuard"; import { useArmed } from "../hooks/useArmed"; import { FileItemMenu, MdItemList } from "../components/config/MdItemList"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { ScopeBadge } from "../components/config/ScopeBadge"; import { ConfigTabBar } from "../components/config/ConfigTabBar"; import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal"; import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings"; import { Empty, SkeletonRows, CopyButton, formatBytes, } from "../components/config/configPrimitives"; import type { CcArtifactType, CcAvailablePlugin, CcBackup, CcFileResponse, CcHookEntry, CcHookScripts, CcHookSource, CcKeybindings, CcKeybindingGroup, CcMarketplacesResponse, CcMcpAddArgs, CcMcpResponse, CcMcpScope, CcMcpServer, CcMdItem, CcMemoryItem, CcMutationResult, CcOverview, CcPlugin, CcPluginItem, CcPluginsResponse, CcUpdateAllBatch, CcScope, CcSettingsSource, CcStatusline, } from "../lib/api"; import { isSubmitEnter } from "../lib/keys"; /** How an inline editor reports unsaved edits to the page. */ type OnDirty = (id: string, dirty: boolean) => void; /** * Report an inline editor's unsaved state to the page (tab switches ask) and * to the browser (closing the tab asks). */ function useReportDirty(id: string, dirty: boolean, onDirty: OnDirty | undefined): void { useEffect(() => { onDirty?.(id, dirty); return () => onDirty?.(id, false); }, [id, dirty, onDirty]); useUnsavedGuard(dirty); } function isMutable( tab: TabKey ): tab is "skills" | "agents" | "commands" | "outputStyles" | "memory" { return ( tab === "skills" || tab === "agents" || tab === "commands" || tab === "outputStyles" || tab === "memory" ); } function tabToArtifactType( tab: "skills" | "agents" | "commands" | "outputStyles" | "memory" ): CcArtifactType { return tab === "outputStyles" ? "output-styles" : tab; } type TabKey = | "overview" | "skills" | "agents" | "commands" | "outputStyles" | "plugins" | "marketplaces" | "mcp" | "hooks" | "keybindings" | "settings" | "memory"; interface TabDef { key: TabKey; icon: typeof Sparkles; i18nKey: string; /** i18n key of the tab's group label (Content · Extensions · Config). */ group?: string; } const TABS: TabDef[] = [ { key: "overview", icon: Boxes, i18nKey: "tabs.overview" }, { key: "skills", icon: Sparkles, i18nKey: "tabs.skills", group: "tabGroups.content" }, { key: "agents", icon: UserRound, i18nKey: "tabs.agents", group: "tabGroups.content" }, { key: "commands", icon: Slash, i18nKey: "tabs.commands", group: "tabGroups.content" }, { key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles", group: "tabGroups.content" }, { key: "memory", icon: BookOpen, i18nKey: "tabs.memory", group: "tabGroups.content" }, { key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins", group: "tabGroups.extensions" }, { key: "marketplaces", icon: Store, i18nKey: "tabs.marketplaces", group: "tabGroups.extensions" }, { key: "mcp", icon: Server, i18nKey: "tabs.mcp", group: "tabGroups.extensions" }, { key: "hooks", icon: Webhook, i18nKey: "tabs.hooks", group: "tabGroups.extensions" }, { key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings", group: "tabGroups.config" }, { key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings", group: "tabGroups.config" }, ]; interface PageState { overview: CcOverview | null; skills: CcMdItem[] | null; agents: CcMdItem[] | null; commands: CcMdItem[] | null; outputStyles: CcMdItem[] | null; plugins: CcPluginsResponse | null; marketplaces: CcMarketplacesResponse | null; mcp: CcMcpResponse | null; hooks: CcHookSource[] | null; keybindings: CcKeybindings | null; settings: CcSettingsSource[] | null; memory: CcMemoryItem[] | null; statusline: CcStatusline | null; hookScripts: CcHookScripts | null; } const EMPTY_STATE: PageState = { overview: null, skills: null, agents: null, commands: null, outputStyles: null, plugins: null, marketplaces: null, mcp: null, hooks: null, keybindings: null, settings: null, memory: null, statusline: null, hookScripts: null, }; /** Which page-state keys each tab displays (their load errors show there). */ const TAB_KEYS: Record = { overview: ["overview"], skills: ["skills"], agents: ["agents"], commands: ["commands"], outputStyles: ["outputStyles"], plugins: ["plugins"], marketplaces: ["marketplaces"], mcp: ["mcp"], hooks: ["hooks", "hookScripts"], keybindings: ["keybindings"], settings: ["settings", "statusline"], memory: ["memory"], }; /** The four lists the All / User / Project toggle actually filters. */ const SCOPED_TABS: TabKey[] = ["skills", "agents", "commands", "outputStyles"]; const SCOPED_KEYS: (keyof PageState)[] = ["skills", "agents", "commands", "outputStyles"]; const ALL_KEYS = Object.keys(EMPTY_STATE) as (keyof PageState)[]; /** One loader per page-state key, so a change refetches only what it touched. */ function loadKey(key: keyof PageState, scope: CcScope): Promise { switch (key) { case "overview": return api.ccConfig.overview(); case "skills": return api.ccConfig.skills(scope).then((r) => r.items); case "agents": return api.ccConfig.agents(scope).then((r) => r.items); case "commands": return api.ccConfig.commands(scope).then((r) => r.items); case "outputStyles": return api.ccConfig.outputStyles(scope).then((r) => r.items); case "plugins": return api.ccConfig.plugins(); case "marketplaces": return api.ccConfig.marketplaces(); case "mcp": return api.ccConfig.mcp(); case "hooks": return api.ccConfig.hooks().then((r) => r.items); case "keybindings": return api.ccConfig.keybindings(); case "settings": return api.ccConfig.settings().then((r) => r.items); case "memory": return api.ccConfig.memory().then((r) => r.items); case "statusline": return api.ccConfig.statusline(); case "hookScripts": return api.ccConfig.hookScripts(); } } /** * The page-state keys a `cc_config_changed` message touches. The watcher sends * the changed `paths`, a dashboard mutation its artifact `type`; anything we * cannot place reloads everything. The overview's counts always reload. * @param msg The websocket message's data. */ export function keysForChange(msg: { source?: string; paths?: unknown; type?: unknown; }): (keyof PageState)[] { const out = new Set(["overview"]); const all = () => ALL_KEYS; if (Array.isArray(msg.paths) && msg.paths.length > 0) { for (const raw of msg.paths) { const p = String(raw).replace(/\\/g, "/"); if (/\/skills(\/|$)/.test(p)) out.add("skills"); else if (/\/agents(\/|$)/.test(p)) out.add("agents"); else if (/\/commands(\/|$)/.test(p)) out.add("commands"); else if (/\/output-styles(\/|$)/.test(p)) out.add("outputStyles"); else if (/\/hooks(\/|$)/.test(p)) (out.add("hooks"), out.add("hookScripts")); else if (/keybindings\.json$/.test(p)) out.add("keybindings"); else if (/settings(\.local)?\.json$/.test(p)) (out.add("settings"), out.add("hooks"), out.add("statusline"), out.add("hookScripts")); else if (/CLAUDE\.md$|\/memory\//.test(p)) out.add("memory"); else if (/\/plugins(\/|$)/.test(p)) (out.add("plugins"), out.add("marketplaces")); else if (/\.claude\.json$/.test(p)) (out.add("mcp"), out.add("marketplaces"), out.add("plugins")); else return all(); } return [...out]; } switch (msg.type) { case "skills": case "agents": case "commands": case "memory": case "keybindings": out.add(msg.type); return [...out]; case "output-styles": out.add("outputStyles"); return [...out]; case "auto-memory": out.add("memory"); return [...out]; case "settings": return [...out, "settings", "hooks", "statusline", "hookScripts"]; case "profile": return [...out]; default: return all(); } } /** A project root inside Claude Code's plugin cache is replaced on every * plugin update, so nothing written there survives. */ export function isPluginCachePath(p: string | null | undefined): boolean { return !!p && /\/\.claude\/plugins\/cache(\/|$)/.test(p.replace(/\\/g, "/")); } export function CcConfig() { const { t } = useTranslation("ccConfig"); // The tab lives in the URL (`?tab=`, replaced) so search results, deep links // and Back land on it. const [params, setParams] = useSearchParams(); const rawTab = params.get("tab"); const urlTab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview"; // Editors inside a tab report unsaved edits here (by id), so leaving the tab // asks first instead of silently dropping the draft. const dirtyIds = useRef(new Set()); const setDirty = useCallback((id: string, d: boolean) => { if (d) dirtyIds.current.add(id); else dirtyIds.current.delete(id); }, []); const writeTab = useCallback( (k: TabKey) => setParams( (prev) => { const next = new URLSearchParams(prev); if (k === "overview") next.delete("tab"); else next.set("tab", k); return next; }, { replace: true } ), [setParams] ); // The tab shown follows the URL, but only once an unsaved edit is let go: // the URL also changes from outside the tab bar (a settings search result, // Back), and those must ask too. Kept, the URL goes back to the shown tab. const [tab, setShownTab] = useState(urlTab); useEffect(() => { if (urlTab === tab) return; let live = true; void (async () => { if (dirtyIds.current.size > 0 && !(await confirmDiscard())) { if (live) writeTab(tab); return; } if (!live) return; dirtyIds.current.clear(); setShownTab(urlTab); })(); return () => { live = false; }; }, [urlTab, tab, writeTab]); const setTab = useCallback((k: TabKey) => writeTab(k), [writeTab]); // Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them. const [agenthubKey, setAgenthubKey] = useState(0); const [scope, setScope] = useState("all"); const [data, setData] = useState(EMPTY_STATE); const [loading, setLoading] = useState(false); const [errors, setErrors] = useState>>({}); const [lastUpdated, setLastUpdated] = useState(null); // One search box per tab: a filter typed on Skills must not hide Plugins. const [searches, setSearches] = useState>>({}); const search = searches[tab] ?? ""; const setSearch = (v: string) => setSearches((s) => ({ ...s, [tab]: v })); const [viewer, setViewer] = useState<{ path: string; data: CcFileResponse | null; error: string | null; } | null>(null); const [editor, setEditor] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const setToast = showToast; const [backupsOpen, setBackupsOpen] = useState(false); const [projectMemoryOpen, setProjectMemoryOpen] = useState(false); const scopeRef = useRef(scope); scopeRef.current = scope; /** Reload some page-state keys; one failing endpoint never blanks the rest. */ const fetchKeys = useCallback(async (keys: (keyof PageState)[]) => { setLoading(true); const results = await Promise.allSettled(keys.map((k) => loadKey(k, scopeRef.current))); const patch: Partial = {}; const errs: Partial> = {}; results.forEach((r, i) => { const k = keys[i]!; if (r.status === "fulfilled") (patch as Record)[k] = r.value; else errs[k] = r.reason instanceof Error ? r.reason.message : "unknown error"; }); setData((d) => ({ ...d, ...patch })); setErrors((e) => { const next = { ...e }; for (const k of keys) delete next[k]; return { ...next, ...errs }; }); setLastUpdated(new Date()); setLoading(false); }, []); const fetchAll = useCallback(() => fetchKeys(ALL_KEYS), [fetchKeys]); useEffect(() => { void fetchAll(); }, [fetchAll]); // A scope change reloads only the four lists it filters. const firstScope = useRef(true); useEffect(() => { if (firstScope.current) { firstScope.current = false; return; } void fetchKeys(SCOPED_KEYS); }, [scope, fetchKeys]); // Live updates - refetch what the server says changed (dashboard mutations // or external file edits picked up by the cc-watcher). Debounced because a // single user action can write multiple files (e.g. a skill backup + the // skill itself + the file-history snapshot all land within tens of ms). const refetchTimerRef = useRef | null>(null); const pendingKeys = useRef(new Set()); useEffect(() => { return eventBus.subscribe((msg) => { if (msg.type !== "cc_config_changed") return; for (const k of keysForChange((msg.data ?? {}) as Parameters[0])) pendingKeys.current.add(k); if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current); refetchTimerRef.current = setTimeout(() => { refetchTimerRef.current = null; const keys = [...pendingKeys.current]; pendingKeys.current.clear(); void fetchKeys(keys); }, 250); }); }, [fetchKeys]); useEffect(() => { return () => { if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current); }; }, []); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); const projectRoot = data.overview?.roots.projectRoot ?? null; // Project files under the plugin cache are wiped by the next plugin update. const projectDisabled = isPluginCachePath(projectRoot); useEffect(() => { if (projectDisabled && scope === "project") setScope("all"); }, [projectDisabled, scope]); const tabErrors = TAB_KEYS[tab].map((k) => errors[k]).filter(Boolean) as string[]; const openViewer = useCallback(async (path: string) => { setViewer({ path, data: null, error: null }); try { const file = await api.ccConfig.file(path); setViewer({ path, data: file, error: null }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "unknown error"; setViewer({ path, data: null, error: msg }); } }, []); const openCreate = useCallback( (type: CcArtifactType, overrideScope?: "user" | "project") => { const tplKey = `edit.templates.${type}`; const template = t(tplKey); const defaultScope: "user" | "project" = overrideScope ?? (scope === "project" && !projectDisabled ? "project" : "user"); setEditor({ mode: "create", type, defaultScope, template }); }, [scope, t, projectDisabled] ); const openEdit = useCallback( (type: CcArtifactType, item: { scope: "user" | "project"; name: string; filePath: string }) => { setEditor({ mode: "edit", type, scope: item.scope, name: item.name, filePath: item.filePath, }); }, [] ); const openDelete = useCallback( ( type: CcArtifactType, scopeArg: "user" | "project", name: string | undefined, path: string ) => { setConfirmDelete({ type, scope: scopeArg, name, path }); }, [] ); // ── Auto-memory (per-project file-based memory) create / edit / delete ── const openCreateAuto = useCallback( (project: string) => { setEditor({ mode: "create", type: "auto-memory", defaultScope: "user", // unused for auto-memory; scope is fixed template: t("edit.templates.auto-memory"), project, }); }, [t] ); const openEditAuto = useCallback((item: CcMemoryItem) => { if (!item.project || !item.name) return; setEditor({ mode: "edit", type: "auto-memory", scope: "auto-memory", name: item.name, filePath: item.file, project: item.project, }); }, []); const openDeleteAuto = useCallback((item: CcMemoryItem) => { if (!item.project || !item.name) return; setConfirmDelete({ type: "auto-memory", scope: "auto-memory", name: item.name, path: item.file, project: item.project, }); }, []); const handleSave = useCallback( async (args: { type: CcArtifactType; targetScope: "user" | "project" | "auto-memory"; name: string | undefined; content: string; project?: string; }) => { const result: CcMutationResult = await api.ccConfig.write({ scope: args.targetScope, type: args.type, name: args.name, content: args.content, project: args.project, }); setEditor(null); setToast({ kind: "success", message: result.created ? t("edit.saveSuccessNew") : t("edit.saveSuccess", { path: result.backupPath || "-" }), }); void fetchAll(); }, [fetchAll, t] ); // Create CLAUDE.md in ANOTHER project's directory. Deliberately not part of // `handleSave`: that one writes this machine's user/project layers through // the artifact writer, while this names its own target directory. const handleCreateProjectMemory = useCallback( async (args: { dir: string; content: string }) => { const result = await api.ccConfig.createProjectMemory(args); setProjectMemoryOpen(false); setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) }); void fetchAll(); return result.file; }, [fetchAll, t] ); const handleDelete = useCallback(async () => { if (!confirmDelete) return; try { const result = await api.ccConfig.delete({ scope: confirmDelete.scope, type: confirmDelete.type, name: confirmDelete.name, project: confirmDelete.project, }); setConfirmDelete(null); setToast({ kind: "success", message: t("edit.deleteSuccess", { path: result.backupPath || "-" }), }); void fetchAll(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "unknown error"; setConfirmDelete(null); setToast({ kind: "error", message: t("edit.deleteError", { message: msg }) }); } }, [confirmDelete, fetchAll, t]); return (
{ void fetchAll(); setAgenthubKey((k) => k + 1); }} onOpenBackups={() => setBackupsOpen(true)} wsConnected={wsConnected} /> void setTab(k)} counts={data.overview?.counts} />
{tabErrors.length > 0 && (
{t("loadError", { message: tabErrors.join(" · ") })}
)} {tab !== "overview" && (
setSearch(e.target.value)} placeholder={t("common.search")} aria-label={t("common.search")} className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1" /> {search && ( )}
{SCOPED_TABS.includes(tab) && ( )} {isMutable(tab) && tab !== "memory" && ( )} {/* The memory tab's own "new" is a different thing: the two CLAUDE.md cards below are this machine's user + current-project files, while this creates one in ANOTHER project's directory. */} {tab === "memory" && ( )}
)}
openCreate("memory", s)} onEditAuto={openEditAuto} onDeleteAuto={openDeleteAuto} onCreateAuto={openCreateAuto} onChanged={fetchAll} onToast={setToast} onDirty={setDirty} />
{/* AgentHub's own Claude Code settings follow the overview, outside the list's search box and scope. */} {tab === "overview" && ( void fetchAll()} /> )} {viewer && setViewer(null)} />} {editor && ( api.ccConfig.file(p)} onClose={() => setEditor(null)} onSave={handleSave} projectDisabled={projectDisabled} /> )} {confirmDelete && ( setConfirmDelete(null)} onConfirm={handleDelete} /> )} {projectMemoryOpen && ( setProjectMemoryOpen(false)} onCreate={handleCreateProjectMemory} /> )} {backupsOpen && setBackupsOpen(false)} />}
); } // ── Header ──────────────────────────────────────────────────────────── interface HeaderProps { loading: boolean; lastUpdated: Date | null; onRefresh: () => void; onOpenBackups: () => void; wsConnected: boolean; } /** The page's toolbar. No title of its own: the Settings hub already draws one. */ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) { const { t } = useTranslation("ccConfig"); const settingsHref = useSettingsHref(); const { t: tCommon } = useTranslation("common"); const formatted = lastUpdated ? lastUpdated.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", }) : "-"; return (
{wsConnected ? ( {tCommon("live")} ) : ( {tCommon("offline")} )}

{t("subtitle")}

{/* Every AI tool's login profiles live in one place: Settings → AI tools. */} {t("profiles.openButton")}
{lastUpdated && ( {t("lastUpdated", { time: formatted })} )}
); } /** All / User / Project for the four scoped lists, naming the folder "Project" * resolves to — and refusing it when that folder is the plugin cache. */ function ScopeToggle({ value, onChange, projectRoot, projectDisabled, }: { value: CcScope; onChange: (s: CcScope) => void; projectRoot: string | null; projectDisabled: boolean; }) { const { t } = useTranslation("ccConfig"); const opts: { v: CcScope; label: string }[] = [ { v: "all", label: t("scope.all") }, { v: "user", label: t("scope.user") }, { v: "project", label: t("scope.project") }, ]; return (
{opts.map((o) => { const disabled = o.v === "project" && projectDisabled; return ( ); })}
{projectRoot && ( {t("scope.projectIs", { path: projectRoot })} )}
); } // ── Tabs ────────────────────────────────────────────────────────────── interface TabsProps { current: TabKey; onSelect: (k: TabKey) => void; counts?: CcOverview["counts"]; } function Tabs({ current, onSelect, counts }: TabsProps) { const { t } = useTranslation("ccConfig"); const countFor = (key: TabKey): number | null => { if (!counts) return null; switch (key) { case "skills": return counts.skills.user + counts.skills.project; case "agents": return counts.agents.user + counts.agents.project; case "commands": return counts.commands.user + counts.commands.project; case "outputStyles": return counts.outputStyles.user + counts.outputStyles.project; case "plugins": return counts.plugins; case "marketplaces": return counts.marketplaces; case "keybindings": return counts.keybindings; case "mcp": return counts.mcpServers.user + counts.mcpServers.project; case "hooks": return Object.values(counts.hooks).reduce((a, b) => a + b, 0); case "settings": return counts.settingsFiles; case "memory": return counts.memory; default: return null; } }; return ( ({ key, icon, label: t(i18nKey), count: countFor(key), group: group ? t(group) : undefined, }))} current={current} onSelect={onSelect} testIdPrefix="cc-tab" panelId="cc-tabpanel" ariaLabel={t("title")} /> ); } // ── Tab panel switch ────────────────────────────────────────────────── interface TabPanelProps { tab: TabKey; data: PageState; search: string; onOpenFile: (path: string) => void; onEdit: ( type: CcArtifactType, item: { scope: "user" | "project"; name: string; filePath: string } ) => void; onDelete: ( type: CcArtifactType, scope: "user" | "project", name: string | undefined, path: string ) => void; onCreateMemory: (scope: "user" | "project") => void; onEditAuto: (item: CcMemoryItem) => void; onDeleteAuto: (item: CcMemoryItem) => void; onCreateAuto: (project: string) => void; /** Refetch every config surface after a mutation. */ onChanged: () => void; onToast: (toast: NonNullable) => void; /** An editor reports unsaved edits (by id), so a tab switch asks first. */ onDirty: OnDirty; } function TabPanel({ tab, data, search, onOpenFile, onEdit, onDelete, onCreateMemory, onEditAuto, onDeleteAuto, onCreateAuto, onChanged, onToast, onDirty, }: TabPanelProps) { switch (tab) { case "overview": return ; case "skills": return ( ); case "agents": return ( ); case "commands": return ( ); case "outputStyles": return ( ); case "plugins": return ( ); case "marketplaces": return ( ); case "mcp": return ( ); case "hooks": return ( ); case "keybindings": return ( ); case "settings": return ( ); case "memory": return ( ); default: return null; } } // ── Overview ────────────────────────────────────────────────────────── // Tone palette - each tone is { iconBg, iconText, border, accentBar }. // Used by both root rows and summary stat tiles for a consistent color story. type Tone = | "sky" | "emerald" | "violet" | "amber" | "fuchsia" | "cyan" | "pink" | "indigo" | "orange" | "teal" | "slate" | "rose"; const TONES: Record = { sky: { iconBg: "bg-sky-500/10", iconText: "text-sky-700 dark:text-sky-300", bar: "bg-sky-500/40", ring: "ring-sky-500/20", }, emerald: { iconBg: "bg-status-success/10", iconText: "text-status-success", bar: "bg-status-success/40", ring: "ring-status-success/20", }, violet: { iconBg: "bg-violet-500/10", iconText: "text-violet-700 dark:text-violet-300", bar: "bg-violet-500/40", ring: "ring-violet-500/20", }, amber: { iconBg: "bg-status-warning/10", iconText: "text-status-warning", bar: "bg-status-warning/40", ring: "ring-status-warning/20", }, fuchsia: { iconBg: "bg-fuchsia-500/10", iconText: "text-fuchsia-700 dark:text-fuchsia-300", bar: "bg-fuchsia-500/40", ring: "ring-fuchsia-500/20", }, cyan: { iconBg: "bg-cyan-500/10", iconText: "text-cyan-700 dark:text-cyan-300", bar: "bg-cyan-500/40", ring: "ring-cyan-500/20", }, pink: { iconBg: "bg-pink-500/10", iconText: "text-pink-700 dark:text-pink-300", bar: "bg-pink-500/40", ring: "ring-pink-500/20", }, indigo: { iconBg: "bg-indigo-500/10", iconText: "text-indigo-700 dark:text-indigo-300", bar: "bg-indigo-500/40", ring: "ring-indigo-500/20", }, orange: { iconBg: "bg-orange-500/10", iconText: "text-orange-700 dark:text-orange-300", bar: "bg-orange-500/40", ring: "ring-orange-500/20", }, teal: { iconBg: "bg-teal-500/10", iconText: "text-teal-700 dark:text-teal-300", bar: "bg-teal-500/40", ring: "ring-teal-500/20", }, slate: { iconBg: "bg-surface-4/10", iconText: "text-fg-secondary", bar: "bg-surface-4/40", ring: "ring-border-light/20", }, rose: { iconBg: "bg-rose-500/10", iconText: "text-rose-700 dark:text-rose-300", bar: "bg-rose-500/40", ring: "ring-rose-500/20", }, }; function OverviewPanel({ overview }: { overview: CcOverview | null }) { const { t } = useTranslation("ccConfig"); if (!overview) return ; const { roots, counts } = overview; return (

{t("overview.rootsTitle")}

{t("overview.summary")}

a + b, 0)} />
); } interface SummaryStatProps { tone: Tone; icon: typeof Sparkles; label: string; // Either a single value, OR a user/project pair (which is summed for the headline number). value?: number; user?: number; project?: number; } function SummaryStat({ tone, icon: Icon, label, value, user, project }: SummaryStatProps) { const { t } = useTranslation("ccConfig"); const T = TONES[tone]; const total = value !== undefined ? value : (user ?? 0) + (project ?? 0); const showBreakdown = user !== undefined && project !== undefined; return (
{/* Left accent bar */}
{label}
{total} {showBreakdown && ( {user} {t("overview.user")} · {project} {t("overview.project")} )}
); } function RootRow({ icon: Icon, tone, label, value, }: { icon: typeof FolderTree; tone: Tone; label: string; value: string; }) { const T = TONES[tone]; return (
{label}
{value}
); } // ── Plugins ─────────────────────────────────────────────────────────── /** * Shared runner for the `claude`-CLI-backed actions (plugins, marketplaces) * and the settings key-merge. Tracks which row is mid-flight so its buttons * can disable, and funnels both outcomes into the page's toast. */ function useConfigAction(onToast: (t: NonNullable) => void, onChanged: () => void) { const [busy, setBusy] = useState(null); const run = useCallback( async (id: string, fn: () => Promise, successMessage: string) => { setBusy(id); try { await fn(); onToast({ kind: "success", message: successMessage }); onChanged(); return true; } catch (err: unknown) { onToast({ kind: "error", message: err instanceof Error ? err.message : "unknown error", }); return false; } finally { setBusy(null); } }, [onChanged, onToast] ); return { busy, run }; } /** * Does this look like a marketplace SOURCE (`owner/repo`, an http(s) URL, or an * absolute path) rather than a plugin spec (`name` / `name@marketplace`)? Lets * one input field accept either — pasting a GitHub link registers the * marketplace, typing a spec installs the plugin. The server re-validates; this * only decides which endpoint to call. */ function looksLikeMarketplaceSource(value: string): boolean { const v = value.trim(); if (!v) return false; if (/^https?:\/\//i.test(v)) return true; if (v.startsWith("/")) return true; return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v); } /** Small action button used across the plugin/marketplace cards. */ function ActionButton({ onClick, disabled, danger, icon: Icon, children, }: { onClick: () => void; disabled?: boolean; danger?: boolean; icon: typeof Sparkles; children: React.ReactNode; }) { return ( ); } /** Icon + colour per batch-step state. */ type StepLook = { Icon: typeof CheckCircle2; className: string }; const STEP_FALLBACK: StepLook = { Icon: MinusCircle, className: "text-fg-muted" }; const STEP_LOOK: Record = { running: { Icon: RefreshCw, className: "text-accent animate-spin" }, ok: { Icon: CheckCircle2, className: "text-status-success" }, failed: { Icon: XCircle, className: "text-status-error" }, skipped: { Icon: MinusCircle, className: "text-fg-muted" }, }; /** * "Update all" + "Reload sessions" for the whole Claude Code install. * * The batch runs in a detached server-side worker and is followed by polling * rather than by the POST's response, because the last step restarts the * dashboard when AgentHub is itself a plugin install. Polling therefore has to * tolerate the server disappearing mid-batch: a failed poll is ignored and * retried, never rendered as an error, because the most likely cause is the * successful restart this button asked for. */ function UpdateAllCard({ onToast, onChanged, }: { onToast: (t: NonNullable) => void; onChanged: () => void; }) { const { t } = useTranslation("ccConfig"); const [batch, setBatch] = useState(null); const [starting, setStarting] = useState(false); const [reloading, setReloading] = useState(false); const running = batch?.status === "running"; const poll = useCallback(async () => { try { const res = await api.ccConfig.updateAllStatus(); setBatch(res.batch); return res.batch; } catch { // Server is down (very likely the AgentHub restart we triggered) — keep // the last known steps on screen and try again on the next tick. return undefined; } }, []); // Pick up a batch already in flight, including one started by the server we // were talking to BEFORE it restarted itself. useEffect(() => { void poll(); }, [poll]); useEffect(() => { if (!running) return; const id = setInterval(() => void poll(), 2000); return () => clearInterval(id); }, [running, poll]); // Refresh the surrounding inventory once the batch stops moving, so version // numbers and install paths catch up without a manual reload. const prevStatus = useRef(undefined); useEffect(() => { if (prevStatus.current === "running" && batch?.status !== "running") onChanged(); prevStatus.current = batch?.status; }, [batch?.status, onChanged]); const start = useCallback(async () => { setStarting(true); try { const res = await api.ccConfig.updateAll(); onToast({ kind: "success", message: t("updateAll.started", { count: res.planned }) }); await poll(); } catch (err: unknown) { onToast({ kind: "error", message: err instanceof Error ? err.message : t("updateAll.failed"), }); } finally { setStarting(false); } }, [onToast, poll, t]); const reloadOnly = useCallback(async () => { setReloading(true); try { const res = await api.ccConfig.reload(); onToast({ kind: "success", message: res.lanes_total > 0 ? t("updateAll.reloaded", { count: res.lanes_total }) : t("updateAll.reloadedNone"), }); } catch (err: unknown) { onToast({ kind: "error", message: err instanceof Error ? err.message : t("updateAll.failed"), }); } finally { setReloading(false); } }, [onToast, t]); return (
{t("updateAll.title")}

{t("updateAll.help")}

{batch && (
{batch.steps.map((step) => { const look = STEP_LOOK[step.status] ?? STEP_FALLBACK; return (
{step.label} {step.note && — {step.note}} {step.error && ( — {step.error} )} {step.key === "reload" && step.lanes_total !== undefined && ( {t("updateAll.reloadedCount", { count: step.lanes_total })} )}
); })} {batch.status === "abandoned" && (

{t("updateAll.abandoned")}

)} {batch.bootstrap_error && (

{t("updateAll.bootstrapFailed", { error: batch.bootstrap_error })}

)} {running &&

{t("updateAll.restartWarning")}

}
)}
); } function PluginsPanel({ data, search, onToast, onChanged, }: { data: CcPluginsResponse | null; search: string; onToast: (t: NonNullable) => void; onChanged: () => void; }) { const { t } = useTranslation("ccConfig"); const { busy, run } = useConfigAction(onToast, onChanged); const [input, setInput] = useState(""); const [available, setAvailable] = useState(null); const [availableError, setAvailableError] = useState(null); const availableCtx = useContextMenu(); const install = (a: CcAvailablePlugin) => void run( a.pluginId, () => api.ccConfig.installPlugin(a.pluginId), t("manage.installed", { name: a.pluginId }) ); // The available inventory comes from the CLI, which may not be installed — // a failure here is a normal state, not a page error. const loadAvailable = useCallback(async () => { try { const res = await api.ccConfig.pluginsAvailable(); setAvailable(res.available); setAvailableError(null); } catch (err: unknown) { setAvailable([]); setAvailableError(err instanceof Error ? err.message : "unknown error"); } }, []); useEffect(() => { void loadAvailable(); }, [loadAvailable, data]); const submit = useCallback(async () => { const value = input.trim(); if (!value) return; const isSource = looksLikeMarketplaceSource(value); const ok = await run( "add", () => (isSource ? api.ccConfig.addMarketplace(value) : api.ccConfig.installPlugin(value)), isSource ? t("manage.marketplaceAdded", { name: value }) : t("manage.installed", { name: value }) ); if (ok) { setInput(""); void loadAvailable(); } }, [input, loadAvailable, run, t]); if (!data) return ; const needle = search.toLowerCase(); const filtered = data.plugins.filter((p) => !search || p.key.toLowerCase().includes(needle)); const installedKeys = new Set(data.plugins.map((p) => p.key)); const installable = (available ?? []).filter( (a) => !installedKeys.has(a.pluginId) && (!search || a.pluginId.toLowerCase().includes(needle) || (a.description || "").toLowerCase().includes(needle)) ); return (
{t("manage.installTitle")}
setInput(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e)) void submit(); }} placeholder={t("manage.installPlaceholder")} aria-label={t("manage.installTitle")} className="h-8 flex-1 min-w-60 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />

{t("manage.installHelp")}

{data.manifestPath} {!data.manifestExists && ( {t("plugins.manifestMissing", { path: "" })} )}
{filtered.length === 0 ? ( ) : ( filtered.map((p) => ) )} {installable.length > 0 && (

{t("manage.availableTitle")}

{installable.map((a) => (
availableCtx.open(e, a)} className="rounded-lg border border-border bg-surface-2 px-4 py-2.5 flex items-start gap-3" >
{a.name} {a.marketplaceName && ( {a.marketplaceName} )}
{a.description && (

{a.description}

)}
install(a)}> {busy === a.pluginId ? t("manage.working") : t("manage.install")}
))}
{availableCtx.menu && ( { availableCtx.close(); install(availableCtx.menu!.item); }} > {t("manage.install")} { availableCtx.close(); void copyText(availableCtx.menu!.item.pluginId); }} > {t("common.copyName")} )}
)} {availableError && (
{t("manage.cliUnavailable", { message: availableError })}
)}
); } function PluginCard({ plugin: p, busy, run, }: { plugin: CcPlugin; busy: string | null; run: (id: string, fn: () => Promise, message: string) => Promise; }) { const { t } = useTranslation("ccConfig"); // Uninstall takes two clicks rather than a modal: the action is reversible // (reinstall), so an accidental single click is the only thing to guard. // useArmed: the armed state disarms by itself after 3 s, so a stray click // minutes later can never complete the uninstall. const uninstallArm = useArmed(); const confirmUninstall = uninstallArm.isArmed("uninstall"); const setConfirmUninstall = (on: boolean) => on ? uninstallArm.arm("uninstall") : uninstallArm.disarm(); // Which contribution surface is expanded, if any. One at a time: the card // sits in a list, and two open lists push everything below off the screen. const [openSurface, setOpenSurface] = useState(null); const ctx = useContextMenu(); const toggle = () => void run( p.key, () => api.ccConfig.pluginAction(p.key, p.enabled === false ? "enable" : "disable"), t(p.enabled === false ? "manage.enabled" : "manage.disabled", { name: p.key }) ); const update = () => void run( p.key, // reload=true: updating one plugin reloads the open sessions // right away. The batch reloads once at the end instead. () => api.ccConfig.pluginAction(p.key, "update", true), t("manage.updated", { name: p.key }) ); const uninstall = () => { if (!confirmUninstall) { setConfirmUninstall(true); return; } setConfirmUninstall(false); void run( p.key, () => api.ccConfig.pluginAction(p.key, "uninstall"), t("manage.uninstalled", { name: p.key }) ); }; const meta = p.contributes?.pluginJson; const description = meta?.description; const contribCounts: { key: string; count: number; label: string; items?: CcPluginItem[] }[] = []; if (p.contributes) { const items = p.contributes.items; if (p.contributes.skills > 0) contribCounts.push({ key: "skills", count: p.contributes.skills, label: t("plugins.skills", { count: p.contributes.skills }), items: items?.skills, }); if (p.contributes.agents > 0) contribCounts.push({ key: "agents", count: p.contributes.agents, label: t("plugins.agents", { count: p.contributes.agents }), items: items?.agents, }); if (p.contributes.commands > 0) contribCounts.push({ key: "commands", count: p.contributes.commands, label: t("plugins.commands", { count: p.contributes.commands }), items: items?.commands, }); if (p.contributes.outputStyles > 0) contribCounts.push({ key: "outputStyles", count: p.contributes.outputStyles, label: t("plugins.outputStyles", { count: p.contributes.outputStyles }), items: items?.outputStyles, }); // Hooks have no per-item list: a plugin's hooks are matcher/command pairs // declared inline in plugin.json, not addressable artifacts with names. if (p.contributes.hooks > 0) contribCounts.push({ key: "hooks", count: p.contributes.hooks, label: t("plugins.hooks", { count: p.contributes.hooks }), }); } const opened = contribCounts.find((c) => c.key === openSurface && c.items?.length); return (
ctx.open(e, true)} className="rounded-lg border border-border bg-surface-2 px-4 py-3" > {ctx.menu && ( { ctx.close(); toggle(); }} > {p.enabled === false ? t("manage.enable") : t("manage.disable")} { ctx.close(); update(); }} > {t("manage.update")} { ctx.close(); void copyText(p.key); }} > {t("common.copyName")} { if (confirmUninstall) ctx.close(); uninstall(); }} > {confirmUninstall ? t("manage.confirmUninstall") : t("manage.uninstall")} )}
{p.name} {p.marketplace && ( {p.marketplace} )} {p.version && ( v{p.version} )} {p.enabled === true && ( {t("plugins.enabled")} )} {p.enabled === false && ( {t("plugins.disabled")} )} {!p.installPathExists && ( {t("plugins.missing")} )}
{description && (

{description}

)} {contribCounts.length > 0 && (
{t("plugins.contributes")}
{contribCounts.map((c) => c.items?.length ? ( ) : ( {c.label} ) )}
{opened && (
    {opened.items?.map((item) => (
  • {opened.key === "commands" ? `/${item.name}` : item.name} {item.description && (

    {item.description}

    )}
  • ))}
)}
)}
{meta?.author?.name && (
{t("plugins.author")}: {meta.author.name}
)} {meta?.license && (
{t("plugins.license")}: {meta.license}
)} {p.installedAt && (
{t("plugins.installedAt")}:{" "} {new Date(p.installedAt).toLocaleString()}
)} {p.lastUpdated && (
{t("plugins.lastUpdated")}:{" "} {new Date(p.lastUpdated).toLocaleString()}
)} {p.gitCommitSha && (
SHA:{" "} {p.gitCommitSha.slice(0, 12)}
)} {meta?.homepage && (
{t("plugins.homepage")}:{" "} {meta.homepage}
)}
{p.installPath && (
{p.installPath}
)}
{p.enabled === false ? ( {t("manage.enable")} ) : ( {t("manage.disable")} )} {t("manage.update")} {busy === p.key ? t("manage.working") : confirmUninstall ? t("manage.confirmUninstall") : t("manage.uninstall")}
); } // ── MCP servers ─────────────────────────────────────────────────────── /** Splits a pasted command line on whitespace — good enough for the common * `npx -y @scope/server --flag value` case. Skipped a full shell-arg * parser (quoted args with embedded spaces won't survive); add one if a * real server needs it. */ function splitCommandLine(value: string): [string, string[]] { const parts = value.trim().split(/\s+/).filter(Boolean); return [parts[0] || "", parts.slice(1)]; } function McpPanel({ data, search, onToast, onChanged, onDirty, }: { data: CcMcpResponse | null; search: string; onToast: (t: NonNullable) => void; onChanged: () => void; onDirty?: OnDirty; }) { const { t } = useTranslation("ccConfig"); const { busy, run } = useConfigAction(onToast, onChanged); const [name, setName] = useState(""); const [scope, setScope] = useState("user"); const [transport, setTransport] = useState<"stdio" | "http">("stdio"); const [commandLine, setCommandLine] = useState(""); const [url, setUrl] = useState(""); useReportDirty("mcp:add", !!(name.trim() || commandLine.trim() || url.trim()), onDirty); const canSubmit = name.trim().length > 0 && (transport === "stdio" ? commandLine.trim() : url.trim()); const submit = useCallback(async () => { const n = name.trim(); if (!n) return; const args: CcMcpAddArgs = transport === "http" ? { name: n, scope, transport: "http", url: url.trim() } : (() => { const [command, cmdArgs] = splitCommandLine(commandLine); return { name: n, scope, command, args: cmdArgs }; })(); const ok = await run("add", () => api.ccConfig.mcpAdd(args), t("manage.mcpAdded", { name: n })); if (ok) { setName(""); setCommandLine(""); setUrl(""); } }, [commandLine, name, run, scope, t, transport, url]); if (!data) return ; const all = [...data.user, ...data.projectScoped]; const filter = (arr: CcMcpServer[]) => arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase())); return (
{t("manage.addMcpTitle")}
setName(e.target.value)} placeholder={t("manage.addMcpNamePlaceholder")} aria-label={t("mcp.name")} className="h-8 w-40 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />
{(["stdio", "http"] as const).map((k) => ( ))}
{transport === "stdio" ? ( setCommandLine(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e)) void submit(); }} placeholder={t("manage.addMcpCommandPlaceholder")} aria-label={t("mcp.command")} className="h-8 w-full bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" /> ) : ( setUrl(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e)) void submit(); }} placeholder={t("manage.addMcpUrlPlaceholder")} aria-label={t("mcp.url")} className="h-8 w-full bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" /> )}

{t("manage.addMcpHelp")}

{all.length === 0 && (
{t("mcp.noServers")}
)} {data.user.length > 0 && (

{t("mcp.userScope")}

{filter(data.user).map((s) => ( ))}
)} {data.projectScoped.length > 0 && (

{t("mcp.projectScope")}

{filter(data.projectScoped).map((s) => ( ))}
)}
); } function McpCard({ server, scope, run, busy, t, }: { server: CcMcpServer; scope: CcMcpScope; run: (id: string, fn: () => Promise, successMessage: string) => Promise; busy: string | null; t: (key: string, opts?: Record) => string; }) { const removeArm = useArmed(); const confirmRemove = removeArm.isArmed("remove"); const setConfirmRemove = (on: boolean) => (on ? removeArm.arm("remove") : removeArm.disarm()); const ctx = useContextMenu(); const busyId = `${scope}:${server.name}`; // The reader decides this: entries from ~/.claude/settings.json have no CLI // scope that addresses them, so a Remove button would look live and never // work. Reading it off a flag rather than matching `source` keeps a reworded // display string from silently bringing that button back. const removable = server.removable !== false; const remove = () => { if (!confirmRemove) { setConfirmRemove(true); return; } setConfirmRemove(false); void run( busyId, () => api.ccConfig.mcpRemove(server.name, scope), t("manage.mcpRemoved", { name: server.name }) ); }; return (
ctx.open(e, true)} className="rounded-lg border border-border bg-surface-2 px-4 py-3" > {ctx.menu && ( { ctx.close(); void copyText(server.name); }} > {t("common.copyName")} {removable && ( <> { if (confirmRemove) ctx.close(); remove(); }} > {confirmRemove ? t("manage.confirmRemove") : t("manage.remove")} )} )}
{server.name} {server.kind} {server.source}
{server.kind === "stdio" && ( <> {server.command} {server.args && server.args.length > 0 && ( {server.args.join(" ")} )} {server.envNames && server.envNames.length > 0 && ( {server.envNames.join(", ")} )} )} {server.kind === "http" && ( <> {server.url} {server.headers && server.headers.length > 0 && ( {server.headers.join(", ")} )} )}
{removable ? ( {busy === busyId ? t("manage.working") : confirmRemove ? t("manage.confirmRemove") : t("manage.remove")} ) : (

{t("mcp.notRemovable")}

)}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}: {children}
); } // ── Hooks ───────────────────────────────────────────────────────────── /** * `readHooks` FLATTENS settings.json's `[{matcher, hooks:[…]}]` into one row * per command; this rebuilds the nested shape the file actually stores, * regrouping consecutive rows that share a matcher. Rows the editor never * created (an unknown event's raw objects) are filtered out upstream — those * events are simply never sent, so they stay untouched on disk. */ function toNestedHooks(entries: CcHookEntry[]): unknown[] { const groups: { matcher: string; hooks: Record[] }[] = []; for (const e of entries) { const matcher = e.matcher || "*"; let group = groups.find((g) => g.matcher === matcher); if (!group) { group = { matcher, hooks: [] }; groups.push(group); } const hook: Record = { type: e.type || "command", command: e.command || "", }; if (e.timeout != null) hook.timeout = e.timeout; group.hooks.push(hook); } return groups; } /** A hook row the editor can round-trip — i.e. one `readHooks` flattened. */ function isEditableHookEntry(entry: unknown): entry is CcHookEntry { return !!entry && typeof entry === "object" && "command" in (entry as object); } function HooksPanel({ sources, scripts, search, onOpen, onToast, onChanged, onDirty, }: { sources: CcHookSource[] | null; scripts: CcHookScripts | null; search: string; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => void; onDirty?: OnDirty; }) { const { t } = useTranslation("ccConfig"); if (!sources) return ; return (
{sources.map((src) => ( ))} {scripts && scripts.items.length > 0 && (
{t("hookScripts.title")}

{t("hookScripts.subtitle")}

{scripts.dir}
{scripts.items.map((s) => ( ))}
)}
); } /** * One settings layer's hooks, readable and editable. Saving writes ONLY the * `hooks.` keys this editor owns, through `PATCH /settings`'s key * merge — every other key in the file, and every event whose entries the * editor could not parse, is left exactly as it was. */ function HookSourceBlock({ source: src, search, onOpen, onToast, onChanged, onDirty, }: { source: CcHookSource; search: string; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => void; onDirty?: OnDirty; }) { const { t } = useTranslation("ccConfig"); const { busy, run } = useConfigAction(onToast, onChanged); const [draft, setDraft] = useState<{ event: string; entries: CcHookEntry[] }[] | null>(null); const [err, setErr] = useState(null); // Snapshot taken at Edit: the events this editor owns and the file's hooks // as they were. Save unsets only events from THIS snapshot, so an event // written meanwhile (a session, a terminal) is never deleted. const base = useRef<{ events: string[]; hooks: string; draft: string } | null>(null); const dirty = draft !== null && JSON.stringify(draft) !== base.current?.draft; useReportDirty(`hooks:${src.scope}`, dirty, onDirty); const editableEvents = useMemo( () => Object.entries(src.hooks).filter(([, entries]) => (entries as unknown[]).every(isEditableHookEntry) ), [src.hooks] ); const readOnlyEvents = useMemo( () => Object.entries(src.hooks).filter( ([, entries]) => !(entries as unknown[]).every(isEditableHookEntry) ), [src.hooks] ); const startEdit = () => { setErr(null); const next = editableEvents.map(([event, entries]) => ({ event, entries: entries.map((e) => ({ ...e })), })); base.current = { events: editableEvents.map(([event]) => event), hooks: JSON.stringify(src.hooks), draft: JSON.stringify(next), }; setDraft(next); }; const save = async () => { if (!draft) return; const set: Record = {}; const seen = new Set(); for (const { event, entries } of draft) { const name = event.trim(); if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) { setErr(t("manage.hookBadEvent", { event: event || "-" })); return; } if (seen.has(name)) { setErr(t("manage.hookDupEvent", { event: name })); return; } // An event the editor could not parse is left on disk untouched; a new // row with its name would replace those entries. if (readOnlyEvents.some(([e]) => e === name)) { setErr(t("manage.hookReadOnlyEvent", { event: name })); return; } seen.add(name); if (entries.some((e) => !e.command || !e.command.trim())) { setErr(t("manage.hookEmptyCommand", { event: name })); return; } set[`hooks.${name}`] = toNestedHooks(entries); } // An event dropped from the draft is removed from the file; one that was // never editable here — or appeared after Edit — is never touched. const unset = (base.current?.events ?? []) .filter((event) => !seen.has(event)) .map((event) => `hooks.${event}`); if ( base.current && JSON.stringify(src.hooks) !== base.current.hooks && !(await confirmDialog({ title: t("edit.changedOnDiskTitle"), message: t("manage.hooksChangedBody"), confirmLabel: t("edit.overwrite"), })) ) return; setErr(null); const ok = await run( "save", () => api.ccConfig.patchSettings({ scope: src.scope, set, ...(unset.length ? { unset } : {}), }), t("manage.hooksSaved", { file: src.file }) ); if (ok) setDraft(null); }; const updateEntry = (ei: number, hi: number, field: keyof CcHookEntry, value: string) => setDraft((d) => d ? d.map((g, i) => i === ei ? { ...g, entries: g.entries.map((h, j) => j === hi ? { ...h, [field]: field === "timeout" ? (value ? Number(value) : null) : value, } : h ), } : g ) : d ); const filteredEvents = search ? Object.entries(src.hooks).filter(([event]) => event.toLowerCase().includes(search.toLowerCase()) ) : Object.entries(src.hooks); return (
{src.file} {src.exists ? ( ) : ( {t("hooks.fileMissing")} )} {draft ? (
{ if (dirty && !(await confirmDiscard())) return; setDraft(null); }} disabled={busy === "save"} > {t("edit.cancel")} void save()} disabled={busy === "save"}> {busy === "save" ? t("edit.saving") : t("edit.save")}
) : ( {t("edit.editButton")} )}
{err && (
{err}
)} {draft ? (
{draft.map((g, ei) => (
setDraft((d) => d ? d.map((x, i) => (i === ei ? { ...x, event: e.target.value } : x)) : d ) } placeholder={t("manage.hookEvent")} aria-label={t("manage.hookEvent")} className="h-7 flex-1 min-w-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />
{g.entries.map((h, hi) => (
updateEntry(ei, hi, "matcher", e.target.value)} placeholder={t("hooks.matcher")} aria-label={t("hooks.matcher")} className="h-7 w-32 flex-shrink-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" /> updateEntry(ei, hi, "timeout", e.target.value)} placeholder={t("hooks.timeout")} aria-label={t("hooks.timeout")} inputMode="numeric" className="h-7 w-24 flex-shrink-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />
updateEntry(ei, hi, "command", e.target.value)} placeholder={t("hooks.command")} aria-label={t("hooks.command")} className="h-7 w-full bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />
))}
setDraft((d) => d ? d.map((x, i) => i === ei ? { ...x, entries: [ ...x.entries, { matcher: "*", type: "command", command: "", timeout: null }, ], } : x ) : d ) } > {t("manage.hookAddEntry")}
))} {readOnlyEvents.length > 0 && (

{t("manage.hookUneditable", { events: readOnlyEvents.map(([e]) => e).join(", "), })}

)}
) : (
{filteredEvents.length === 0 ? (
{t("hooks.noHooks")}
) : (
{filteredEvents.map(([event, entries]) => (
{event} ({entries.length})
{entries.map((h, idx) => (
{t("hooks.matcher")}={h.matcher} · {h.type} {h.timeout != null && ( {h.timeout}ms )}
{h.command && (
{h.command}
)}
))}
))}
)}
)}
); } // ── Settings ────────────────────────────────────────────────────────── // The settings that the TUI's `/config` editor manages, in display order. // Surfaced as a resolved at-a-glance summary so the user sees what `/config` // set (model, verbose, theme, …) without hunting through the raw JSON files. // Keys map 1:1 to settings.json keys per https://code.claude.com/docs/en/settings. const CONFIG_OPTION_GROUPS: { title: string; keys: { key: string; label: string }[] }[] = [ { title: "model", keys: [ { key: "model", label: "Model" }, { key: "effortLevel", label: "Effort level" }, { key: "alwaysThinkingEnabled", label: "Always thinking" }, ], }, { title: "output", keys: [ { key: "outputStyle", label: "Output style" }, { key: "verbose", label: "Verbose output" }, { key: "theme", label: "Theme" }, { key: "language", label: "Language" }, { key: "spinnerTipsEnabled", label: "Spinner tips" }, { key: "autoScrollEnabled", label: "Auto-scroll" }, ], }, { title: "session", keys: [ { key: "autoCompactEnabled", label: "Auto-compact" }, { key: "fileCheckpointingEnabled", label: "File checkpointing" }, { key: "editorMode", label: "Editor mode" }, { key: "preferredNotifChannel", label: "Notifications" }, { key: "awaySummaryEnabled", label: "Away summary" }, ], }, ]; /** * Resolve each /config option across the settings sources (project-local > * project > user precedence - later sources in the array win) and render a * compact summary. Unset options show as "default" so the view reflects the * effective configuration, not just whatever happens to be written to a file. */ function CurrentConfigPanel({ sources }: { sources: CcSettingsSource[] }) { const { t } = useTranslation("ccConfig"); // Build effective map: { key → { value, scope } }. Sources arrive ordered // user → project → project-local, so a later hit overrides an earlier one. const effective = new Map(); for (const src of sources) { if (!src.exists || !src.data || typeof src.data !== "object") continue; const data = src.data as Record; for (const group of CONFIG_OPTION_GROUPS) { for (const { key } of group.keys) { if (Object.prototype.hasOwnProperty.call(data, key)) { effective.set(key, { value: data[key], scope: src.scope }); } } } } const setCount = effective.size; return (
{t("currentConfig.title")} {t("currentConfig.setCount", { count: setCount })}
{CONFIG_OPTION_GROUPS.map((group) => (
{t(`currentConfig.groups.${group.title}`)}
{group.keys.map(({ key, label }) => { const hit = effective.get(key); return (
{t(`currentConfig.keys.${key}`, { defaultValue: label })}
{hit ? ( ) : ( {t("currentConfig.default")} )}
{hit ? ( ) : ( - )}
); })}
))}
); } function SettingsPanel({ sources, statusline, onOpen, onToast, onChanged, onDirty, }: { sources: CcSettingsSource[] | null; statusline: CcStatusline | null; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => void; onDirty?: OnDirty; }) { const { t } = useTranslation("ccConfig"); if (!sources) return ; return (
{t("common.redactedNotice")}
{statusline && (statusline.config || statusline.scripts.length > 0) && ( )} {sources.map((src) => ( ))}
); } function StatuslineBlock({ data, onOpen }: { data: CcStatusline; onOpen: (p: string) => void }) { const { t } = useTranslation("ccConfig"); return (
{t("statusline.title")}
{data.config ? (
{t("statusline.configured")}
type: {data.config.type ?? "-"} {data.config.command && ( <>
command: {data.config.command} )}
) : (
{t("statusline.noStatusline")}
)} {data.scripts.length > 0 && (
{t("statusline.scripts")}
{data.scripts.map((s) => ( ))}
)}
); } /** A key whose value the API redacted cannot be edited here — echoing the * placeholder back would destroy the real secret (the server rejects it too). */ function isRedacted(value: unknown): boolean { try { return JSON.stringify(value)?.includes("") ?? false; } catch { return false; } } /** `origKey` is the key the row was opened with — a renamed row must still be * sent, or its value would be dropped with the unset old key. */ type SettingsDraftRow = { key: string; origKey: string; text: string; original: string; isNew: boolean; }; function SettingsBlock({ source, onOpen, onToast, onChanged, onDirty, }: { source: CcSettingsSource; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => void; onDirty?: OnDirty; }) { const { t } = useTranslation("ccConfig"); const { busy, run } = useConfigAction(onToast, onChanged); const [showRaw, setShowRaw] = useState(false); const [draft, setDraft] = useState(null); const [err, setErr] = useState(null); // Snapshot taken at Edit: the keys this editor owns and the file's data as it // was. Save unsets only keys from THIS snapshot, so a key written meanwhile // (the live CLI rewrites settings.json) is never deleted. const base = useRef<{ keys: string[]; data: string; draft: string } | null>(null); const dirty = draft !== null && JSON.stringify(draft) !== base.current?.draft; useReportDirty(`settings:${source.scope}`, dirty, onDirty); const data = (source.data as Record | null | undefined) || {}; // Redacted keys stay out of the draft and render read-only below the editor; // `save` also refuses a new or renamed row that names one, since `set` would // replace the real secret (e.g. the whole `env` object) with the typed value. const editableKeys = Object.entries(data).filter(([, v]) => !isRedacted(v)); const redactedKeys = Object.entries(data).filter(([, v]) => isRedacted(v)); const startEdit = () => { setErr(null); const next = editableKeys.map(([key, value]) => { const text = JSON.stringify(value, null, 2); return { key, origKey: key, text, original: text, isNew: false }; }); base.current = { keys: editableKeys.map(([key]) => key), data: JSON.stringify(data), draft: JSON.stringify(next), }; setDraft(next); }; const save = async () => { if (!draft) return; const set: Record = {}; const seen = new Set(); for (const row of draft) { const key = row.key.trim(); if (!key) { setErr(t("manage.settingsEmptyKey")); return; } if (seen.has(key)) { setErr(t("manage.settingsDupKey", { key })); return; } if (redactedKeys.some(([k]) => k === key)) { setErr(t("manage.settingsRedactedKey", { key })); return; } seen.add(key); // Only send what actually changed — an untouched key is left alone. if (!row.isNew && key === row.origKey && row.text === row.original) continue; try { set[key] = JSON.parse(row.text); } catch { setErr(t("manage.settingsBadJson", { key })); return; } } const unset = (base.current?.keys ?? []).filter((key) => !seen.has(key)); if (Object.keys(set).length === 0 && unset.length === 0) { setDraft(null); return; } if ( base.current && JSON.stringify(data) !== base.current.data && !(await confirmDialog({ title: t("edit.changedOnDiskTitle"), message: t("manage.settingsChangedBody"), confirmLabel: t("edit.overwrite"), })) ) return; setErr(null); const ok = await run( "save", () => api.ccConfig.patchSettings({ scope: source.scope, ...(Object.keys(set).length ? { set } : {}), ...(unset.length ? { unset } : {}), }), t("manage.settingsSaved", { file: source.file }) ); if (ok) setDraft(null); }; return (
{source.file} {source.exists ? ( <> ) : ( {t("settings.fileMissing")} )} {draft ? (
{ if (dirty && !(await confirmDiscard())) return; setDraft(null); }} disabled={busy === "save"} > {t("edit.cancel")} void save()} disabled={busy === "save"}> {busy === "save" ? t("edit.saving") : t("edit.save")}
) : ( {t("edit.editButton")} )}
{err && (
{err}
)} {draft ? (
{draft.map((row, i) => (
setDraft((d) => d ? d.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)) : d ) } placeholder={t("manage.settingsKeyPlaceholder")} aria-label={t("manage.settingsKey")} className="h-7 flex-1 min-w-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />