/** * @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. * @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. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## 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. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/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 { 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, ShieldAlert, Lock, History, Terminal, Store, Keyboard, CircleDot, CircleSlash, ChevronLeft, ChevronRight, ChevronDown, } from "lucide-react"; import { api } from "../lib/api"; import type { CcArtifactType, CcBackup, CcFileResponse, CcHookScripts, CcHookSource, CcKeybindings, CcKeybindingGroup, CcMarketplacesResponse, CcMcpResponse, CcMcpServer, CcMdItem, CcMemoryItem, CcMutationResult, CcOverview, CcPlugin, CcPluginsResponse, CcScope, CcSettingsSource, CcStatusline, } from "../lib/api"; 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; } const TABS: TabDef[] = [ { key: "overview", icon: Boxes, i18nKey: "tabs.overview" }, { key: "skills", icon: Sparkles, i18nKey: "tabs.skills" }, { key: "agents", icon: UserRound, i18nKey: "tabs.agents" }, { key: "commands", icon: Slash, i18nKey: "tabs.commands" }, { key: "memory", icon: BookOpen, i18nKey: "tabs.memory" }, { key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" }, { key: "marketplaces", icon: Store, i18nKey: "tabs.marketplaces" }, { key: "mcp", icon: Server, i18nKey: "tabs.mcp" }, { key: "hooks", icon: Webhook, i18nKey: "tabs.hooks" }, { key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" }, { key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" }, { key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" }, ]; 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, }; type EditorState = | { mode: "create"; type: CcArtifactType; defaultScope: "user" | "project"; template: string; project?: string; // set for type === "auto-memory" } | { mode: "edit"; type: CcArtifactType; scope: "user" | "project" | "auto-memory"; name: string; filePath: string; project?: string; // set for type === "auto-memory" } | null; type ConfirmDeleteState = { type: CcArtifactType; scope: "user" | "project" | "auto-memory"; name?: string; path: string; project?: string; // set for type === "auto-memory" } | null; type Toast = { kind: "success" | "error"; message: string } | null; export function CcConfig() { const { t } = useTranslation("ccConfig"); const [tab, setTab] = useState("overview"); const [scope, setScope] = useState("all"); const [data, setData] = useState(EMPTY_STATE); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); const [search, setSearch] = useState(""); 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 [toast, setToast] = useState(null); const [backupsOpen, setBackupsOpen] = useState(false); // Auto-dismiss toasts after 5s useEffect(() => { if (!toast) return; const id = setTimeout(() => setToast(null), 5000); return () => clearTimeout(id); }, [toast]); const fetchAll = useCallback(async () => { setLoading(true); setError(null); try { const [ overview, skills, agents, commands, outputStyles, plugins, marketplaces, mcp, hooks, keybindings, settings, memory, statusline, hookScripts, ] = await Promise.all([ api.ccConfig.overview(), api.ccConfig.skills(scope), api.ccConfig.agents(scope), api.ccConfig.commands(scope), api.ccConfig.outputStyles(scope), api.ccConfig.plugins(), api.ccConfig.marketplaces(), api.ccConfig.mcp(), api.ccConfig.hooks(), api.ccConfig.keybindings(), api.ccConfig.settings(), api.ccConfig.memory(), api.ccConfig.statusline(), api.ccConfig.hookScripts(), ]); setData({ overview, skills: skills.items, agents: agents.items, commands: commands.items, outputStyles: outputStyles.items, plugins, marketplaces, mcp, hooks: hooks.items, keybindings, settings: settings.items, memory: memory.items, statusline, hookScripts, }); setLastUpdated(new Date()); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "unknown error"; setError(msg); } finally { setLoading(false); } }, [scope]); useEffect(() => { void fetchAll(); }, [fetchAll]); // Live updates - refetch whenever the server broadcasts that a config // surface has changed (either via 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); useEffect(() => { return eventBus.subscribe((msg) => { if (msg.type !== "cc_config_changed") return; if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current); refetchTimerRef.current = setTimeout(() => { refetchTimerRef.current = null; void fetchAll(); }, 250); }); }, [fetchAll]); useEffect(() => { return () => { if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current); }; }, []); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); 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" ? "project" : "user"); setEditor({ mode: "create", type, defaultScope, template }); }, [scope, t] ); 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] ); 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 (
setBackupsOpen(true)} wsConnected={wsConnected} /> {error && (
{t("loadError", { message: error })}
)}
{tab !== "overview" && (
setSearch(e.target.value)} placeholder={t("common.search")} className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1" /> {search && ( )} {isMutable(tab) && tab !== "memory" && ( )}
)}
openCreate("memory", s)} onEditAuto={openEditAuto} onDeleteAuto={openDeleteAuto} onCreateAuto={openCreateAuto} onKeybindingsSaved={fetchAll} onToast={setToast} />
{viewer && setViewer(null)} />} {editor && setEditor(null)} onSave={handleSave} />} {confirmDelete && ( setConfirmDelete(null)} onConfirm={handleDelete} /> )} {toast && setToast(null)} />} {backupsOpen && setBackupsOpen(false)} />}
); } // ── Header ──────────────────────────────────────────────────────────── interface HeaderProps { loading: boolean; lastUpdated: Date | null; scope: CcScope; onScopeChange: (s: CcScope) => void; onRefresh: () => void; onOpenBackups: () => void; wsConnected: boolean; } function Header({ loading, lastUpdated, scope, onScopeChange, onRefresh, onOpenBackups, wsConnected, }: HeaderProps) { const { t } = useTranslation("ccConfig"); const { t: tCommon } = useTranslation("common"); const formatted = lastUpdated ? lastUpdated.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", }) : "-"; return (

{t("title")}

{wsConnected ? ( {tCommon("live")} ) : ( {tCommon("offline")} )}

{t("subtitle")}

{lastUpdated && ( {t("lastUpdated", { time: formatted })} )}
); } function ScopeToggle({ value, onChange }: { value: CcScope; onChange: (s: CcScope) => void }) { 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) => ( ))}
); } // ── Tabs ────────────────────────────────────────────────────────────── interface TabsProps { current: TabKey; onSelect: (k: TabKey) => void; counts?: CcOverview["counts"]; } function Tabs({ current, onSelect, counts }: TabsProps) { const { t } = useTranslation("ccConfig"); const scrollRef = useRef(null); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); // Update scroll affordances when content size or scroll position changes. const updateAffordances = useCallback(() => { const el = scrollRef.current; if (!el) return; setCanScrollLeft(el.scrollLeft > 1); setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); }, []); useEffect(() => { updateAffordances(); const el = scrollRef.current; if (!el) return; el.addEventListener("scroll", updateAffordances, { passive: true }); const ro = new ResizeObserver(updateAffordances); ro.observe(el); return () => { el.removeEventListener("scroll", updateAffordances); ro.disconnect(); }; }, [updateAffordances]); // Scroll the active tab into view when it changes (e.g. user picks a tab // that's offscreen, or window resize hides the active one). useEffect(() => { const el = scrollRef.current; if (!el) return; const active = el.querySelector('[data-tab-active="true"]'); if (!active) return; const elRect = el.getBoundingClientRect(); const activeRect = active.getBoundingClientRect(); if (activeRect.left < elRect.left + 8) { el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" }); } else if (activeRect.right > elRect.right - 8) { el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" }); } }, [current]); const scrollByButton = (dir: 1 | -1) => { const el = scrollRef.current; if (!el) return; el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" }); }; 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 (
{/* Left edge gradient + chevron */}
{canScrollLeft && ( )}
{TABS.map(({ key, icon: Icon, i18nKey }) => { const c = countFor(key); const active = current === key; return ( ); })}
{/* Right edge gradient + chevron */}
{canScrollRight && ( )}
); } // ── 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; onKeybindingsSaved: () => void; onToast: (toast: NonNullable) => void; } function TabPanel({ tab, data, search, onOpenFile, onEdit, onDelete, onCreateMemory, onEditAuto, onDeleteAuto, onCreateAuto, onKeybindingsSaved, onToast, }: 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-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-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-300", bar: "bg-fuchsia-500/40", ring: "ring-fuchsia-500/20", }, cyan: { iconBg: "bg-cyan-500/10", iconText: "text-cyan-300", bar: "bg-cyan-500/40", ring: "ring-cyan-500/20", }, pink: { iconBg: "bg-pink-500/10", iconText: "text-pink-300", bar: "bg-pink-500/40", ring: "ring-pink-500/20", }, indigo: { iconBg: "bg-indigo-500/10", iconText: "text-indigo-300", bar: "bg-indigo-500/40", ring: "ring-indigo-500/20", }, orange: { iconBg: "bg-orange-500/10", iconText: "text-orange-300", bar: "bg-orange-500/40", ring: "ring-orange-500/20", }, teal: { iconBg: "bg-teal-500/10", iconText: "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-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}
); } // ── MD-item generic list (skills/agents/commands/output-styles) ─────── interface MdItemListProps { items: CcMdItem[] | null; search: string; onOpen: (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; kind: "skills" | "agents" | "commands" | "outputStyles"; } function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) { const filtered = useMemo(() => { if (!items) return null; const q = search.toLowerCase(); return items.filter((it) => { if (!q) return true; const blob = [it.name, it.frontmatter.description, it.frontmatter.name] .filter(Boolean) .join(" ") .toLowerCase(); return blob.includes(q); }); }, [items, search]); if (!filtered) return ; if (filtered.length === 0) return ; return (
{filtered.map((it) => ( ))}
); } interface MdItemCardProps { item: CcMdItem; onOpen: (p: 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; kind: "skills" | "agents" | "commands" | "outputStyles"; } function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) { const { t } = useTranslation("ccConfig"); const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind; const filePath = item.file || `${item.path}/SKILL.md`; const description = item.frontmatter.description || item.preview .replace(/^#+\s.*\n/, "") .trim() .slice(0, 200); return (
{item.name} {item.frontmatter.model && ( {item.frontmatter.model} )}
{description && (

{description}

)} {kind === "agents" && item.frontmatter.tools && (
{t("agents.tools")}:{" "} {item.frontmatter.tools}
)}
{filePath}
); } // ── Plugins ─────────────────────────────────────────────────────────── function PluginsPanel({ data, search }: { data: CcPluginsResponse | null; search: string }) { const { t } = useTranslation("ccConfig"); if (!data) return ; const filtered = data.plugins.filter( (p) => !search || p.key.toLowerCase().includes(search.toLowerCase()) ); return (
{data.manifestPath} {!data.manifestExists && ( {t("plugins.manifestMissing", { path: "" })} )}
{filtered.length === 0 ? ( ) : ( filtered.map((p) => ) )}
); } function PluginCard({ plugin: p }: { plugin: CcPlugin }) { const { t } = useTranslation("ccConfig"); const meta = p.contributes?.pluginJson; const description = meta?.description; const contribCounts: { key: string; count: number; label: string }[] = []; if (p.contributes) { if (p.contributes.skills > 0) contribCounts.push({ key: "skills", count: p.contributes.skills, label: t("plugins.skills", { count: p.contributes.skills }), }); if (p.contributes.agents > 0) contribCounts.push({ key: "agents", count: p.contributes.agents, label: t("plugins.agents", { count: p.contributes.agents }), }); if (p.contributes.commands > 0) contribCounts.push({ key: "commands", count: p.contributes.commands, label: t("plugins.commands", { count: p.contributes.commands }), }); if (p.contributes.outputStyles > 0) contribCounts.push({ key: "outputStyles", count: p.contributes.outputStyles, label: t("plugins.outputStyles", { count: p.contributes.outputStyles }), }); if (p.contributes.hooks > 0) contribCounts.push({ key: "hooks", count: p.contributes.hooks, label: t("plugins.hooks", { count: p.contributes.hooks }), }); } return (
{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.label} ))}
)}
{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}
)}
); } // ── MCP servers ─────────────────────────────────────────────────────── function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string }) { const { t } = useTranslation("ccConfig"); if (!data) return ; const all = [...data.user, ...data.projectScoped]; const filter = (arr: CcMcpServer[]) => arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase())); return (
{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 }: { server: CcMcpServer }) { const { t } = useTranslation("ccConfig"); return (
{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(", ")} )} )}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}: {children}
); } // ── Hooks ───────────────────────────────────────────────────────────── function HooksPanel({ sources, scripts, search, onOpen, }: { sources: CcHookSource[] | null; scripts: CcHookScripts | null; search: string; onOpen: (p: string) => void; }) { const { t } = useTranslation("ccConfig"); if (!sources) return ; return (
{sources.map((src) => { const events = Object.entries(src.hooks); const filteredEvents = search ? events.filter(([event]) => event.toLowerCase().includes(search.toLowerCase())) : events; return (
{src.file} {src.exists ? ( ) : ( {t("hooks.fileMissing")} )}
{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}
)}
))}
))}
)}
); })} {scripts && scripts.items.length > 0 && (
{t("hookScripts.title")}

{t("hookScripts.subtitle")}

{scripts.dir}
{scripts.items.map((s) => ( ))}
)}
); } // ── 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 & reasoning", keys: [ { key: "model", label: "Model" }, { key: "effortLevel", label: "Effort level" }, { key: "alwaysThinkingEnabled", label: "Always thinking" }, ], }, { title: "Output & display", 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 & input", 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[] }) { // 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 (
Current configuration {setCount} option{setCount !== 1 ? "s" : ""} set · the rest use defaults
{CONFIG_OPTION_GROUPS.map((group) => (
{group.title}
{group.keys.map(({ key, label }) => { const hit = effective.get(key); return (
{label}
{hit ? ( ) : ( default )}
{hit ? ( ) : ( - )}
); })}
))}
); } function SettingsPanel({ sources, statusline, onOpen, }: { sources: CcSettingsSource[] | null; statusline: CcStatusline | null; onOpen: (p: string) => void; }) { 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) => ( ))}
)}
); } function SettingsBlock({ source, onOpen, }: { source: CcSettingsSource; onOpen: (p: string) => void; }) { const { t } = useTranslation("ccConfig"); const [showRaw, setShowRaw] = useState(false); return (
{source.file} {source.exists ? ( <> ) : ( {t("settings.fileMissing")} )}
{source.exists && (showRaw ? (
            {JSON.stringify(source.data, null, 2)}
          
) : ( | null | undefined} /> ))}
); } function SettingsKeyValueList({ data }: { data: Record | null | undefined }) { if (!data || typeof data !== "object") { return
-
; } const entries = Object.entries(data); if (entries.length === 0) { return
{}
; } return (
{entries.map(([k, v]) => (
{k}
))}
); } function SettingsValue({ value }: { value: unknown }) { if (value === null || value === undefined) return null; if (typeof value === "boolean") { return ( {value ? "true" : "false"} ); } if (typeof value === "number") { return {value}; } if (typeof value === "string") { return {value}; } if (Array.isArray(value)) { if (value.length === 0) return []; return (
{value.map((item, i) => ( {typeof item === "object" ? JSON.stringify(item) : String(item)} ))}
); } // object const obj = value as Record; return (
{Object.entries(obj).map(([k, v]) => (
{k}:{" "} {typeof v === "object" ? JSON.stringify(v) : String(v)}
))}
); } // ── Memory ──────────────────────────────────────────────────────────── interface MemoryPanelProps { items: CcMemoryItem[] | null; search: string; onOpen: (p: 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; onCreate: (scope: "user" | "project") => void; onEditAuto: (item: CcMemoryItem) => void; onDeleteAuto: (item: CcMemoryItem) => void; onCreateAuto: (project: string) => void; } // Strip a leading markdown heading then take a short snippet — used when a // per-fact memory file has no frontmatter description. function memoryDescription(m: CcMemoryItem): string { return ( m.frontmatter?.description || m.preview .replace(/^#+\s.*\n/, "") .trim() .slice(0, 200) ); } // Reduce a markdown link target (as written inside MEMORY.md, e.g. // `./feedback_x.md#section` or `feedback_x.md`) to the bare filename we can // match against a fact file's `name`. Tolerant of URL-encoding and anchors. function normalizeMemoryTarget(target: string): string { let v = target.trim(); const hash = v.indexOf("#"); if (hash >= 0) v = v.slice(0, hash); try { v = decodeURIComponent(v); } catch { /* leave as-is when not valid percent-encoding */ } const slash = v.lastIndexOf("/"); if (slash >= 0) v = v.slice(slash + 1); return v.trim(); } // Render a MEMORY.md preview with its `[label](target.md)` markdown links // turned into clickable buttons. Everything else is emitted verbatim so the // surrounding
 keeps the original index layout. Clicking a link asks the
// parent to jump to (scroll + highlight) the matching fact file.
function renderMemoryIndex(
  preview: string,
  onJump: (target: string) => void,
  jumpTitle: string
): React.ReactNode {
  const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
  const lines = preview.split("\n");
  return lines.map((line, li) => {
    const parts: React.ReactNode[] = [];
    let last = 0;
    let m: RegExpExecArray | null;
    linkRe.lastIndex = 0;
    while ((m = linkRe.exec(line)) !== null) {
      const full = m[0];
      const label = m[1] ?? "";
      const capturedTarget = m[2] ?? "";
      if (m.index > last) parts.push(line.slice(last, m.index));
      parts.push(
        
      );
      last = m.index + full.length;
    }
    if (last < line.length) parts.push(line.slice(last));
    return (
      
        {parts.length ? parts : line}
        {li < lines.length - 1 ? "\n" : null}
      
    );
  });
}

function MemoryPanel({
  items,
  search,
  onOpen,
  onEdit,
  onDelete,
  onCreate,
  onEditAuto,
  onDeleteAuto,
  onCreateAuto,
}: MemoryPanelProps) {
  const { t } = useTranslation("ccConfig");

  const q = search.trim().toLowerCase();

  const { primary, autoFiltered, groups, missingScopes } = useMemo(() => {
    const list = items ?? [];
    const primaryItems = list.filter(
      (m): m is CcMemoryItem & { scope: "user" | "project" } =>
        m.scope === "user" || m.scope === "project"
    );
    const autoItems = list.filter((m) => m.scope === "auto-memory");

    const matchesAuto = (m: CcMemoryItem) => {
      if (!q) return true;
      const blob = [m.name, m.project, m.frontmatter?.description, m.frontmatter?.name, m.preview]
        .filter(Boolean)
        .join(" ")
        .toLowerCase();
      return blob.includes(q);
    };

    // Search applies to the whole tab — match the CLAUDE.md cards on their
    // scope label, path, and body too so the filter is consistent.
    const primaryFilteredItems = primaryItems.filter((m) => {
      if (!q) return true;
      return [m.scope, m.file, m.preview].join(" ").toLowerCase().includes(q);
    });

    const filtered = autoItems.filter(matchesAuto);

    // Group surviving auto-memory files by their project dir.
    const byProject = new Map();
    for (const m of filtered) {
      const key = m.project || "(unknown)";
      if (!byProject.has(key)) byProject.set(key, []);
      byProject.get(key)!.push(m);
    }
    const grouped = [...byProject.entries()].sort((a, b) => a[0].localeCompare(b[0]));

    const present = new Set(primaryItems.map((m) => m.scope));
    const missing = (["user", "project"] as const).filter((s) => !present.has(s));

    return {
      primary: primaryFilteredItems,
      autoFiltered: filtered,
      groups: grouped,
      missingScopes: missing,
    };
  }, [items, q]);

  if (!items) return ;

  const totalAuto = items.filter((m) => m.scope === "auto-memory").length;
  // The "create missing CLAUDE.md" prompts only make sense when not filtering.
  const showMissing = !q;

  return (
    
{/* Primary CLAUDE.md memory (user + project) — editable */} {primary.map((m) => (
{m.file} {formatBytes(m.size)}
            {m.preview}
            {m.truncated && (
              
                {"\n\n"}
                {t("common.truncated")}
              
            )}
          
))} {showMissing && missingScopes.map((s) => (
{t("memory.missing")}
))} {/* Per-project file-based memory (~/.claude/projects//memory/) */} {totalAuto > 0 && (

{t("memory.autoTitle")}

{q ? `${autoFiltered.length}/${totalAuto}` : totalAuto}

{t("memory.autoSubtitle")}

{groups.length === 0 ? (
{t("memory.noMatches")}
) : (
{groups.map(([project, files]) => ( ))}
)}
)}
); } interface MemoryProjectGroupProps { project: string; files: CcMemoryItem[]; onOpen: (p: string) => void; onEditAuto: (item: CcMemoryItem) => void; onDeleteAuto: (item: CcMemoryItem) => void; onCreateAuto: (project: string) => void; defaultOpen: boolean; } function MemoryProjectGroup({ project, files, onOpen, onEditAuto, onDeleteAuto, onCreateAuto, defaultOpen, }: MemoryProjectGroupProps) { const { t } = useTranslation("ccConfig"); const [open, setOpen] = useState(defaultOpen); // Re-sync when the search-driven default flips (expand on search, collapse // when cleared). User toggles within a stable search state are preserved. useEffect(() => { setOpen(defaultOpen); }, [defaultOpen]); const indexFiles = files.filter((f) => f.isIndex); const factFiles = files.filter((f) => !f.isIndex); // Wiring for "click an index entry → jump to its fact file". Fact rows // register their DOM node keyed by filename; the index links look them up. const rowRefs = useRef>(new Map()); const [highlighted, setHighlighted] = useState(null); const highlightTimer = useRef | null>(null); useEffect( () => () => { if (highlightTimer.current) clearTimeout(highlightTimer.current); }, [] ); const rowKey = useCallback((m: CcMemoryItem) => m.name || normalizeMemoryTarget(m.file), []); const handleJump = useCallback( (target: string) => { const name = normalizeMemoryTarget(target); const el = rowRefs.current.get(name); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); setHighlighted(name); if (highlightTimer.current) clearTimeout(highlightTimer.current); highlightTimer.current = setTimeout(() => setHighlighted(null), 2200); return; } // Target isn't currently in view (e.g. filtered out by search) — open the // underlying file directly if we can resolve it within this project. const match = files.find((f) => (f.name || normalizeMemoryTarget(f.file)) === name); if (match) onOpen(match.file); }, [files, onOpen] ); return (
{open && (
{indexFiles.length > 0 && (
{t("memory.indexFiles")}
{indexFiles.map((m) => ( ))}
)} {factFiles.length > 0 && (
{t("memory.factFiles", { count: factFiles.length })}
{factFiles.map((m) => { const key = rowKey(m); return ( { if (el) rowRefs.current.set(key, el); else rowRefs.current.delete(key); }} /> ); })}
)}
)}
); } interface MemoryAutoItemProps { item: CcMemoryItem; onOpen: (p: string) => void; onEditAuto: (item: CcMemoryItem) => void; onDeleteAuto: (item: CcMemoryItem) => void; } // Compact View / Edit / Delete button cluster shared by index + fact rows. function MemoryAutoActions({ item, onOpen, onEditAuto, onDeleteAuto }: MemoryAutoItemProps) { const { t } = useTranslation("ccConfig"); return (
); } function MemoryIndexCard({ item, onOpen, onEditAuto, onDeleteAuto, onJump, }: MemoryAutoItemProps & { onJump: (target: string) => void }) { const { t } = useTranslation("ccConfig"); return (
{item.name} {formatBytes(item.size)}
        {renderMemoryIndex(item.preview, onJump, t("memory.jumpTo"))}
        {item.truncated && {"\n…"}}
      
); } function MemoryFactRow({ item, onOpen, onEditAuto, onDeleteAuto, highlighted, rowRef, }: MemoryAutoItemProps & { highlighted?: boolean; rowRef?: (el: HTMLDivElement | null) => void; }) { const desc = memoryDescription(item); return (
{item.name} {desc && (

{desc}

)}
{formatBytes(item.size)}
); } // ── Marketplaces ────────────────────────────────────────────────────── function MarketplacesPanel({ data, search, }: { data: CcMarketplacesResponse | null; search: string; }) { const { t } = useTranslation("ccConfig"); if (!data) return ; const filtered = data.items.filter( (m) => !search || m.name.toLowerCase().includes(search.toLowerCase()) || (m.marketplaceName || "").toLowerCase().includes(search.toLowerCase()) ); return (
{data.knownPath}
{filtered.length === 0 ? (
{t("marketplaces.noMarketplaces")}
) : ( filtered.map((m) => (
{m.name} {m.marketplaceName && m.marketplaceName !== m.name && ( {m.marketplaceName} )} {m.pluginCount != null && ( {t("marketplaces.pluginCount")}: {m.pluginCount} )}
{m.marketplaceDescription && (

{m.marketplaceDescription}

)}
{m.source && (
{t("marketplaces.source")}:{" "} {m.source.source === "github" && m.source.repo ? `github.com/${m.source.repo}` : m.source.url || m.source.repo || JSON.stringify(m.source)}
)} {m.marketplaceOwner?.name && (
{t("marketplaces.owner")}:{" "} {m.marketplaceOwner.name}
)} {m.lastUpdated && (
{t("marketplaces.lastUpdated")}:{" "} {new Date(m.lastUpdated).toLocaleString()}
)}
{m.installLocation && (
{m.installLocation}
)}
)) )}
); } // ── Keybindings ─────────────────────────────────────────────────────── function KeybindingsPanel({ data, search, onSaved, onToast, }: { data: CcKeybindings | null; search: string; onSaved: () => void; onToast: (toast: NonNullable) => void; }) { const { t } = useTranslation("ccConfig"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState([]); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); const startEdit = useCallback(() => { const groups = data?.groups ?? []; // Deep clone so edits never mutate the fetched data. setDraft( groups.map((g) => ({ context: g.context, bindings: g.bindings.map((b) => ({ ...b })) })) ); setErr(null); setEditing(true); }, [data]); const cancelEdit = useCallback(() => { setEditing(false); setDraft([]); setErr(null); }, []); const updateContext = (gi: number, value: string) => setDraft((d) => d.map((g, i) => (i === gi ? { ...g, context: value } : g))); const removeContext = (gi: number) => setDraft((d) => d.filter((_, i) => i !== gi)); const addContext = () => setDraft((d) => [...d, { context: "", bindings: [{ key: "", action: "" }] }]); const updateBinding = (gi: number, bi: number, field: "key" | "action", value: string) => setDraft((d) => d.map((g, i) => i === gi ? { ...g, bindings: g.bindings.map((b, j) => (j === bi ? { ...b, [field]: value } : b)) } : g ) ); const removeBinding = (gi: number, bi: number) => setDraft((d) => d.map((g, i) => (i === gi ? { ...g, bindings: g.bindings.filter((_, j) => j !== bi) } : g)) ); const addBinding = (gi: number) => setDraft((d) => d.map((g, i) => (i === gi ? { ...g, bindings: [...g.bindings, { key: "", action: "" }] } : g)) ); const handleSave = useCallback(async () => { const groups: CcKeybindingGroup[] = draft.map((g) => ({ context: g.context.trim(), bindings: g.bindings.map((b) => ({ key: b.key.trim(), action: b.action.trim() })), })); // Mirror the server-side validation so users get instant, local feedback. const seen = new Set(); for (const g of groups) { if (!g.context) return setErr(t("keybindings.errContext")); if (seen.has(g.context)) return setErr(t("keybindings.errDupContext", { context: g.context })); seen.add(g.context); const keys = new Set(); for (const b of g.bindings) { if (!b.key || !b.action) return setErr(t("keybindings.errEmpty", { context: g.context })); if (keys.has(b.key)) return setErr(t("keybindings.errDupKey", { key: b.key, context: g.context })); keys.add(b.key); } } setSaving(true); setErr(null); try { const result = await api.ccConfig.writeKeybindings(groups); onToast({ kind: "success", message: result.created ? t("edit.saveSuccessNew") : t("edit.saveSuccess", { path: result.backupPath || "-" }), }); setEditing(false); setDraft([]); onSaved(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "unknown error"; setErr(msg); onToast({ kind: "error", message: t("edit.writeError", { message: msg }) }); } finally { setSaving(false); } }, [draft, onSaved, onToast, t]); if (!data) return ; const headerBar = (
{data.file} {data.docs && ( {t("keybindings.docsLink")} )} {editing ? (
) : ( )}
); // ── Edit mode ──────────────────────────────────────────────────────── if (editing) { return (
{headerBar} {err && (
{err}
)} {draft.map((g, gi) => (
{t("keybindings.context")} updateContext(gi, e.target.value)} placeholder={t("keybindings.contextPlaceholder")} className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />
{g.bindings.map((b, bi) => (
updateBinding(gi, bi, "key", e.target.value)} placeholder={t("keybindings.key")} className="h-7 w-40 flex-shrink-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" /> updateBinding(gi, bi, "action", e.target.value)} placeholder={t("keybindings.action")} className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" />
))}
))}
); } // ── Read-only mode ─────────────────────────────────────────────────── if (!data.exists) { return (
{headerBar}
{t("keybindings.missing", { path: data.file })}
); } const q = search.toLowerCase(); return (
{headerBar} {data.groups.map((g) => { const filtered = g.bindings.filter( (b) => !q || b.key.toLowerCase().includes(q) || b.action.toLowerCase().includes(q) || g.context.toLowerCase().includes(q) ); if (filtered.length === 0) return null; return (
{t("keybindings.context")}: {g.context} ({filtered.length})
{filtered.map((b) => (
{b.key} {b.action}
))}
); })}
); } // ── Shared atoms ────────────────────────────────────────────────────── function ScopeBadge({ scope }: { scope: string }) { const { t } = useTranslation("ccConfig"); const color = scope === "user" ? "bg-sky-500/10 text-sky-300 border-sky-500/30" : scope === "project" ? "bg-status-success/10 text-status-success border-status-success/30" : scope === "project-local" ? "bg-violet-500/10 text-violet-300 border-violet-500/30" : "bg-surface-3 text-fg-secondary border-border"; const label = scope === "project-local" ? t("scope.projectLocal") : scope === "user" ? t("scope.user") : scope === "project" ? t("scope.project") : scope; return ( {label} ); } function CopyButton({ value }: { value: string }) { const { t } = useTranslation("ccConfig"); const [copied, setCopied] = useState(false); return ( ); } function Empty() { const { t } = useTranslation("ccConfig"); return (
{t("common.empty")}
); } function SkeletonRows({ n }: { n: number }) { return (
{Array.from({ length: n }).map((_, i) => (
))}
); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } // ── File viewer modal ───────────────────────────────────────────────── function FileViewer({ state, onClose, }: { state: { path: string; data: CcFileResponse | null; error: string | null }; onClose: () => void; }) { const { t } = useTranslation("ccConfig"); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return (
e.stopPropagation()} >
{state.path}
{state.error ? (
{state.error}
) : !state.data ? (
) : (
              {state.data.text}
              {state.data.truncated && (
                
                  {"\n\n"}
                  {t("common.truncated")}
                
              )}
            
)}
); } // ── Editor modal (create + edit) ────────────────────────────────────── interface EditorModalProps { state: NonNullable; onClose: () => void; onSave: (args: { type: CcArtifactType; targetScope: "user" | "project" | "auto-memory"; name: string | undefined; content: string; project?: string; }) => Promise; } function EditorModal({ state, onClose, onSave }: EditorModalProps) { const { t } = useTranslation("ccConfig"); const isCreate = state.mode === "create"; const isAutoMemory = state.type === "auto-memory"; const [content, setContent] = useState(isCreate ? state.template : ""); const [name, setName] = useState(""); const [targetScope, setTargetScope] = useState<"user" | "project">( isCreate ? state.defaultScope : state.scope === "auto-memory" ? "user" : state.scope ); const [loading, setLoading] = useState(!isCreate); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // For edit mode, fetch the actual file content useEffect(() => { if (state.mode === "edit") { setLoading(true); api.ccConfig .file(state.filePath) .then((r) => { setContent(r.text); setLoading(false); }) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : "unknown"; setError(msg); setLoading(false); }); } }, [state]); // Esc to close useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const handleSave = useCallback(async () => { setSaving(true); setError(null); try { if (state.type !== "memory" && state.mode === "create" && !name) { setError(t("edit.nameLabel")); setSaving(false); return; } // For auto-memory creates, append a .md extension when the user omits it. const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name; const effectiveName = state.mode === "edit" ? state.name : state.type === "memory" ? undefined : createName; await onSave({ type: state.type, targetScope: isAutoMemory ? "auto-memory" : targetScope, name: effectiveName, content, project: state.project, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "unknown"; setError(t("edit.writeError", { message: msg })); } finally { setSaving(false); } }, [state, targetScope, name, content, isAutoMemory, onSave, t]); const titleText = isCreate ? isAutoMemory ? t("memory.newFileTitle", { project: state.project ?? "" }) : t("edit.newTitle", { type: state.type }) : t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" }); return (
e.stopPropagation()} >
{titleText}
{isCreate && state.type !== "memory" && (
setName(e.target.value)} placeholder={ isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder") } pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"} className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50" />

{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}

{isAutoMemory ? (
{state.project}

{t("memory.projectHelp")}

) : (
{(["user", "project"] as const).map((s) => ( ))}
)}
)}
{loading ? (
) : (