/** * @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. * * ## 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 { 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, 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 { ToastNotice, type Toast } from "../components/config/ToastNotice"; import { MdItemList } from "../components/config/MdItemList"; import { ScopeBadge } from "../components/config/ScopeBadge"; import { ConfigTabBar } from "../components/config/ConfigTabBar"; import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal"; 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, CcProfile, CcAccountIdentity, CcProfileUsage, CcUsageWindow, 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, }; 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); const [profilesOpen, setProfilesOpen] = useState(false); const [projectMemoryOpen, setProjectMemoryOpen] = 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] ); // 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 (
setBackupsOpen(true)} onOpenProfiles={() => setProfilesOpen(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" && ( )} {/* 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} />
{viewer && setViewer(null)} />} {editor && ( api.ccConfig.file(p).then((r) => r.text)} onClose={() => setEditor(null)} onSave={handleSave} /> )} {confirmDelete && ( setConfirmDelete(null)} onConfirm={handleDelete} /> )} {projectMemoryOpen && ( setProjectMemoryOpen(false)} onCreate={handleCreateProjectMemory} /> )} {toast && setToast(null)} />} {backupsOpen && setBackupsOpen(false)} />} {profilesOpen && setProfilesOpen(false)} onToast={setToast} />}
); } // ── Header ──────────────────────────────────────────────────────────── interface HeaderProps { loading: boolean; lastUpdated: Date | null; scope: CcScope; onScopeChange: (s: CcScope) => void; onRefresh: () => void; onOpenBackups: () => void; onOpenProfiles: () => void; wsConnected: boolean; } function Header({ loading, lastUpdated, scope, onScopeChange, onRefresh, onOpenBackups, onOpenProfiles, 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 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), }))} current={current} onSelect={onSelect} testIdPrefix="cc-tab" 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; } function TabPanel({ tab, data, search, onOpenFile, onEdit, onDelete, onCreateMemory, onEditAuto, onDeleteAuto, onCreateAuto, onChanged, 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}
); } // ── 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); // 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 (e.key === "Enter") 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) => (
{a.name} {a.marketplaceName && ( {a.marketplaceName} )}
{a.description && (

{a.description}

)}
void run( a.pluginId, () => api.ccConfig.installPlugin(a.pluginId), t("manage.installed", { name: a.pluginId }) ) } > {busy === a.pluginId ? t("manage.working") : t("manage.install")}
))}
)} {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. const [confirmUninstall, setConfirmUninstall] = useState(false); // 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 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 (
{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 ? ( void run( p.key, () => api.ccConfig.pluginAction(p.key, "enable"), t("manage.enabled", { name: p.key }) ) } > {t("manage.enable")} ) : ( void run( p.key, () => api.ccConfig.pluginAction(p.key, "disable"), t("manage.disabled", { name: p.key }) ) } > {t("manage.disable")} )} 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 }) ) } > {t("manage.update")} { if (!confirmUninstall) { setConfirmUninstall(true); return; } setConfirmUninstall(false); void run( p.key, () => api.ccConfig.pluginAction(p.key, "uninstall"), t("manage.uninstalled", { name: p.key }) ); }} > {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, }: { data: CcMcpResponse | null; search: string; onToast: (t: NonNullable) => void; onChanged: () => void; }) { 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(""); 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 (e.key === "Enter") 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 (e.key === "Enter") 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 [confirmRemove, setConfirmRemove] = useState(false); 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; 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(", ")} )} )}
{removable ? ( { if (!confirmRemove) { setConfirmRemove(true); return; } setConfirmRemove(false); void run( busyId, () => api.ccConfig.mcpRemove(server.name, scope), t("manage.mcpRemoved", { name: server.name }) ); }} > {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, }: { sources: CcHookSource[] | null; scripts: CcHookScripts | null; search: string; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => void; }) { 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, }: { source: CcHookSource; search: string; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => void; }) { 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); 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); setDraft( editableEvents.map(([event, entries]) => ({ event, entries: entries.map((e) => ({ ...e })), })) ); }; 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; } 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 is not in either list, so it is never touched. const unset = editableEvents .map(([event]) => event) .filter((event) => !seen.has(event)) .map((event) => `hooks.${event}`); 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 ? (
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 & 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, onToast, onChanged, }: { sources: CcSettingsSource[] | null; statusline: CcStatusline | null; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => 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) => ( ))}
)}
); } /** 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; } } type SettingsDraftRow = { key: string; text: string; original: string; isNew: boolean }; function SettingsBlock({ source, onOpen, onToast, onChanged, }: { source: CcSettingsSource; onOpen: (p: string) => void; onToast: (t: NonNullable) => void; onChanged: () => void; }) { 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); const data = (source.data as Record | null | undefined) || {}; // Redacted keys stay out of the draft entirely, so a save can never mention // them; they keep rendering read-only below the editor. const editableKeys = Object.entries(data).filter(([, v]) => !isRedacted(v)); const redactedKeys = Object.entries(data).filter(([, v]) => isRedacted(v)); const startEdit = () => { setErr(null); setDraft( editableKeys.map(([key, value]) => { const text = JSON.stringify(value, null, 2); return { key, text, original: text, isNew: false }; }) ); }; 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; } seen.add(key); // Only send what actually changed — an untouched key is left alone. if (!row.isNew && row.text === row.original) continue; try { set[key] = JSON.parse(row.text); } catch { setErr(t("manage.settingsBadJson", { key })); return; } } const unset = editableKeys.map(([key]) => key).filter((key) => !seen.has(key)); if (Object.keys(set).length === 0 && unset.length === 0) { setDraft(null); 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 ? (
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" />