Files
Claude-Code-Monitor/client/src/pages/CcConfig.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00

3691 lines
132 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). Plugins, MCP,
* hooks-in-settings, and settings.json files stay read-only - those have
* concurrent-write races with the live CLI.
* @author Nguyễn Ngọc Trí Vĩ <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.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/eventBus`
* - `../lib/api`
*
* ## Public surface
* - `CcConfig` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **CcConfig**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
RefreshCw,
Search,
Sparkles,
UserRound,
FolderTree,
Wrench,
Slash,
Palette,
PlugZap,
Server,
Webhook,
Settings as SettingsIcon,
BookOpen,
FileText,
Copy,
Check,
AlertCircle,
ExternalLink,
X,
Info,
Pencil,
Trash2,
Plus,
Save,
ShieldAlert,
Lock,
History,
Terminal,
Store,
Keyboard,
CircleDot,
CircleSlash,
ChevronLeft,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { api } from "../lib/api";
import type {
CcArtifactType,
CcBackup,
CcFileResponse,
CcHookScripts,
CcHookSource,
CcKeybindings,
CcKeybindingGroup,
CcMarketplacesResponse,
CcMcpResponse,
CcMcpServer,
CcMdItem,
CcMemoryItem,
CcMutationResult,
CcOverview,
CcPlugin,
CcPluginsResponse,
CcScope,
CcSettingsSource,
CcStatusline,
} from "../lib/api";
function isMutable(
tab: TabKey
): tab is "skills" | "agents" | "commands" | "outputStyles" | "memory" {
return (
tab === "skills" ||
tab === "agents" ||
tab === "commands" ||
tab === "outputStyles" ||
tab === "memory"
);
}
function tabToArtifactType(
tab: "skills" | "agents" | "commands" | "outputStyles" | "memory"
): CcArtifactType {
return tab === "outputStyles" ? "output-styles" : tab;
}
type TabKey =
| "overview"
| "skills"
| "agents"
| "commands"
| "outputStyles"
| "plugins"
| "marketplaces"
| "mcp"
| "hooks"
| "keybindings"
| "settings"
| "memory";
interface TabDef {
key: TabKey;
icon: typeof Sparkles;
i18nKey: string;
}
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents" },
{ key: "commands", icon: Slash, i18nKey: "tabs.commands" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
{ key: "marketplaces", icon: Store, i18nKey: "tabs.marketplaces" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "hooks", icon: Webhook, i18nKey: "tabs.hooks" },
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
];
interface PageState {
overview: CcOverview | null;
skills: CcMdItem[] | null;
agents: CcMdItem[] | null;
commands: CcMdItem[] | null;
outputStyles: CcMdItem[] | null;
plugins: CcPluginsResponse | null;
marketplaces: CcMarketplacesResponse | null;
mcp: CcMcpResponse | null;
hooks: CcHookSource[] | null;
keybindings: CcKeybindings | null;
settings: CcSettingsSource[] | null;
memory: CcMemoryItem[] | null;
statusline: CcStatusline | null;
hookScripts: CcHookScripts | null;
}
const EMPTY_STATE: PageState = {
overview: null,
skills: null,
agents: null,
commands: null,
outputStyles: null,
plugins: null,
marketplaces: null,
mcp: null,
hooks: null,
keybindings: null,
settings: null,
memory: null,
statusline: null,
hookScripts: null,
};
type EditorState =
| {
mode: "create";
type: CcArtifactType;
defaultScope: "user" | "project";
template: string;
project?: string; // set for type === "auto-memory"
}
| {
mode: "edit";
type: CcArtifactType;
scope: "user" | "project" | "auto-memory";
name: string;
filePath: string;
project?: string; // set for type === "auto-memory"
}
| null;
type ConfirmDeleteState = {
type: CcArtifactType;
scope: "user" | "project" | "auto-memory";
name?: string;
path: string;
project?: string; // set for type === "auto-memory"
} | null;
type Toast = { kind: "success" | "error"; message: string } | null;
export function CcConfig() {
const { t } = useTranslation("ccConfig");
const [tab, setTab] = useState<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);
// 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)}
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}
onKeybindingsSaved={fetchAll}
onToast={setToast}
/>
</div>
</div>
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && <EditorModal state={editor} 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)} />}
</div>
);
}
// ── Header ────────────────────────────────────────────────────────────
interface HeaderProps {
loading: boolean;
lastUpdated: Date | null;
scope: CcScope;
onScopeChange: (s: CcScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
}
function Header({
loading,
lastUpdated,
scope,
onScopeChange,
onRefresh,
onOpenBackups,
wsConnected,
}: HeaderProps) {
const { t } = useTranslation("ccConfig");
const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated
? lastUpdated.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
: "-";
return (
<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={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" : ""}`} />
{loading ? t("refreshing") : t("refresh")}
</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-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;
onKeybindingsSaved: () => void;
onToast: (toast: NonNullable<Toast>) => void;
}
function TabPanel({
tab,
data,
search,
onOpenFile,
onEdit,
onDelete,
onCreateMemory,
onEditAuto,
onDeleteAuto,
onCreateAuto,
onKeybindingsSaved,
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} />;
case "marketplaces":
return <MarketplacesPanel data={data.marketplaces} search={search} />;
case "mcp":
return <McpPanel data={data.mcp} search={search} />;
case "hooks":
return (
<HooksPanel
sources={data.hooks}
scripts={data.hookScripts}
search={search}
onOpen={onOpenFile}
/>
);
case "keybindings":
return (
<KeybindingsPanel
data={data.keybindings}
search={search}
onSaved={onKeybindingsSaved}
onToast={onToast}
/>
);
case "settings":
return (
<SettingsPanel sources={data.settings} statusline={data.statusline} onOpen={onOpenFile} />
);
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>
);
}
// ── MD-item generic list (skills/agents/commands/output-styles) ───────
interface MdItemListProps {
items: CcMdItem[] | null;
search: string;
onOpen: (path: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
const filtered = useMemo(() => {
if (!items) return null;
const q = search.toLowerCase();
return items.filter((it) => {
if (!q) return true;
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
.filter(Boolean)
.join(" ")
.toLowerCase();
return blob.includes(q);
});
}, [items, search]);
if (!filtered) return <SkeletonRows n={6} />;
if (filtered.length === 0) return <Empty />;
return (
<div className="space-y-2">
{filtered.map((it) => (
<MdItemCard
key={`${it.scope}:${it.name}`}
item={it}
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
kind={kind}
/>
))}
</div>
);
}
interface MdItemCardProps {
item: CcMdItem;
onOpen: (p: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
const { t } = useTranslation("ccConfig");
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
const filePath = item.file || `${item.path}/SKILL.md`;
const description =
item.frontmatter.description ||
item.preview
.replace(/^#+\s.*\n/, "")
.trim()
.slice(0, 200);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
<ScopeBadge scope={item.scope} />
{item.frontmatter.model && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{item.frontmatter.model}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{description}
</p>
)}
{kind === "agents" && item.frontmatter.tools && (
<div className="mt-2 text-[11px] text-fg-muted">
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
</div>
)}
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button
onClick={() => onOpen(filePath)}
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(artifactType, { scope: item.scope, name: item.name, filePath })}
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(artifactType, item.scope, item.name, filePath)}
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>
</div>
</div>
);
}
// ── Plugins ───────────────────────────────────────────────────────────
function PluginsPanel({ data, search }: { data: CcPluginsResponse | null; search: string }) {
const { t } = useTranslation("ccConfig");
if (!data) return <SkeletonRows n={4} />;
const filtered = data.plugins.filter(
(p) => !search || p.key.toLowerCase().includes(search.toLowerCase())
);
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-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} />)
)}
</div>
);
}
function PluginCard({ plugin: p }: { plugin: CcPlugin }) {
const { t } = useTranslation("ccConfig");
const meta = p.contributes?.pluginJson;
const description = meta?.description;
const contribCounts: { key: string; count: number; label: string }[] = [];
if (p.contributes) {
if (p.contributes.skills > 0)
contribCounts.push({
key: "skills",
count: p.contributes.skills,
label: t("plugins.skills", { count: p.contributes.skills }),
});
if (p.contributes.agents > 0)
contribCounts.push({
key: "agents",
count: p.contributes.agents,
label: t("plugins.agents", { count: p.contributes.agents }),
});
if (p.contributes.commands > 0)
contribCounts.push({
key: "commands",
count: p.contributes.commands,
label: t("plugins.commands", { count: p.contributes.commands }),
});
if (p.contributes.outputStyles > 0)
contribCounts.push({
key: "outputStyles",
count: p.contributes.outputStyles,
label: t("plugins.outputStyles", { count: p.contributes.outputStyles }),
});
if (p.contributes.hooks > 0)
contribCounts.push({
key: "hooks",
count: p.contributes.hooks,
label: t("plugins.hooks", { count: p.contributes.hooks }),
});
}
return (
<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) => (
<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>
</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">
<CommandSnippet
command={`claude plugin uninstall ${p.key}`}
label={t("explain.plugins.uninstall")}
/>
</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 ─────────────────────────────────────────────────────────────
function HooksPanel({
sources,
scripts,
search,
onOpen,
}: {
sources: CcHookSource[] | null;
scripts: CcHookScripts | null;
search: string;
onOpen: (p: string) => void;
}) {
const { t } = useTranslation("ccConfig");
if (!sources) return <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) => {
const events = Object.entries(src.hooks);
const filteredEvents = search
? events.filter(([event]) => event.toLowerCase().includes(search.toLowerCase()))
: events;
return (
<div key={src.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">
<ScopeBadge scope={src.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1">
{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>
)}
</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>
);
})}
{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>
);
}
// ── 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,
}: {
sources: CcSettingsSource[] | null;
statusline: CcStatusline | null;
onOpen: (p: string) => 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} />
))}
</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>
);
}
function SettingsBlock({
source,
onOpen,
}: {
source: CcSettingsSource;
onOpen: (p: string) => void;
}) {
const { t } = useTranslation("ccConfig");
const [showRaw, setShowRaw] = useState(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>
{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>
) : (
<SettingsKeyValueList data={source.data as Record<string, unknown> | null | undefined} />
))}
</div>
);
}
function SettingsKeyValueList({ data }: { data: Record<string, unknown> | null | undefined }) {
if (!data || typeof data !== "object") {
return <div className="p-3 text-xs text-fg-muted">-</div>;
}
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(([k, v]) => (
<div
key={k}
className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-1 md:gap-3 items-start"
>
<div className="font-mono text-[11px] text-fg-secondary truncate">{k}</div>
<div className="min-w-0">
<SettingsValue value={v} />
</div>
</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,
}: {
data: CcMarketplacesResponse | null;
search: string;
}) {
const { t } = useTranslation("ccConfig");
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.plugins.title")}
body={t("explain.plugins.body")}
howTo={t("marketplaces.manifest")}
commands={[{ cmd: t("marketplaces.addCmd"), note: "" }]}
/>
<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>
))
)}
</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>
);
}
// ── Shared atoms ──────────────────────────────────────────────────────
function ScopeBadge({ scope }: { scope: string }) {
const { t } = useTranslation("ccConfig");
const color =
scope === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: scope === "project"
? "bg-status-success/10 text-status-success border-status-success/30"
: scope === "project-local"
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
: "bg-surface-3 text-fg-secondary border-border";
const label =
scope === "project-local"
? t("scope.projectLocal")
: scope === "user"
? t("scope.user")
: scope === "project"
? t("scope.project")
: scope;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
);
}
function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
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("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
);
}
function Empty() {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("common.empty")}
</div>
);
}
function SkeletonRows({ n }: { n: number }) {
return (
<div className="space-y-2">
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
))}
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// ── File viewer modal ─────────────────────────────────────────────────
function FileViewer({
state,
onClose,
}: {
state: { path: string; data: CcFileResponse | null; error: string | null };
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<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-4xl 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">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
<CopyButton value={state.path} />
<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="overflow-auto p-4">
{state.error ? (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{state.error}
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted"></div>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
{state.data.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
)}
</div>
</div>
</div>
);
}
// ── Editor modal (create + edit) ──────────────────────────────────────
interface EditorModalProps {
state: NonNullable<EditorState>;
onClose: () => void;
onSave: (args: {
type: CcArtifactType;
targetScope: "user" | "project" | "auto-memory";
name: string | undefined;
content: string;
project?: string;
}) => Promise<void>;
}
function EditorModal({ state, onClose, onSave }: EditorModalProps) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
const isAutoMemory = state.type === "auto-memory";
const [content, setContent] = useState<string>(isCreate ? state.template : "");
const [name, setName] = useState<string>("");
const [targetScope, setTargetScope] = useState<"user" | "project">(
isCreate ? state.defaultScope : state.scope === "auto-memory" ? "user" : state.scope
);
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
api.ccConfig
.file(state.filePath)
.then((r) => {
setContent(r.text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state]);
// Esc to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (state.type !== "memory" && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
const effectiveName =
state.mode === "edit" ? state.name : state.type === "memory" ? undefined : createName;
await onSave({
type: state.type,
targetScope: isAutoMemory ? "auto-memory" : targetScope,
name: effectiveName,
content,
project: state.project,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
const titleText = isCreate
? isAutoMemory
? t("memory.newFileTitle", { project: state.project ?? "" })
: t("edit.newTitle", { type: state.type })
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
return (
<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-4xl max-h-[90vh] 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">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</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="overflow-auto p-4 space-y-3">
{isCreate && state.type !== "memory" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.nameLabel")}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
}
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<p className="mt-1 text-[10px] text-fg-muted">
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
</p>
</div>
{isAutoMemory ? (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("memory.projectLabel")}
</label>
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
{state.project}
</div>
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
</div>
) : (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.scopePicker")}
</label>
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
{(["user", "project"] as const).map((s) => (
<button
key={s}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`scope.${s}`)}
</button>
))}
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.contentLabel")}
</label>
{loading ? (
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
) : (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
)}
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("edit.cancel")}
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
</div>
</div>
);
}
// ── Confirm-delete modal ──────────────────────────────────────────────
interface ConfirmDeleteModalProps {
state: NonNullable<ConfirmDeleteState>;
onCancel: () => void;
onConfirm: () => Promise<void>;
}
function ConfirmDeleteModal({ state, onCancel, onConfirm }: ConfirmDeleteModalProps) {
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const handleConfirm = useCallback(async () => {
setBusy(true);
try {
await onConfirm();
} finally {
setBusy(false);
}
}, [onConfirm]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
>
<div
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onCancel}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
>
{t("edit.cancel")}
</button>
<button
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="w-3.5 h-3.5" />
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
</button>
</div>
</div>
</div>
);
}
// ── Toast (5s auto-dismiss) ───────────────────────────────────────────
function ToastNotice({ toast, onDismiss }: { toast: NonNullable<Toast>; onDismiss: () => void }) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</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>
);
}
// ── 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, `'\\''`)}'`;
}