Files
AgentHub/client/src/pages/CcConfig.tsx
T
nntrivi2001 ce64132771 feat(cc-config): edit settings.json values from the dashboard
Replace the batch multi-key settings.json editor with a recursive per-leaf
editor: each scalar value gets its own inline edit control, the dotted path
is rebuilt from the node's ancestry, and a redacted value renders as a
static non-editable chip. Saves go straight through the existing
PATCH /api/cc-config/settings per-key merge.
2026-09-09 08:44:57 +07:00

4432 lines
160 KiB
TypeScript

/**
* @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), plus a
* per-key merge editor for settings.json (`PATCH /cc-config/settings` -
* never a whole-file overwrite, so a concurrent CLI write loses nothing
* else). Plugins, MCP, and hooks-in-settings stay read-only here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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,
ChevronLeft,
ChevronRight,
ChevronDown,
UserCog,
} 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 {
Empty,
SkeletonRows,
CopyButton,
formatBytes,
} from "../components/config/configPrimitives";
import type {
CcArtifactType,
CcAvailablePlugin,
CcBackup,
CcFileResponse,
CcHookEntry,
CcHookScripts,
CcHookSource,
CcKeybindings,
CcKeybindingGroup,
CcMarketplacesResponse,
CcMcpResponse,
CcMcpServer,
CcMdItem,
CcMemoryItem,
CcMutationResult,
CcOverview,
CcPlugin,
CcPluginItem,
CcPluginsResponse,
CcProfile,
CcAccountIdentity,
CcScope,
CcSettingsPatchResult,
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<TabKey>("overview");
const [scope, setScope] = useState<CcScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
const [viewer, setViewer] = useState<{
path: string;
data: CcFileResponse | null;
error: string | null;
} | null>(null);
const [editor, setEditor] = useState<EditorState>(null);
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
const [toast, setToast] = useState<Toast>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = 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<ReturnType<typeof setTimeout> | 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 (
<div className="space-y-5">
<Header
loading={loading}
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={fetchAll}
onOpenBackups={() => setBackupsOpen(true)}
onOpenProfiles={() => setProfilesOpen(true)}
wsConnected={wsConnected}
/>
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("loadError", { message: error })}</span>
</div>
)}
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isMutable(tab) && tab !== "memory" && (
<button
onClick={() => openCreate(tabToArtifactType(tab))}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateMemory={(s) => openCreate("memory", s)}
onEditAuto={openEditAuto}
onDeleteAuto={openDeleteAuto}
onCreateAuto={openCreateAuto}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
</div>
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && (
<EditorModal
state={editor}
fetchContent={(p) => api.ccConfig.file(p).then((r) => r.text)}
onClose={() => setEditor(null)}
onSave={handleSave}
/>
)}
{confirmDelete && (
<ConfirmDeleteModal
state={confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
</div>
);
}
// ── 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 (
<header className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Boxes className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
onClick={onOpenProfiles}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</button>
<button
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<History className="w-3.5 h-3.5" />
{t("backups.openButton")}
</button>
<button
onClick={onRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 disabled:opacity-60 transition-colors"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
{/* Both labels occupy the same grid cell, so the button is always as
wide as the longer one ("Refreshing…" / "Đang làm mới…") and does
not resize the toolbar when the state flips. Measuring the widest
string beats a hardcoded min-width, which would be wrong in the
next translation. */}
<span className="grid">
<span aria-hidden className="invisible col-start-1 row-start-1 whitespace-nowrap">
{t("refreshing")}
</span>
<span className="col-start-1 row-start-1 whitespace-nowrap">
{loading ? t("refreshing") : t("refresh")}
</span>
</span>
</button>
</div>
{lastUpdated && (
<span className="text-[11px] text-fg-muted self-end">
{t("lastUpdated", { time: formatted })}
</span>
)}
</div>
</header>
);
}
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 (
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{opts.map((o) => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
// ── 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<HTMLDivElement | null>(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<HTMLElement>('[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 (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{TABS.map(({ key, icon: Icon, i18nKey }) => {
const c = countFor(key);
const active = current === key;
return (
<button
key={key}
data-testid={`cc-tab-${key}`}
data-tab-active={active ? "true" : undefined}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{t(i18nKey)}</span>
{c !== null && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
}`}
>
{c}
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
);
}
// ── 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<Toast>) => void;
}
function TabPanel({
tab,
data,
search,
onOpenFile,
onEdit,
onDelete,
onCreateMemory,
onEditAuto,
onDeleteAuto,
onCreateAuto,
onChanged,
onToast,
}: TabPanelProps) {
switch (tab) {
case "overview":
return <OverviewPanel overview={data.overview} />;
case "skills":
return (
<MdItemList
items={data.skills}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="skills"
/>
);
case "agents":
return (
<MdItemList
items={data.agents}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="agents"
/>
);
case "commands":
return (
<MdItemList
items={data.commands}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="commands"
/>
);
case "outputStyles":
return (
<MdItemList
items={data.outputStyles}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="outputStyles"
/>
);
case "plugins":
return (
<PluginsPanel data={data.plugins} search={search} onToast={onToast} onChanged={onChanged} />
);
case "marketplaces":
return (
<MarketplacesPanel
data={data.marketplaces}
search={search}
onToast={onToast}
onChanged={onChanged}
/>
);
case "mcp":
return <McpPanel data={data.mcp} search={search} />;
case "hooks":
return (
<HooksPanel
sources={data.hooks}
scripts={data.hookScripts}
search={search}
onOpen={onOpenFile}
onToast={onToast}
onChanged={onChanged}
/>
);
case "keybindings":
return (
<KeybindingsPanel
data={data.keybindings}
search={search}
onSaved={onChanged}
onToast={onToast}
/>
);
case "settings":
return (
<SettingsPanel
sources={data.settings}
statusline={data.statusline}
onOpen={onOpenFile}
onToast={onToast}
onChanged={onChanged}
/>
);
case "memory":
return (
<MemoryPanel
items={data.memory}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
onCreate={onCreateMemory}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onCreateAuto={onCreateAuto}
/>
);
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<Tone, { iconBg: string; iconText: string; bar: string; ring: string }> = {
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 <SkeletonRows n={4} />;
const { roots, counts } = overview;
return (
<div className="space-y-5">
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="sky"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
<RootRow
icon={FileText}
tone="amber"
label={t("overview.claudeJson")}
value={roots.claudeJson}
/>
</div>
</section>
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("overview.summary")}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
<SummaryStat
tone="fuchsia"
icon={Sparkles}
label={t("tabs.skills")}
user={counts.skills.user}
project={counts.skills.project}
/>
<SummaryStat
tone="sky"
icon={UserRound}
label={t("tabs.agents")}
user={counts.agents.user}
project={counts.agents.project}
/>
<SummaryStat
tone="cyan"
icon={Slash}
label={t("tabs.commands")}
user={counts.commands.user}
project={counts.commands.project}
/>
<SummaryStat
tone="pink"
icon={Palette}
label={t("tabs.outputStyles")}
user={counts.outputStyles.user}
project={counts.outputStyles.project}
/>
<SummaryStat
tone="indigo"
icon={Server}
label={t("tabs.mcp")}
user={counts.mcpServers.user}
project={counts.mcpServers.project}
/>
<SummaryStat
tone="emerald"
icon={PlugZap}
label={t("tabs.plugins")}
value={counts.plugins}
/>
<SummaryStat
tone="amber"
icon={Store}
label={t("tabs.marketplaces")}
value={counts.marketplaces}
/>
<SummaryStat
tone="orange"
icon={Webhook}
label={t("tabs.hooks")}
value={Object.values(counts.hooks).reduce((a, b) => a + b, 0)}
/>
<SummaryStat
tone="rose"
icon={Keyboard}
label={t("tabs.keybindings")}
value={counts.keybindings}
/>
<SummaryStat
tone="slate"
icon={SettingsIcon}
label={t("tabs.settings")}
value={counts.settingsFiles}
/>
<SummaryStat tone="teal" icon={BookOpen} label={t("tabs.memory")} value={counts.memory} />
</div>
</section>
</div>
);
}
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 (
<div className={`relative rounded-lg border border-border bg-surface-2 overflow-hidden`}>
{/* Left accent bar */}
<div className={`absolute left-0 top-0 bottom-0 w-1 ${T.bar}`} aria-hidden />
<div className="pl-3.5 pr-3 py-2.5">
<div className="flex items-center gap-2">
<span
className={`w-6 h-6 rounded-md ${T.iconBg} flex items-center justify-center flex-shrink-0`}
>
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
</span>
<span className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary truncate">
{label}
</span>
</div>
<div className="mt-1.5 flex items-baseline gap-2">
<span className="text-xl font-semibold text-fg-primary tabular-nums">{total}</span>
{showBreakdown && (
<span className="text-[10px] text-fg-muted truncate">
{user} {t("overview.user")} · {project} {t("overview.project")}
</span>
)}
</div>
</div>
</div>
);
}
function RootRow({
icon: Icon,
tone,
label,
value,
}: {
icon: typeof FolderTree;
tone: Tone;
label: string;
value: string;
}) {
const T = TONES[tone];
return (
<div className="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden">
<div className={`absolute left-0 top-0 bottom-0 w-1 ${T.bar}`} aria-hidden />
<span
className={`w-7 h-7 rounded-md ${T.iconBg} flex items-center justify-center flex-shrink-0 ml-1.5`}
>
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
</span>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary">
{label}
</div>
<div className="font-mono text-[11px] text-fg-secondary truncate">{value}</div>
</div>
<CopyButton value={value} />
</div>
);
}
// ── 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<Toast>) => void, onChanged: () => void) {
const [busy, setBusy] = useState<string | null>(null);
const run = useCallback(
async (id: string, fn: () => Promise<unknown>, 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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`h-7 text-[11px] font-medium px-2.5 rounded-md border inline-flex items-center gap-1.5 disabled:opacity-50 ${
danger
? "border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger"
: "border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary"
}`}
>
<Icon className="w-3 h-3" />
{children}
</button>
);
}
function PluginsPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcPluginsResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [input, setInput] = useState("");
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
const [availableError, setAvailableError] = useState<string | null>(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 <SkeletonRows n={4} />;
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 (
<div className="space-y-3">
<ExplainerBanner
title={t("explain.plugins.title")}
body={t("explain.plugins.body")}
howTo={t("explain.plugins.install")}
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
/>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
{t("manage.installTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={input}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => void submit()}
disabled={!input.trim() || busy === "add"}
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.installButton")}
</button>
</div>
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">
{t("manage.installHelp")}
</p>
</div>
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate">{data.manifestPath}</span>
{!data.manifestExists && (
<span className="ml-auto text-status-warning">
{t("plugins.manifestMissing", { path: "" })}
</span>
)}
</div>
{filtered.length === 0 ? (
<Empty />
) : (
filtered.map((p) => <PluginCard key={p.key} plugin={p} busy={busy} run={run} />)
)}
{installable.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("manage.availableTitle")}
</h3>
<div className="space-y-2">
{installable.map((a) => (
<div
key={a.pluginId}
className="rounded-lg border border-border bg-surface-2 px-4 py-2.5 flex items-start gap-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-fg-primary">{a.name}</span>
{a.marketplaceName && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{a.marketplaceName}
</span>
)}
</div>
{a.description && (
<p className="mt-1 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{a.description}
</p>
)}
</div>
<ActionButton
icon={Plus}
disabled={busy === a.pluginId}
onClick={() =>
void run(
a.pluginId,
() => api.ccConfig.installPlugin(a.pluginId),
t("manage.installed", { name: a.pluginId })
)
}
>
{busy === a.pluginId ? t("manage.working") : t("manage.install")}
</ActionButton>
</div>
))}
</div>
</div>
)}
{availableError && (
<div className="rounded-md border border-status-warning/30 bg-status-warning/5 px-3 py-2 text-[11px] text-status-warning/90 flex items-center gap-2">
<Info className="w-3.5 h-3.5 flex-shrink-0" />
{t("manage.cliUnavailable", { message: availableError })}
</div>
)}
</div>
);
}
function PluginCard({
plugin: p,
busy,
run,
}: {
plugin: CcPlugin;
busy: string | null;
run: (id: string, fn: () => Promise<unknown>, message: string) => Promise<boolean>;
}) {
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<string | null>(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 (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-fg-primary">{p.name}</span>
{p.marketplace && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{p.marketplace}
</span>
)}
{p.version && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
v{p.version}
</span>
)}
<ScopeBadge scope={p.scope} />
{p.enabled === true && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-status-success/10 text-status-success border border-status-success/30 inline-flex items-center gap-1">
<CircleDot className="w-3 h-3" />
{t("plugins.enabled")}
</span>
)}
{p.enabled === false && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-4/10 text-fg-secondary border border-border-light/30 inline-flex items-center gap-1">
<CircleSlash className="w-3 h-3" />
{t("plugins.disabled")}
</span>
)}
{!p.installPathExists && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-status-warning/10 text-status-warning border border-status-warning/30 inline-flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t("plugins.missing")}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed">{description}</p>
)}
{contribCounts.length > 0 && (
<div className="mt-2.5">
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("plugins.contributes")}
</div>
<div className="flex flex-wrap gap-1.5">
{contribCounts.map((c) =>
c.items?.length ? (
<button
key={c.key}
type="button"
aria-expanded={openSurface === c.key}
data-testid={`plugin-contrib-${p.key}-${c.key}`}
onClick={() => setOpenSurface((cur) => (cur === c.key ? null : c.key))}
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border inline-flex items-center gap-1 transition-colors ${
openSurface === c.key
? "bg-accent/10 text-accent border-accent/30"
: "bg-surface-3 text-fg-secondary border-border hover:border-border-light hover:text-fg-primary"
}`}
>
{c.label}
<ChevronDown
className={`w-2.5 h-2.5 transition-transform ${
openSurface === c.key ? "rotate-180" : ""
}`}
/>
</button>
) : (
<span
key={c.key}
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border"
>
{c.label}
</span>
)
)}
</div>
{opened && (
<ul
data-testid={`plugin-contrib-list-${p.key}`}
className="mt-2 rounded border border-border bg-surface-1 divide-y divide-border"
>
{opened.items?.map((item) => (
<li key={item.file} className="px-2.5 py-1.5">
<span className="font-mono text-[11px] text-fg-primary">
{opened.key === "commands" ? `/${item.name}` : item.name}
</span>
{item.description && (
<p className="mt-0.5 text-[11px] text-fg-muted leading-snug line-clamp-2">
{item.description}
</p>
)}
</li>
))}
</ul>
)}
</div>
)}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
{meta?.author?.name && (
<div>
<span className="text-fg-muted">{t("plugins.author")}:</span> {meta.author.name}
</div>
)}
{meta?.license && (
<div>
<span className="text-fg-muted">{t("plugins.license")}:</span> {meta.license}
</div>
)}
{p.installedAt && (
<div>
<span className="text-fg-muted">{t("plugins.installedAt")}:</span>{" "}
{new Date(p.installedAt).toLocaleString()}
</div>
)}
{p.lastUpdated && (
<div>
<span className="text-fg-muted">{t("plugins.lastUpdated")}:</span>{" "}
{new Date(p.lastUpdated).toLocaleString()}
</div>
)}
{p.gitCommitSha && (
<div className="col-span-2">
<span className="text-fg-muted">SHA:</span>{" "}
<span className="font-mono">{p.gitCommitSha.slice(0, 12)}</span>
</div>
)}
{meta?.homepage && (
<div className="col-span-2 truncate">
<span className="text-fg-muted">{t("plugins.homepage")}:</span>{" "}
<a
href={meta.homepage}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{meta.homepage}
</a>
</div>
)}
</div>
{p.installPath && (
<div className="mt-2 flex items-center gap-2">
<span className="font-mono text-[10px] text-fg-muted truncate flex-1">
{p.installPath}
</span>
<CopyButton value={p.installPath} />
</div>
)}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
{p.enabled === false ? (
<ActionButton
icon={CircleDot}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "enable"),
t("manage.enabled", { name: p.key })
)
}
>
{t("manage.enable")}
</ActionButton>
) : (
<ActionButton
icon={CircleSlash}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "disable"),
t("manage.disabled", { name: p.key })
)
}
>
{t("manage.disable")}
</ActionButton>
)}
<ActionButton
icon={RefreshCw}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "update"),
t("manage.updated", { name: p.key })
)
}
>
{t("manage.update")}
</ActionButton>
<ActionButton
icon={Trash2}
danger
disabled={busy === p.key}
onClick={() => {
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")}
</ActionButton>
</div>
</div>
</div>
</div>
);
}
// ── MCP servers ───────────────────────────────────────────────────────
function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string }) {
const { t } = useTranslation("ccConfig");
if (!data) return <SkeletonRows n={3} />;
const all = [...data.user, ...data.projectScoped];
const filter = (arr: CcMcpServer[]) =>
arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-4">
<ExplainerBanner
title={t("explain.mcp.title")}
body={t("explain.mcp.body")}
howTo={t("explain.mcp.add")}
commands={[
{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") },
{ cmd: t("explain.mcp.addCmd"), note: t("explain.mcp.add") },
]}
/>
{all.length === 0 && (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("mcp.noServers")}
</div>
)}
{data.user.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("mcp.userScope")}
</h3>
<div className="space-y-2">
{filter(data.user).map((s) => (
<McpCard key={`u:${s.name}:${s.source}`} server={s} />
))}
</div>
</div>
)}
{data.projectScoped.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("mcp.projectScope")}
</h3>
<div className="space-y-2">
{filter(data.projectScoped).map((s) => (
<McpCard key={`p:${s.name}:${s.source}`} server={s} />
))}
</div>
</div>
)}
</div>
);
}
function McpCard({ server }: { server: CcMcpServer }) {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-fg-primary">{server.name}</span>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{server.kind}
</span>
<span className="text-[10px] text-fg-muted ml-auto truncate max-w-xs">{server.source}</span>
</div>
<div className="mt-2 space-y-1 text-[11px]">
{server.kind === "stdio" && (
<>
<Field label={t("mcp.command")}>
<span className="font-mono text-fg-secondary">{server.command}</span>
</Field>
{server.args && server.args.length > 0 && (
<Field label={t("mcp.args")}>
<span className="font-mono text-fg-secondary">{server.args.join(" ")}</span>
</Field>
)}
{server.envNames && server.envNames.length > 0 && (
<Field label={t("mcp.env")}>
<span className="font-mono text-fg-secondary">{server.envNames.join(", ")}</span>
</Field>
)}
</>
)}
{server.kind === "http" && (
<>
<Field label={t("mcp.url")}>
<span className="font-mono text-fg-secondary">{server.url}</span>
</Field>
{server.headers && server.headers.length > 0 && (
<Field label={t("mcp.headers")}>
<span className="font-mono text-fg-secondary">{server.headers.join(", ")}</span>
</Field>
)}
</>
)}
</div>
<div className="mt-3">
<CommandSnippet
command={`claude mcp remove ${server.name}`}
label={t("explain.mcp.remove")}
/>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<span className="text-fg-muted min-w-20">{label}:</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</div>
);
}
// ── 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<string, unknown>[] }[] = [];
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<string, unknown> = {
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<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
if (!sources) return <SkeletonRows n={3} />;
return (
<div className="space-y-4">
<ExplainerBanner
title={t("explain.hooks.title")}
body={t("explain.hooks.body")}
howTo={t("explain.hooks.howTo")}
commands={[
{ cmd: t("explain.hooks.cmd1"), note: t("explain.hooks.cmd1Note") },
{ cmd: t("explain.hooks.cmd2"), note: t("explain.hooks.cmd2Note") },
{ cmd: t("explain.hooks.cmd3"), note: t("explain.hooks.cmd3Note") },
]}
/>
{sources.map((src) => (
<HookSourceBlock
key={src.scope}
source={src}
search={search}
onOpen={onOpen}
onToast={onToast}
onChanged={onChanged}
/>
))}
{scripts && scripts.items.length > 0 && (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-fg-primary">{t("hookScripts.title")}</div>
<p className="mt-1 text-[11px] text-fg-muted leading-relaxed">
{t("hookScripts.subtitle")}
</p>
<div className="mt-1 font-mono text-[10px] text-fg-muted">{scripts.dir}</div>
</div>
<div className="p-3 space-y-1.5">
{scripts.items.map((s) => (
<button
key={s.file}
onClick={() => onOpen(s.file)}
className="w-full text-left rounded-md border border-border bg-surface-1 hover:bg-surface-3 px-3 py-1.5 inline-flex items-center gap-2"
>
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary flex-1 truncate">
{s.name}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(s.size)}</span>
<span className="text-[10px] text-fg-muted hidden md:inline">
{new Date(s.mtime).toLocaleDateString()}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
/**
* One settings layer's hooks, readable and editable. Saving writes ONLY the
* `hooks.<Event>` 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<Toast>) => 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<string | null>(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<string, unknown> = {};
const seen = new Set<string>();
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 (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={src.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
{src.file}
</span>
{src.exists ? (
<button
onClick={() => onOpen(src.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
) : (
<span className="text-[11px] text-fg-muted">{t("hooks.fileMissing")}</span>
)}
{draft ? (
<div className="inline-flex items-center gap-1.5">
<ActionButton icon={X} onClick={() => setDraft(null)} disabled={busy === "save"}>
{t("edit.cancel")}
</ActionButton>
<ActionButton icon={Save} onClick={() => void save()} disabled={busy === "save"}>
{busy === "save" ? t("edit.saving") : t("edit.save")}
</ActionButton>
</div>
) : (
<ActionButton icon={Pencil} onClick={startEdit}>
{t("edit.editButton")}
</ActionButton>
)}
</div>
{err && (
<div className="mx-3 mt-3 rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span>{err}</span>
</div>
)}
{draft ? (
<div className="p-3 space-y-3">
{draft.map((g, ei) => (
<div key={ei} className="rounded-md border border-border bg-surface-1">
<div className="border-b border-border px-2.5 py-1.5 flex items-center gap-2">
<Wrench className="w-3 h-3 text-fg-muted flex-shrink-0" />
<input
value={g.event}
onChange={(e) =>
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"
/>
<button
type="button"
onClick={() => setDraft((d) => (d ? d.filter((_, i) => i !== ei) : d))}
title={t("manage.hookRemoveEvent")}
aria-label={t("manage.hookRemoveEvent")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="divide-y divide-border">
{g.entries.map((h, hi) => (
<div key={hi} className="px-2.5 py-2 space-y-1.5">
<div className="flex items-center gap-2">
<input
value={h.matcher || ""}
onChange={(e) => 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"
/>
<input
value={h.timeout == null ? "" : String(h.timeout)}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() =>
setDraft((d) =>
d
? d.map((x, i) =>
i === ei
? { ...x, entries: x.entries.filter((_, j) => j !== hi) }
: x
)
: d
)
}
title={t("manage.hookRemoveEntry")}
aria-label={t("manage.hookRemoveEntry")}
className="h-7 w-7 ml-auto flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<input
value={h.command || ""}
onChange={(e) => 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"
/>
</div>
))}
<div className="px-2.5 py-1.5">
<ActionButton
icon={Plus}
onClick={() =>
setDraft((d) =>
d
? d.map((x, i) =>
i === ei
? {
...x,
entries: [
...x.entries,
{ matcher: "*", type: "command", command: "", timeout: null },
],
}
: x
)
: d
)
}
>
{t("manage.hookAddEntry")}
</ActionButton>
</div>
</div>
</div>
))}
<button
type="button"
onClick={() =>
setDraft((d) => [
...(d ?? []),
{
event: "",
entries: [{ matcher: "*", type: "command", command: "", timeout: null }],
},
])
}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("manage.hookAddEvent")}
</button>
{readOnlyEvents.length > 0 && (
<p className="text-[10px] text-fg-muted leading-relaxed">
{t("manage.hookUneditable", {
events: readOnlyEvents.map(([e]) => e).join(", "),
})}
</p>
)}
</div>
) : (
<div className="p-3">
{filteredEvents.length === 0 ? (
<div className="text-xs text-fg-muted px-1 py-2">{t("hooks.noHooks")}</div>
) : (
<div className="space-y-3">
{filteredEvents.map(([event, entries]) => (
<div key={event}>
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5 inline-flex items-center gap-2">
<Wrench className="w-3 h-3 text-fg-muted" />
{event}
<span className="text-[10px] text-fg-muted">({entries.length})</span>
</div>
<div className="space-y-1.5 pl-5">
{entries.map((h, idx) => (
<div
key={`${event}-${idx}`}
className="rounded-md border border-border bg-surface-1 px-2.5 py-1.5 text-[11px]"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-fg-muted">
{t("hooks.matcher")}={h.matcher}
</span>
<span className="text-[10px] text-fg-muted">·</span>
<span className="font-mono text-[10px] text-fg-muted">{h.type}</span>
{h.timeout != null && (
<span className="text-[10px] text-fg-muted">{h.timeout}ms</span>
)}
</div>
{h.command && (
<div className="mt-1 font-mono text-[11px] text-fg-secondary break-all">
{h.command}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ── 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<string, { value: unknown; scope: CcSettingsSource["scope"] }>();
for (const src of sources) {
if (!src.exists || !src.data || typeof src.data !== "object") continue;
const data = src.data as Record<string, unknown>;
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 (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<SettingsIcon className="w-3.5 h-3.5 text-violet-300/80" />
<span className="text-sm font-medium text-fg-primary">Current configuration</span>
<span className="text-[11px] text-fg-muted ml-auto">
{setCount} option{setCount !== 1 ? "s" : ""} set · the rest use defaults
</span>
</div>
<div className="p-3 space-y-3">
{CONFIG_OPTION_GROUPS.map((group) => (
<div key={group.title}>
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{group.title}
</div>
<div className="rounded-md border border-border bg-surface-1 divide-y divide-border">
{group.keys.map(({ key, label }) => {
const hit = effective.get(key);
return (
<div
key={key}
className="px-3 py-1.5 grid grid-cols-[150px_1fr_auto] gap-3 items-center"
>
<div className="text-[11px] text-fg-secondary truncate">{label}</div>
<div className="min-w-0">
{hit ? (
<SettingsValue value={hit.value} />
) : (
<span className="text-[10px] text-fg-muted italic">default</span>
)}
</div>
{hit ? (
<ScopeBadge scope={hit.scope} />
) : (
<span className="text-[10px] text-fg-muted">-</span>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
function SettingsPanel({
sources,
statusline,
onOpen,
onToast,
onChanged,
}: {
sources: CcSettingsSource[] | null;
statusline: CcStatusline | null;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
if (!sources) return <SkeletonRows n={3} />;
return (
<div className="space-y-3">
<ExplainerBanner
title={t("explain.settings.title")}
body={t("explain.settings.body")}
howTo={t("explain.settings.howTo")}
commands={[
{ cmd: t("explain.settings.cmd1"), note: t("explain.settings.cmd1Note") },
{ cmd: t("explain.settings.cmd2"), note: t("explain.settings.cmd2Note") },
{ cmd: t("explain.settings.cmd3"), note: t("explain.settings.cmd3Note") },
]}
/>
<CurrentConfigPanel sources={sources} />
<div className="rounded-md border border-status-warning/30 bg-status-warning/5 px-3 py-2 text-[11px] text-status-warning/90 flex items-center gap-2">
<Info className="w-3.5 h-3.5 flex-shrink-0" />
{t("common.redactedNotice")}
</div>
{statusline && (statusline.config || statusline.scripts.length > 0) && (
<StatuslineBlock data={statusline} onOpen={onOpen} />
)}
{sources.map((src) => (
<SettingsBlock
key={src.scope}
source={src}
onOpen={onOpen}
onToast={onToast}
onChanged={onChanged}
/>
))}
</div>
);
}
function StatuslineBlock({ data, onOpen }: { data: CcStatusline; onOpen: (p: string) => void }) {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-fg-primary">{t("statusline.title")}</div>
</div>
<div className="p-3 space-y-3">
{data.config ? (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t("statusline.configured")}
</div>
<div className="rounded-md border border-border bg-surface-1 px-3 py-2 text-[11px] font-mono text-fg-secondary">
<span className="text-fg-muted">type:</span> {data.config.type ?? "-"}
{data.config.command && (
<>
<br />
<span className="text-fg-muted">command:</span> {data.config.command}
</>
)}
</div>
</div>
) : (
<div className="text-xs text-fg-muted">{t("statusline.noStatusline")}</div>
)}
{data.scripts.length > 0 && (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t("statusline.scripts")}
</div>
<div className="space-y-1.5">
{data.scripts.map((s) => (
<button
key={s.file}
onClick={() => onOpen(s.file)}
className="w-full text-left rounded-md border border-border bg-surface-1 hover:bg-surface-3 px-3 py-1.5 inline-flex items-center gap-2"
>
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary flex-1 truncate">
{s.file}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(s.size)}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}
/** 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("<redacted>") ?? false;
} catch {
return false;
}
}
/** A JSON object (not an array, not null) — the only value kind the tree
* recurses into. Everything else (string/number/boolean/null/array) is a
* leaf, edited as one unit. */
function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}
function SettingsBlock({
source,
onOpen,
onToast,
onChanged,
}: {
source: CcSettingsSource;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const [showRaw, setShowRaw] = useState(false);
// Last successful write in this block — shown inline so the backup it took
// is visible without hunting through the (auto-dismissing) toast.
const [lastSaved, setLastSaved] = useState<{ key: string; backupPath: string | null } | null>(
null
);
const [addOpen, setAddOpen] = useState(false);
const [addKey, setAddKey] = useState("");
const [addValue, setAddValue] = useState("");
const [addErr, setAddErr] = useState<string | null>(null);
const [addBusy, setAddBusy] = useState(false);
const data = (source.data as Record<string, unknown> | null | undefined) || {};
const handleSaved = useCallback(
(dottedKey: string, result: CcSettingsPatchResult) => {
setLastSaved({ key: dottedKey, backupPath: result.backupPath });
onToast({
kind: "success",
message: t("manage.settingsKeySaved", { key: dottedKey, backup: result.backupPath || "-" }),
});
onChanged();
},
[onChanged, onToast, t]
);
const submitAdd = async () => {
const key = addKey.trim();
if (!key) {
setAddErr(t("manage.settingsEmptyKey"));
return;
}
let value: unknown;
try {
value = JSON.parse(addValue);
} catch {
setAddErr(t("manage.settingsBadJson", { key }));
return;
}
setAddBusy(true);
setAddErr(null);
try {
const result = await api.ccConfig.patchSettings({
scope: source.scope,
set: { [key]: value },
});
handleSaved(key, result);
setAddOpen(false);
setAddKey("");
setAddValue("");
} catch (err: unknown) {
setAddErr(err instanceof Error ? err.message : "unknown error");
} finally {
setAddBusy(false);
}
};
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={source.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
{source.file}
</span>
{source.exists ? (
<>
<button
onClick={() => setShowRaw((v) => !v)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary"
>
{showRaw ? "Structured" : "Raw JSON"}
</button>
<button
onClick={() => onOpen(source.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
</>
) : (
<span className="text-[11px] text-fg-muted">{t("settings.fileMissing")}</span>
)}
</div>
{lastSaved && (
<div className="px-4 py-1.5 border-b border-border text-[10px] text-fg-muted">
{t("manage.settingsLastBackup", {
key: lastSaved.key,
backup: lastSaved.backupPath || "-",
})}
</div>
)}
{source.exists &&
(showRaw ? (
<pre className="p-3 text-[11px] font-mono text-fg-secondary overflow-auto max-h-96">
{JSON.stringify(source.data, null, 2)}
</pre>
) : (
<>
<SettingsTree data={data} scope={source.scope} ancestry={[]} onSaved={handleSaved} />
<div className="p-3 border-t border-border">
{addOpen ? (
<div className="rounded-md border border-border bg-surface-1 px-2.5 py-2 space-y-1.5">
<input
value={addKey}
onChange={(e) => setAddKey(e.target.value)}
placeholder={t("manage.settingsKeyPlaceholder")}
aria-label={t("manage.settingsKey")}
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"
/>
<textarea
value={addValue}
onChange={(e) => setAddValue(e.target.value)}
rows={2}
spellCheck={false}
aria-label={t("manage.settingsValue")}
className="w-full bg-surface-2 border border-border rounded px-2 py-1 text-[11px] font-mono text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
{addErr && <div className="text-[10px] text-status-danger">{addErr}</div>}
<div className="flex items-center gap-1.5 justify-end">
<ActionButton icon={X} onClick={() => setAddOpen(false)} disabled={addBusy}>
{t("edit.cancel")}
</ActionButton>
<ActionButton icon={Save} onClick={() => void submitAdd()} disabled={addBusy}>
{addBusy ? t("edit.saving") : t("edit.save")}
</ActionButton>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAddOpen(true)}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("manage.settingsAddKey")}
</button>
)}
</div>
</>
))}
</div>
);
}
/** Recursively renders one settings.json layer: a plain-object value gets an
* indented sub-tree, everything else is one editable leaf row. The dotted
* path passed to `patchSettings` is rebuilt from `ancestry` at each leaf. */
function SettingsTree({
data,
scope,
ancestry,
onSaved,
}: {
data: Record<string, unknown>;
scope: CcSettingsSource["scope"];
ancestry: string[];
onSaved: (dottedKey: string, result: CcSettingsPatchResult) => void;
}) {
const entries = Object.entries(data);
if (entries.length === 0) {
return <div className="p-3 text-xs text-fg-muted">{"{}"}</div>;
}
return (
<div className="divide-y divide-border">
{entries.map(([key, value]) => (
<SettingsLeafRow
key={key}
keyName={key}
value={value}
scope={scope}
ancestry={ancestry}
onSaved={onSaved}
/>
))}
</div>
);
}
function SettingsLeafRow({
keyName,
value,
scope,
ancestry,
onSaved,
}: {
keyName: string;
value: unknown;
scope: CcSettingsSource["scope"];
ancestry: string[];
onSaved: (dottedKey: string, result: CcSettingsPatchResult) => void;
}) {
const { t } = useTranslation("ccConfig");
const [editing, setEditing] = useState(false);
const [text, setText] = useState("");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const dotted = [...ancestry, keyName].join(".");
const startEdit = () => {
setErr(null);
// Strings edit as raw text (no surrounding quotes); everything else is
// JSON so the type round-trips (42, true, ["a"]).
setText(typeof value === "string" ? value : JSON.stringify(value));
setEditing(true);
};
const save = async () => {
let parsed: unknown;
if (typeof value === "string") {
parsed = text;
} else {
try {
parsed = JSON.parse(text);
} catch {
setErr(t("manage.settingsBadJson", { key: dotted }));
return;
}
}
setSaving(true);
setErr(null);
try {
const result = await api.ccConfig.patchSettings({ scope, set: { [dotted]: parsed } });
setEditing(false);
onSaved(dotted, result);
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : "unknown error");
} finally {
setSaving(false);
}
};
if (isPlainObject(value)) {
return (
<div className="px-3 py-2">
<div className="font-mono text-[11px] text-fg-secondary mb-1">{keyName}</div>
<div className="pl-3 border-l border-border">
<SettingsTree
data={value}
scope={scope}
ancestry={[...ancestry, keyName]}
onSaved={onSaved}
/>
</div>
</div>
);
}
if (isRedacted(value)) {
return (
<div className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-1 md:gap-3 items-center">
<div className="font-mono text-[11px] text-fg-secondary truncate">{keyName}</div>
<span
title={t("manage.settingsRedactedTooltip")}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-muted border border-border inline-flex items-center gap-1 w-fit"
>
<Lock className="w-3 h-3" />
{t("manage.settingsRedactedChip")}
</span>
</div>
);
}
return (
<div className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr_auto] gap-1 md:gap-3 items-start">
<div className="font-mono text-[11px] text-fg-secondary truncate pt-0.5">{keyName}</div>
<div className="min-w-0">
{editing ? (
<input
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
aria-label={t("manage.settingsValue")}
className="w-full bg-surface-1 border border-border rounded px-2 py-1 text-[11px] font-mono text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
) : (
<SettingsValue value={value} />
)}
{err && <div className="mt-1 text-[10px] text-status-danger">{err}</div>}
</div>
<div className="flex items-center gap-1">
{editing ? (
<>
<button
type="button"
onClick={() => void save()}
disabled={saving}
aria-label={t("edit.save")}
className="h-6 w-6 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-accent hover:bg-accent/10 disabled:opacity-50"
>
<Save className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => {
setEditing(false);
setErr(null);
}}
disabled={saving}
aria-label={t("edit.cancel")}
className="h-6 w-6 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
>
<X className="w-3.5 h-3.5" />
</button>
</>
) : (
<button
type="button"
onClick={startEdit}
aria-label={t("manage.editKey", { key: keyName })}
className="h-6 w-6 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3"
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
</div>
);
}
function SettingsValue({ value }: { value: unknown }) {
if (value === null || value === undefined)
return <span className="text-[11px] text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
return (
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
value
? "bg-status-success/10 text-status-success border-status-success/30"
: "bg-surface-4/10 text-fg-secondary border-border-light/30"
}`}
>
{value ? "true" : "false"}
</span>
);
}
if (typeof value === "number") {
return <span className="font-mono text-[11px] text-fg-secondary">{value}</span>;
}
if (typeof value === "string") {
return <span className="font-mono text-[11px] text-fg-secondary break-all">{value}</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-[11px] text-fg-muted">[]</span>;
return (
<div className="flex flex-wrap gap-1">
{value.map((item, i) => (
<span
key={i}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border break-all"
>
{typeof item === "object" ? JSON.stringify(item) : String(item)}
</span>
))}
</div>
);
}
// object
const obj = value as Record<string, unknown>;
return (
<div className="space-y-0.5">
{Object.entries(obj).map(([k, v]) => (
<div key={k} className="font-mono text-[11px]">
<span className="text-fg-muted">{k}:</span>{" "}
<span className="text-fg-secondary break-all">
{typeof v === "object" ? JSON.stringify(v) : String(v)}
</span>
</div>
))}
</div>
);
}
// ── 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 <pre> 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(
<button
key={`${li}-${m.index}`}
type="button"
onClick={() => onJump(capturedTarget)}
title={`${jumpTitle}: ${normalizeMemoryTarget(capturedTarget)}`}
className="text-teal-300 hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
>
{label}
</button>
);
last = m.index + full.length;
}
if (last < line.length) parts.push(line.slice(last));
return (
<span key={li}>
{parts.length ? parts : line}
{li < lines.length - 1 ? "\n" : null}
</span>
);
});
}
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<string, CcMemoryItem[]>();
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 <SkeletonRows n={2} />;
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 (
<div className="space-y-3">
{/* Primary CLAUDE.md memory (user + project) — editable */}
{primary.map((m) => (
<div key={m.scope} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={m.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
{m.file}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(m.size)}</span>
<button
onClick={() => onOpen(m.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
<button
onClick={() => onEdit("memory", { scope: m.scope, name: "", filePath: m.file })}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
<button
onClick={() => onDelete("memory", m.scope, undefined, m.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
>
<Trash2 className="w-3 h-3" />
{t("edit.deleteButton")}
</button>
</div>
<pre className="p-3 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-72 overflow-auto">
{m.preview}
{m.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
</div>
))}
{showMissing &&
missingScopes.map((s) => (
<div
key={`missing-${s}`}
className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-4 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<ScopeBadge scope={s} />
<span className="text-xs text-fg-muted">{t("memory.missing")}</span>
</div>
<button
onClick={() => onCreate(s)}
className="text-[11px] font-medium px-2.5 py-1 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
</div>
))}
{/* Per-project file-based memory (~/.claude/projects/<slug>/memory/) */}
{totalAuto > 0 && (
<section className="pt-1">
<div className="flex items-center gap-2 mb-1">
<BookOpen className="w-3.5 h-3.5 text-teal-300" />
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-secondary">
{t("memory.autoTitle")}
</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-semibold bg-surface-3 text-fg-secondary">
{q ? `${autoFiltered.length}/${totalAuto}` : totalAuto}
</span>
</div>
<p className="text-[11px] text-fg-muted mb-2.5 leading-relaxed">
{t("memory.autoSubtitle")}
</p>
{groups.length === 0 ? (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("memory.noMatches")}
</div>
) : (
<div className="space-y-2">
{groups.map(([project, files]) => (
<MemoryProjectGroup
key={project}
project={project}
files={files}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onCreateAuto={onCreateAuto}
defaultOpen={!!q || groups.length === 1}
/>
))}
</div>
)}
</section>
)}
</div>
);
}
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<Map<string, HTMLDivElement>>(new Map());
const [highlighted, setHighlighted] = useState<string | null>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
<div className="flex items-center gap-1 pr-2 hover:bg-surface-3 transition-colors">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-2.5 text-left flex-1 min-w-0"
>
<ChevronDown
className={`w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform ${
open ? "" : "-rotate-90"
}`}
/>
<FolderTree className="w-3.5 h-3.5 text-teal-300 flex-shrink-0" />
<span className="font-mono text-xs text-fg-secondary truncate flex-1 min-w-0">
{project}
</span>
<span className="text-[10px] text-fg-muted flex-shrink-0">
{t("memory.fileCount", { count: files.length })}
</span>
</button>
<button
onClick={() => onCreateAuto(project)}
title={t("memory.newFile")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1 flex-shrink-0"
>
<Plus className="w-2.5 h-2.5" />
{t("memory.newFile")}
</button>
</div>
{open && (
<div className="border-t border-border p-2.5 space-y-2.5">
{indexFiles.length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5 px-1">
{t("memory.indexFiles")}
</div>
<div className="space-y-1.5">
{indexFiles.map((m) => (
<MemoryIndexCard
key={m.file}
item={m}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onJump={handleJump}
/>
))}
</div>
</div>
)}
{factFiles.length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5 px-1">
{t("memory.factFiles", { count: factFiles.length })}
</div>
<div className="space-y-1">
{factFiles.map((m) => {
const key = rowKey(m);
return (
<MemoryFactRow
key={m.file}
item={m}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
highlighted={highlighted === key}
rowRef={(el) => {
if (el) rowRefs.current.set(key, el);
else rowRefs.current.delete(key);
}}
/>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
}
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 (
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onOpen(item.file)}
title={t("common.viewSource")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1"
>
<ExternalLink className="w-2.5 h-2.5" />
</button>
<button
onClick={() => onEditAuto(item)}
title={t("edit.editButton")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1"
>
<Pencil className="w-2.5 h-2.5" />
</button>
<button
onClick={() => onDeleteAuto(item)}
title={t("edit.deleteButton")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</div>
);
}
function MemoryIndexCard({
item,
onOpen,
onEditAuto,
onDeleteAuto,
onJump,
}: MemoryAutoItemProps & { onJump: (target: string) => void }) {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
<BookOpen className="w-3 h-3 text-teal-300 flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
{item.name}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(item.size)}</span>
<MemoryAutoActions
item={item}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
/>
</div>
<pre className="px-3 py-2 text-[10.5px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-40 overflow-auto">
{renderMemoryIndex(item.preview, onJump, t("memory.jumpTo"))}
{item.truncated && <span className="text-fg-muted italic">{"\n…"}</span>}
</pre>
</div>
);
}
function MemoryFactRow({
item,
onOpen,
onEditAuto,
onDeleteAuto,
highlighted,
rowRef,
}: MemoryAutoItemProps & {
highlighted?: boolean;
rowRef?: (el: HTMLDivElement | null) => void;
}) {
const desc = memoryDescription(item);
return (
<div
ref={rowRef}
className={`flex items-start gap-2.5 rounded-md border px-3 py-2 transition-colors ${
highlighted
? "border-teal-400/70 bg-teal-500/10 ring-1 ring-teal-400/50"
: "border-border bg-surface-1 hover:border-border/80"
}`}
>
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<span className="font-mono text-[11px] text-fg-secondary truncate block">{item.name}</span>
{desc && (
<p className="mt-0.5 text-[11px] text-fg-muted leading-snug line-clamp-2">{desc}</p>
)}
</div>
<span className="text-[10px] text-fg-muted flex-shrink-0 mt-0.5">
{formatBytes(item.size)}
</span>
<div className="mt-0.5">
<MemoryAutoActions
item={item}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
/>
</div>
</div>
);
}
// ── Marketplaces ──────────────────────────────────────────────────────
function MarketplacesPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcMarketplacesResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [source, setSource] = useState("");
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const add = useCallback(async () => {
const value = source.trim();
if (!value) return;
const ok = await run(
"add",
() => api.ccConfig.addMarketplace(value),
t("manage.marketplaceAdded", { name: value })
);
if (ok) setSource("");
}, [run, source, t]);
if (!data) return <SkeletonRows n={3} />;
const filtered = data.items.filter(
(m) =>
!search ||
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.marketplaceName || "").toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-3">
<ExplainerBanner
title={t("explain.marketplaces.title")}
body={t("explain.marketplaces.body")}
howTo={t("marketplaces.manifest")}
commands={[{ cmd: t("marketplaces.addCmd"), note: "" }]}
/>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
{t("manage.addMarketplaceTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void add();
}}
placeholder={t("manage.addMarketplacePlaceholder")}
aria-label={t("manage.addMarketplaceTitle")}
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"
/>
<button
type="button"
onClick={() => void add()}
disabled={!source.trim() || busy === "add"}
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.addMarketplaceButton")}
</button>
</div>
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">
{t("manage.addMarketplaceHelp")}
</p>
</div>
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate">{data.knownPath}</span>
</div>
{filtered.length === 0 ? (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("marketplaces.noMarketplaces")}
</div>
) : (
filtered.map((m) => (
<div key={m.name} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Store className="w-3.5 h-3.5 text-fg-muted" />
<span className="font-mono text-sm text-fg-primary">{m.name}</span>
{m.marketplaceName && m.marketplaceName !== m.name && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{m.marketplaceName}
</span>
)}
{m.pluginCount != null && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{t("marketplaces.pluginCount")}: {m.pluginCount}
</span>
)}
</div>
{m.marketplaceDescription && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{m.marketplaceDescription}
</p>
)}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
{m.source && (
<div className="col-span-2 truncate">
<span className="text-fg-muted">{t("marketplaces.source")}:</span>{" "}
<span className="font-mono">
{m.source.source === "github" && m.source.repo
? `github.com/${m.source.repo}`
: m.source.url || m.source.repo || JSON.stringify(m.source)}
</span>
</div>
)}
{m.marketplaceOwner?.name && (
<div>
<span className="text-fg-muted">{t("marketplaces.owner")}:</span>{" "}
{m.marketplaceOwner.name}
</div>
)}
{m.lastUpdated && (
<div>
<span className="text-fg-muted">{t("marketplaces.lastUpdated")}:</span>{" "}
{new Date(m.lastUpdated).toLocaleString()}
</div>
)}
</div>
{m.installLocation && (
<div className="mt-2 flex items-center gap-2">
<span className="font-mono text-[10px] text-fg-muted truncate flex-1">
{m.installLocation}
</span>
<CopyButton value={m.installLocation} />
</div>
)}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
<ActionButton
icon={RefreshCw}
disabled={busy === m.name}
onClick={() =>
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "update"),
t("manage.marketplaceUpdated", { name: m.name })
)
}
>
{t("manage.update")}
</ActionButton>
<ActionButton
icon={Trash2}
danger
disabled={busy === m.name}
onClick={() => {
if (confirmRemove !== m.name) {
setConfirmRemove(m.name);
return;
}
setConfirmRemove(null);
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "remove"),
t("manage.marketplaceRemoved", { name: m.name })
);
}}
>
{busy === m.name
? t("manage.working")
: confirmRemove === m.name
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
</div>
</div>
))
)}
</div>
);
}
// ── Keybindings ───────────────────────────────────────────────────────
function KeybindingsPanel({
data,
search,
onSaved,
onToast,
}: {
data: CcKeybindings | null;
search: string;
onSaved: () => void;
onToast: (toast: NonNullable<Toast>) => void;
}) {
const { t } = useTranslation("ccConfig");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<CcKeybindingGroup[]>([]);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(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<string>();
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<string>();
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 <SkeletonRows n={3} />;
const headerBar = (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted flex-wrap">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate flex-1 min-w-0">{data.file}</span>
{data.docs && (
<a
href={data.docs}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-accent hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
{t("keybindings.docsLink")}
</a>
)}
{editing ? (
<div className="inline-flex items-center gap-1.5">
<button
type="button"
onClick={cancelEdit}
disabled={saving}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary disabled:opacity-50"
>
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Save className="w-3 h-3" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
) : (
<button
type="button"
onClick={startEdit}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
)}
</div>
);
// ── Edit mode ────────────────────────────────────────────────────────
if (editing) {
return (
<div className="space-y-3">
{headerBar}
{err && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span>{err}</span>
</div>
)}
{draft.map((g, gi) => (
<div key={gi} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-3 py-2 flex items-center gap-2">
<span className="text-[11px] text-fg-muted flex-shrink-0">
{t("keybindings.context")}
</span>
<input
value={g.context}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => removeContext(gi)}
title={t("keybindings.removeContext")}
aria-label={t("keybindings.removeContext")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="divide-y divide-border">
{g.bindings.map((b, bi) => (
<div key={bi} className="px-3 py-1.5 flex items-center gap-2">
<input
value={b.key}
onChange={(e) => 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"
/>
<span className="text-fg-muted flex-shrink-0">→</span>
<input
value={b.action}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => removeBinding(gi, bi)}
title={t("keybindings.removeBinding")}
aria-label={t("keybindings.removeBinding")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="px-3 py-1.5">
<button
type="button"
onClick={() => addBinding(gi)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("keybindings.addBinding")}
</button>
</div>
</div>
</div>
))}
<button
type="button"
onClick={addContext}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("keybindings.addContext")}
</button>
</div>
);
}
// ── Read-only mode ───────────────────────────────────────────────────
if (!data.exists) {
return (
<div className="space-y-3">
{headerBar}
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("keybindings.missing", { path: data.file })}
</div>
</div>
);
}
const q = search.toLowerCase();
return (
<div className="space-y-3">
{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 (
<div key={g.context} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2 text-xs font-medium text-fg-secondary">
{t("keybindings.context")}: <span className="text-fg-primary">{g.context}</span>
<span className="ml-2 text-[10px] text-fg-muted">({filtered.length})</span>
</div>
<div className="divide-y divide-border">
{filtered.map((b) => (
<div key={b.key} className="px-4 py-1.5 flex items-center gap-3">
<kbd className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-surface-3 border border-border text-fg-secondary min-w-20 text-center">
{b.key}
</kbd>
<span className="font-mono text-[11px] text-fg-secondary">{b.action}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
// ── Read-only explainer banner ─────────────────────────────────────────
interface ExplainerBannerProps {
title: string;
body: string;
howTo: string;
commands: { cmd: string; note: string }[];
}
function ExplainerBanner({ title, body, howTo, commands }: ExplainerBannerProps) {
return (
<div className="rounded-lg border border-status-warning/30 bg-status-warning/[0.04] px-4 py-3">
<div className="flex items-start gap-2">
<Lock className="w-4 h-4 text-status-warning flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1 space-y-2">
<div className="text-sm font-medium text-status-warning">{title}</div>
<p className="text-xs text-fg-secondary leading-relaxed">{body}</p>
{commands.length > 0 && (
<div className="pt-1">
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{howTo}
</div>
<div className="space-y-1.5">
{commands.map((c, i) => (
<CommandSnippet key={i} command={c.cmd} label={c.note} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Inline copyable command ────────────────────────────────────────────
function CommandSnippet({ command, label }: { command: string; label?: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
{label && (
<span className="text-[10px] text-fg-muted hidden md:inline truncate">{label}</span>
)}
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
);
}
// ── Auth profiles modal ────────────────────────────────────────────────
//
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
// subagents, memory, plugins, marketplaces and settings all stay exactly where
// they are — that is the whole point of the feature.
//
// The dashboard cannot log a new account in (only `claude auth login` can mint
// tokens), so adding a profile is always "log in, then snapshot what the CLI
// wrote" — hence a save button on the live account rather than an add-account
// flow. There is no name field: the server derives the identifier, and the user
// edits the display label in place by clicking it. That keeps the one string
// the user types free of filename rules, so spaces and diacritics are fine.
// The save button only appears while the live account is unsaved, because one
// account is one profile — a second copy would just report itself active too.
function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: Toast) => void }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const load = useCallback(async () => {
try {
const r = await api.ccConfig.profiles();
setItems(r.items);
setActive(r.active);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
/** Run one profile mutation, then refresh the list. */
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => {
setBusy(key);
try {
await fn();
onToast({ kind: "success", message: success });
await load();
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : "unknown error",
});
} finally {
setBusy(null);
}
},
[load, onToast]
);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
const copies = (p: CcProfile) =>
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<UserCog className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("profiles.title")}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border space-y-2">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("profiles.warning")}</span>
</p>
</div>
<div className="overflow-auto p-4 space-y-3">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("profiles.activeLabel")}
</div>
{active ? (
<>
<div className="text-sm text-fg-primary">
{active.email || active.displayName || active.accountUuid}
</div>
<IdentityLine identity={active} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Save className="w-3 h-3" />
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
</button>
</div>
)}
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("profiles.empty")}
</div>
)}
{items?.map((p) => (
<div key={p.name} className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
{renaming === p.name ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (e.key !== "Enter") return;
const next = draft.trim();
setRenaming(null);
if (!next || next === p.label) return;
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
);
}}
maxLength={60}
aria-label={t("profiles.renameLabel")}
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
) : (
<button
onClick={() => {
setDraft(p.label);
setRenaming(p.name);
}}
title={t("profiles.renameHint")}
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
>
{p.label}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
</button>
)}
{p.active && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
{t("profiles.activeBadge")}
</span>
)}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`switch:${p.name}`,
() => api.ccConfig.activateProfile(p.name),
t("profiles.switchSuccess", { name: p.label })
)
}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{/* Deletable unless it is the ONLY saved copy of the live
account — a redundant copy has to be removable, or an
accidental duplicate could never be cleaned up. */}
{(!p.active || copies(p) > 1) && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`del:${p.name}`,
() => api.ccConfig.deleteProfile(p.name),
t("profiles.deleteSuccess", { name: p.label })
)
}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
<div>
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
<CommandSnippet command="claude auth login" />
</div>
</div>
</div>
</div>
);
}
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
Boolean
);
if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
// ── Backups modal ──────────────────────────────────────────────────────
function BackupsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcBackup[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.ccConfig
.backups()
.then((r) => setItems(r.items))
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-3xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<History className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("backups.title")}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("backups.subtitle")}</p>
</div>
<div className="overflow-auto p-4 space-y-2">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{items === null && !error && <SkeletonRows n={4} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("backups.empty")}
</div>
)}
{items?.map((b) => (
<BackupRow key={b.backupPath} backup={b} />
))}
</div>
</div>
</div>
);
}
function BackupRow({ backup }: { backup: CcBackup }) {
const { t } = useTranslation("ccConfig");
// Heuristic restore: rename the backup back to the active path. We don't
// shell out from the dashboard for this (too risky to silently overwrite
// a current active version) - show the user a copyable mv command instead.
const restoreCmd = `mv ${shellEscape(backup.backupPath)} ${shellEscape(deriveActivePath(backup))}`;
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
<ScopeBadge scope={backup.scope} />
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{backup.type}
</span>
<span className="font-mono text-xs text-fg-primary truncate flex-1 min-w-0">
{backup.name}
</span>
<span className="text-[10px] text-fg-muted">{new Date(backup.mtime).toLocaleString()}</span>
{backup.size != null && (
<span className="text-[10px] text-fg-muted">{formatBytes(backup.size)}</span>
)}
</div>
<div className="mt-1.5 font-mono text-[10px] text-fg-muted truncate">{backup.backupPath}</div>
<div className="mt-2">
<div className="text-[10px] text-fg-muted mb-1">{t("backups.restoreHint")}</div>
<CommandSnippet command={restoreCmd} />
</div>
</div>
);
}
// Best-effort: derive the active path the backup would restore to. We strip
// the trailing `.<ISO>.bak` segment from the basename and put the result back
// under the right active subdir. If the format doesn't match, fall back to
// "(unknown)" - the user can still copy the backup path itself.
function deriveActivePath(b: CcBackup): string {
// Strip ".<ISO>.bak" suffix from the basename.
const m = b.name.match(/^(.+?)\.[^.]+\.bak$/);
const baseName = m ? m[1] : b.name;
const backupDir = b.backupPath.replace(/\/[^/]+$/, ""); // dirname
// Backups live at <root>/cc-config-backups/<type>/<name>.<ts>.bak
// Active lives at <root>/<type>/<baseName> (or .../skills/<baseName>/SKILL.md handled at use-time)
const rootMatch = backupDir.match(/^(.*)\/cc-config-backups\/([^/]+)$/);
if (rootMatch) {
const root = rootMatch[1];
const type = rootMatch[2];
return `${root}/${type}/${baseName}`;
}
// memory backups live at <projectRoot>/.cc-config-backups/memory/<name>.<ts>.bak
const memMatch = backupDir.match(/^(.*)\/\.cc-config-backups\/memory$/);
if (memMatch) return `${memMatch[1]}/${baseName}`;
// auto-memory backups live at <memoryDir>/.cc-config-backups/auto-memory/<name>.<ts>.bak
const autoMatch = backupDir.match(/^(.*)\/\.cc-config-backups\/auto-memory$/);
if (autoMatch) return `${autoMatch[1]}/${baseName}`;
return "<active path>";
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}