Files
AgentHub/client/src/pages/CcConfig.tsx
T
nntrivi2001 8ea40260ad feat(ui): terminal, charts and low-contrast text follow the selected theme
- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
2026-09-24 09:50:44 +07:00

4733 lines
172 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.
*
* ## Internal dependencies
* - `../lib/eventBus`
* - `../lib/api`
*
* ## Public surface
* - `CcConfig` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **CcConfig**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
RefreshCw,
Search,
Sparkles,
UserRound,
FolderTree,
Wrench,
Slash,
Palette,
PlugZap,
Server,
Webhook,
Settings as SettingsIcon,
BookOpen,
FileText,
Copy,
Check,
AlertCircle,
ExternalLink,
X,
Info,
Pencil,
Trash2,
Plus,
Save,
ShieldAlert,
Lock,
History,
Terminal,
Store,
Keyboard,
CircleDot,
CircleSlash,
ChevronDown,
UserCog,
ArrowUpCircle,
RotateCw,
CheckCircle2,
XCircle,
MinusCircle,
} from "lucide-react";
import { api } from "../lib/api";
import { FileViewer } from "../components/config/FileViewer";
import { EditorModal, type EditorState } from "../components/config/EditorModal";
import {
ConfirmDeleteModal,
type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { MdItemList } from "../components/config/MdItemList";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
SkeletonRows,
CopyButton,
formatBytes,
} from "../components/config/configPrimitives";
import type {
CcArtifactType,
CcAvailablePlugin,
CcBackup,
CcFileResponse,
CcHookEntry,
CcHookScripts,
CcHookSource,
CcKeybindings,
CcKeybindingGroup,
CcMarketplacesResponse,
CcMcpAddArgs,
CcMcpResponse,
CcMcpScope,
CcMcpServer,
CcMdItem,
CcMemoryItem,
CcMutationResult,
CcOverview,
CcPlugin,
CcPluginItem,
CcPluginsResponse,
CcUpdateAllBatch,
CcProfile,
CcAccountIdentity,
CcProfileUsage,
CcUsageWindow,
CcScope,
CcSettingsSource,
CcStatusline,
} from "../lib/api";
function isMutable(
tab: TabKey
): tab is "skills" | "agents" | "commands" | "outputStyles" | "memory" {
return (
tab === "skills" ||
tab === "agents" ||
tab === "commands" ||
tab === "outputStyles" ||
tab === "memory"
);
}
function tabToArtifactType(
tab: "skills" | "agents" | "commands" | "outputStyles" | "memory"
): CcArtifactType {
return tab === "outputStyles" ? "output-styles" : tab;
}
type TabKey =
| "overview"
| "skills"
| "agents"
| "commands"
| "outputStyles"
| "plugins"
| "marketplaces"
| "mcp"
| "hooks"
| "keybindings"
| "settings"
| "memory";
interface TabDef {
key: TabKey;
icon: typeof Sparkles;
i18nKey: string;
}
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents" },
{ key: "commands", icon: Slash, i18nKey: "tabs.commands" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
{ key: "marketplaces", icon: Store, i18nKey: "tabs.marketplaces" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "hooks", icon: Webhook, i18nKey: "tabs.hooks" },
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
];
interface PageState {
overview: CcOverview | null;
skills: CcMdItem[] | null;
agents: CcMdItem[] | null;
commands: CcMdItem[] | null;
outputStyles: CcMdItem[] | null;
plugins: CcPluginsResponse | null;
marketplaces: CcMarketplacesResponse | null;
mcp: CcMcpResponse | null;
hooks: CcHookSource[] | null;
keybindings: CcKeybindings | null;
settings: CcSettingsSource[] | null;
memory: CcMemoryItem[] | null;
statusline: CcStatusline | null;
hookScripts: CcHookScripts | null;
}
const EMPTY_STATE: PageState = {
overview: null,
skills: null,
agents: null,
commands: null,
outputStyles: null,
plugins: null,
marketplaces: null,
mcp: null,
hooks: null,
keybindings: null,
settings: null,
memory: null,
statusline: null,
hookScripts: null,
};
export function CcConfig() {
const { t } = useTranslation("ccConfig");
const [tab, setTab] = useState<TabKey>("overview");
const [scope, setScope] = useState<CcScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
const [viewer, setViewer] = useState<{
path: string;
data: CcFileResponse | null;
error: string | null;
} | null>(null);
const [editor, setEditor] = useState<EditorState>(null);
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
const [toast, setToast] = useState<Toast>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
// Auto-dismiss toasts after 5s
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(id);
}, [toast]);
const fetchAll = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [
overview,
skills,
agents,
commands,
outputStyles,
plugins,
marketplaces,
mcp,
hooks,
keybindings,
settings,
memory,
statusline,
hookScripts,
] = await Promise.all([
api.ccConfig.overview(),
api.ccConfig.skills(scope),
api.ccConfig.agents(scope),
api.ccConfig.commands(scope),
api.ccConfig.outputStyles(scope),
api.ccConfig.plugins(),
api.ccConfig.marketplaces(),
api.ccConfig.mcp(),
api.ccConfig.hooks(),
api.ccConfig.keybindings(),
api.ccConfig.settings(),
api.ccConfig.memory(),
api.ccConfig.statusline(),
api.ccConfig.hookScripts(),
]);
setData({
overview,
skills: skills.items,
agents: agents.items,
commands: commands.items,
outputStyles: outputStyles.items,
plugins,
marketplaces,
mcp,
hooks: hooks.items,
keybindings,
settings: settings.items,
memory: memory.items,
statusline,
hookScripts,
});
setLastUpdated(new Date());
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown error";
setError(msg);
} finally {
setLoading(false);
}
}, [scope]);
useEffect(() => {
void fetchAll();
}, [fetchAll]);
// Live updates - refetch whenever the server broadcasts that a config
// surface has changed (either via dashboard mutations or external file
// edits picked up by the cc-watcher). Debounced because a single user
// action can write multiple files (e.g. a skill backup + the skill itself
// + the file-history snapshot all land within tens of ms).
const refetchTimerRef = useRef<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]
);
// Create CLAUDE.md in ANOTHER project's directory. Deliberately not part of
// `handleSave`: that one writes this machine's user/project layers through
// the artifact writer, while this names its own target directory.
const handleCreateProjectMemory = useCallback(
async (args: { dir: string; content: string }) => {
const result = await api.ccConfig.createProjectMemory(args);
setProjectMemoryOpen(false);
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
void fetchAll();
return result.file;
},
[fetchAll, t]
);
const handleDelete = useCallback(async () => {
if (!confirmDelete) return;
try {
const result = await api.ccConfig.delete({
scope: confirmDelete.scope,
type: confirmDelete.type,
name: confirmDelete.name,
project: confirmDelete.project,
});
setConfirmDelete(null);
setToast({
kind: "success",
message: t("edit.deleteSuccess", { path: result.backupPath || "-" }),
});
void fetchAll();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown error";
setConfirmDelete(null);
setToast({ kind: "error", message: t("edit.deleteError", { message: msg }) });
}
}, [confirmDelete, fetchAll, t]);
return (
<div className="space-y-5">
<Header
loading={loading}
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={fetchAll}
onOpenBackups={() => setBackupsOpen(true)}
onOpenProfiles={() => setProfilesOpen(true)}
wsConnected={wsConnected}
/>
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("loadError", { message: error })}</span>
</div>
)}
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isMutable(tab) && tab !== "memory" && (
<button
onClick={() => openCreate(tabToArtifactType(tab))}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
)}
{/* The memory tab's own "new" is a different thing: the two
CLAUDE.md cards below are this machine's user + current-project
files, while this creates one in ANOTHER project's directory. */}
{tab === "memory" && (
<button
onClick={() => setProjectMemoryOpen(true)}
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 whitespace-nowrap"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateMemory={(s) => openCreate("memory", s)}
onEditAuto={openEditAuto}
onDeleteAuto={openDeleteAuto}
onCreateAuto={openCreateAuto}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
</div>
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && (
<EditorModal
state={editor}
fetchContent={(p) => api.ccConfig.file(p).then((r) => r.text)}
onClose={() => setEditor(null)}
onSave={handleSave}
/>
)}
{confirmDelete && (
<ConfirmDeleteModal
state={confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
/>
)}
{projectMemoryOpen && (
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template={t("projectMemory.template")}
onClose={() => setProjectMemoryOpen(false)}
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
</div>
);
}
// ── Header ────────────────────────────────────────────────────────────
interface HeaderProps {
loading: boolean;
lastUpdated: Date | null;
scope: CcScope;
onScopeChange: (s: CcScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
onOpenProfiles: () => void;
wsConnected: boolean;
}
function Header({
loading,
lastUpdated,
scope,
onScopeChange,
onRefresh,
onOpenBackups,
onOpenProfiles,
wsConnected,
}: HeaderProps) {
const { t } = useTranslation("ccConfig");
const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated
? lastUpdated.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
: "-";
return (
<header className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Boxes className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
onClick={onOpenProfiles}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</button>
<button
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<History className="w-3.5 h-3.5" />
{t("backups.openButton")}
</button>
<button
onClick={onRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 disabled:opacity-60 transition-colors"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
{/* Both labels occupy the same grid cell, so the button is always as
wide as the longer one ("Refreshing…" / "Đang làm mới…") and does
not resize the toolbar when the state flips. Measuring the widest
string beats a hardcoded min-width, which would be wrong in the
next translation. */}
<span className="grid">
<span aria-hidden className="invisible col-start-1 row-start-1 whitespace-nowrap">
{t("refreshing")}
</span>
<span className="col-start-1 row-start-1 whitespace-nowrap">
{loading ? t("refreshing") : t("refresh")}
</span>
</span>
</button>
</div>
{lastUpdated && (
<span className="text-[11px] text-fg-muted self-end">
{t("lastUpdated", { time: formatted })}
</span>
)}
</div>
</header>
);
}
function ScopeToggle({ value, onChange }: { value: CcScope; onChange: (s: CcScope) => void }) {
const { t } = useTranslation("ccConfig");
const opts: { v: CcScope; label: string }[] = [
{ v: "all", label: t("scope.all") },
{ v: "user", label: t("scope.user") },
{ v: "project", label: t("scope.project") },
];
return (
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{opts.map((o) => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
// ── Tabs ──────────────────────────────────────────────────────────────
interface TabsProps {
current: TabKey;
onSelect: (k: TabKey) => void;
counts?: CcOverview["counts"];
}
function Tabs({ current, onSelect, counts }: TabsProps) {
const { t } = useTranslation("ccConfig");
const 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 (
<ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
}))}
current={current}
onSelect={onSelect}
testIdPrefix="cc-tab"
ariaLabel={t("title")}
/>
);
}
// ── Tab panel switch ──────────────────────────────────────────────────
interface TabPanelProps {
tab: TabKey;
data: PageState;
search: string;
onOpenFile: (path: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
onCreateMemory: (scope: "user" | "project") => void;
onEditAuto: (item: CcMemoryItem) => void;
onDeleteAuto: (item: CcMemoryItem) => void;
onCreateAuto: (project: string) => void;
/** Refetch every config surface after a mutation. */
onChanged: () => void;
onToast: (toast: NonNullable<Toast>) => void;
}
function TabPanel({
tab,
data,
search,
onOpenFile,
onEdit,
onDelete,
onCreateMemory,
onEditAuto,
onDeleteAuto,
onCreateAuto,
onChanged,
onToast,
}: TabPanelProps) {
switch (tab) {
case "overview":
return <OverviewPanel overview={data.overview} />;
case "skills":
return (
<MdItemList
items={data.skills}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="skills"
/>
);
case "agents":
return (
<MdItemList
items={data.agents}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="agents"
/>
);
case "commands":
return (
<MdItemList
items={data.commands}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="commands"
/>
);
case "outputStyles":
return (
<MdItemList
items={data.outputStyles}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
kind="outputStyles"
/>
);
case "plugins":
return (
<PluginsPanel data={data.plugins} search={search} onToast={onToast} onChanged={onChanged} />
);
case "marketplaces":
return (
<MarketplacesPanel
data={data.marketplaces}
search={search}
onToast={onToast}
onChanged={onChanged}
/>
);
case "mcp":
return <McpPanel data={data.mcp} search={search} onToast={onToast} onChanged={onChanged} />;
case "hooks":
return (
<HooksPanel
sources={data.hooks}
scripts={data.hookScripts}
search={search}
onOpen={onOpenFile}
onToast={onToast}
onChanged={onChanged}
/>
);
case "keybindings":
return (
<KeybindingsPanel
data={data.keybindings}
search={search}
onSaved={onChanged}
onToast={onToast}
/>
);
case "settings":
return (
<SettingsPanel
sources={data.settings}
statusline={data.statusline}
onOpen={onOpenFile}
onToast={onToast}
onChanged={onChanged}
/>
);
case "memory":
return (
<MemoryPanel
items={data.memory}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
onCreate={onCreateMemory}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onCreateAuto={onCreateAuto}
/>
);
default:
return null;
}
}
// ── Overview ──────────────────────────────────────────────────────────
// Tone palette - each tone is { iconBg, iconText, border, accentBar }.
// Used by both root rows and summary stat tiles for a consistent color story.
type Tone =
| "sky"
| "emerald"
| "violet"
| "amber"
| "fuchsia"
| "cyan"
| "pink"
| "indigo"
| "orange"
| "teal"
| "slate"
| "rose";
const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring: string }> = {
sky: {
iconBg: "bg-sky-500/10",
iconText: "text-sky-700 dark:text-sky-300",
bar: "bg-sky-500/40",
ring: "ring-sky-500/20",
},
emerald: {
iconBg: "bg-status-success/10",
iconText: "text-status-success",
bar: "bg-status-success/40",
ring: "ring-status-success/20",
},
violet: {
iconBg: "bg-violet-500/10",
iconText: "text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/40",
ring: "ring-violet-500/20",
},
amber: {
iconBg: "bg-status-warning/10",
iconText: "text-status-warning",
bar: "bg-status-warning/40",
ring: "ring-status-warning/20",
},
fuchsia: {
iconBg: "bg-fuchsia-500/10",
iconText: "text-fuchsia-700 dark:text-fuchsia-300",
bar: "bg-fuchsia-500/40",
ring: "ring-fuchsia-500/20",
},
cyan: {
iconBg: "bg-cyan-500/10",
iconText: "text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/40",
ring: "ring-cyan-500/20",
},
pink: {
iconBg: "bg-pink-500/10",
iconText: "text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/40",
ring: "ring-pink-500/20",
},
indigo: {
iconBg: "bg-indigo-500/10",
iconText: "text-indigo-700 dark:text-indigo-300",
bar: "bg-indigo-500/40",
ring: "ring-indigo-500/20",
},
orange: {
iconBg: "bg-orange-500/10",
iconText: "text-orange-700 dark:text-orange-300",
bar: "bg-orange-500/40",
ring: "ring-orange-500/20",
},
teal: {
iconBg: "bg-teal-500/10",
iconText: "text-teal-700 dark:text-teal-300",
bar: "bg-teal-500/40",
ring: "ring-teal-500/20",
},
slate: {
iconBg: "bg-surface-4/10",
iconText: "text-fg-secondary",
bar: "bg-surface-4/40",
ring: "ring-border-light/20",
},
rose: {
iconBg: "bg-rose-500/10",
iconText: "text-rose-700 dark:text-rose-300",
bar: "bg-rose-500/40",
ring: "ring-rose-500/20",
},
};
function OverviewPanel({ overview }: { overview: CcOverview | null }) {
const { t } = useTranslation("ccConfig");
if (!overview) return <SkeletonRows n={4} />;
const { roots, counts } = overview;
return (
<div className="space-y-5">
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="sky"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
<RootRow
icon={FileText}
tone="amber"
label={t("overview.claudeJson")}
value={roots.claudeJson}
/>
</div>
</section>
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("overview.summary")}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
<SummaryStat
tone="fuchsia"
icon={Sparkles}
label={t("tabs.skills")}
user={counts.skills.user}
project={counts.skills.project}
/>
<SummaryStat
tone="sky"
icon={UserRound}
label={t("tabs.agents")}
user={counts.agents.user}
project={counts.agents.project}
/>
<SummaryStat
tone="cyan"
icon={Slash}
label={t("tabs.commands")}
user={counts.commands.user}
project={counts.commands.project}
/>
<SummaryStat
tone="pink"
icon={Palette}
label={t("tabs.outputStyles")}
user={counts.outputStyles.user}
project={counts.outputStyles.project}
/>
<SummaryStat
tone="indigo"
icon={Server}
label={t("tabs.mcp")}
user={counts.mcpServers.user}
project={counts.mcpServers.project}
/>
<SummaryStat
tone="emerald"
icon={PlugZap}
label={t("tabs.plugins")}
value={counts.plugins}
/>
<SummaryStat
tone="amber"
icon={Store}
label={t("tabs.marketplaces")}
value={counts.marketplaces}
/>
<SummaryStat
tone="orange"
icon={Webhook}
label={t("tabs.hooks")}
value={Object.values(counts.hooks).reduce((a, b) => a + b, 0)}
/>
<SummaryStat
tone="rose"
icon={Keyboard}
label={t("tabs.keybindings")}
value={counts.keybindings}
/>
<SummaryStat
tone="slate"
icon={SettingsIcon}
label={t("tabs.settings")}
value={counts.settingsFiles}
/>
<SummaryStat tone="teal" icon={BookOpen} label={t("tabs.memory")} value={counts.memory} />
</div>
</section>
</div>
);
}
interface SummaryStatProps {
tone: Tone;
icon: typeof Sparkles;
label: string;
// Either a single value, OR a user/project pair (which is summed for the headline number).
value?: number;
user?: number;
project?: number;
}
function SummaryStat({ tone, icon: Icon, label, value, user, project }: SummaryStatProps) {
const { t } = useTranslation("ccConfig");
const T = TONES[tone];
const total = value !== undefined ? value : (user ?? 0) + (project ?? 0);
const showBreakdown = user !== undefined && project !== undefined;
return (
<div className={`relative rounded-lg border border-border bg-surface-2 overflow-hidden`}>
{/* Left accent bar */}
<div className={`absolute left-0 top-0 bottom-0 w-1 ${T.bar}`} aria-hidden />
<div className="pl-3.5 pr-3 py-2.5">
<div className="flex items-center gap-2">
<span
className={`w-6 h-6 rounded-md ${T.iconBg} flex items-center justify-center flex-shrink-0`}
>
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
</span>
<span className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary truncate">
{label}
</span>
</div>
<div className="mt-1.5 flex items-baseline gap-2">
<span className="text-xl font-semibold text-fg-primary tabular-nums">{total}</span>
{showBreakdown && (
<span className="text-[10px] text-fg-muted truncate">
{user} {t("overview.user")} · {project} {t("overview.project")}
</span>
)}
</div>
</div>
</div>
);
}
function RootRow({
icon: Icon,
tone,
label,
value,
}: {
icon: typeof FolderTree;
tone: Tone;
label: string;
value: string;
}) {
const T = TONES[tone];
return (
<div className="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden">
<div className={`absolute left-0 top-0 bottom-0 w-1 ${T.bar}`} aria-hidden />
<span
className={`w-7 h-7 rounded-md ${T.iconBg} flex items-center justify-center flex-shrink-0 ml-1.5`}
>
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
</span>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary">
{label}
</div>
<div className="font-mono text-[11px] text-fg-secondary truncate">{value}</div>
</div>
<CopyButton value={value} />
</div>
);
}
// ── Plugins ───────────────────────────────────────────────────────────
/**
* Shared runner for the `claude`-CLI-backed actions (plugins, marketplaces)
* and the settings key-merge. Tracks which row is mid-flight so its buttons
* can disable, and funnels both outcomes into the page's toast.
*/
function useConfigAction(onToast: (t: NonNullable<Toast>) => void, onChanged: () => void) {
const [busy, setBusy] = useState<string | null>(null);
const run = useCallback(
async (id: string, fn: () => Promise<unknown>, successMessage: string) => {
setBusy(id);
try {
await fn();
onToast({ kind: "success", message: successMessage });
onChanged();
return true;
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : "unknown error",
});
return false;
} finally {
setBusy(null);
}
},
[onChanged, onToast]
);
return { busy, run };
}
/**
* Does this look like a marketplace SOURCE (`owner/repo`, an http(s) URL, or an
* absolute path) rather than a plugin spec (`name` / `name@marketplace`)? Lets
* one input field accept either — pasting a GitHub link registers the
* marketplace, typing a spec installs the plugin. The server re-validates; this
* only decides which endpoint to call.
*/
function looksLikeMarketplaceSource(value: string): boolean {
const v = value.trim();
if (!v) return false;
if (/^https?:\/\//i.test(v)) return true;
if (v.startsWith("/")) return true;
return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v);
}
/** Small action button used across the plugin/marketplace cards. */
function ActionButton({
onClick,
disabled,
danger,
icon: Icon,
children,
}: {
onClick: () => void;
disabled?: boolean;
danger?: boolean;
icon: typeof Sparkles;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`h-7 text-[11px] font-medium px-2.5 rounded-md border inline-flex items-center gap-1.5 disabled:opacity-50 ${
danger
? "border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger"
: "border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary"
}`}
>
<Icon className="w-3 h-3" />
{children}
</button>
);
}
/** Icon + colour per batch-step state. */
type StepLook = { Icon: typeof CheckCircle2; className: string };
const STEP_FALLBACK: StepLook = { Icon: MinusCircle, className: "text-fg-muted" };
const STEP_LOOK: Record<string, StepLook> = {
running: { Icon: RefreshCw, className: "text-accent animate-spin" },
ok: { Icon: CheckCircle2, className: "text-status-success" },
failed: { Icon: XCircle, className: "text-status-error" },
skipped: { Icon: MinusCircle, className: "text-fg-muted" },
};
/**
* "Update all" + "Reload sessions" for the whole Claude Code install.
*
* The batch runs in a detached server-side worker and is followed by polling
* rather than by the POST's response, because the last step restarts the
* dashboard when AgentHub is itself a plugin install. Polling therefore has to
* tolerate the server disappearing mid-batch: a failed poll is ignored and
* retried, never rendered as an error, because the most likely cause is the
* successful restart this button asked for.
*/
function UpdateAllCard({
onToast,
onChanged,
}: {
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const [batch, setBatch] = useState<CcUpdateAllBatch | null>(null);
const [starting, setStarting] = useState(false);
const [reloading, setReloading] = useState(false);
const running = batch?.status === "running";
const poll = useCallback(async () => {
try {
const res = await api.ccConfig.updateAllStatus();
setBatch(res.batch);
return res.batch;
} catch {
// Server is down (very likely the AgentHub restart we triggered) — keep
// the last known steps on screen and try again on the next tick.
return undefined;
}
}, []);
// Pick up a batch already in flight, including one started by the server we
// were talking to BEFORE it restarted itself.
useEffect(() => {
void poll();
}, [poll]);
useEffect(() => {
if (!running) return;
const id = setInterval(() => void poll(), 2000);
return () => clearInterval(id);
}, [running, poll]);
// Refresh the surrounding inventory once the batch stops moving, so version
// numbers and install paths catch up without a manual reload.
const prevStatus = useRef<string | undefined>(undefined);
useEffect(() => {
if (prevStatus.current === "running" && batch?.status !== "running") onChanged();
prevStatus.current = batch?.status;
}, [batch?.status, onChanged]);
const start = useCallback(async () => {
setStarting(true);
try {
const res = await api.ccConfig.updateAll();
onToast({ kind: "success", message: t("updateAll.started", { count: res.planned }) });
await poll();
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : t("updateAll.failed"),
});
} finally {
setStarting(false);
}
}, [onToast, poll, t]);
const reloadOnly = useCallback(async () => {
setReloading(true);
try {
const res = await api.ccConfig.reload();
onToast({
kind: "success",
message:
res.lanes_total > 0
? t("updateAll.reloaded", { count: res.lanes_total })
: t("updateAll.reloadedNone"),
});
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : t("updateAll.failed"),
});
} finally {
setReloading(false);
}
}, [onToast, t]);
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-[11px] font-semibold text-fg-secondary">{t("updateAll.title")}</div>
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
onClick={() => void reloadOnly()}
disabled={reloading || running}
className="h-8 text-[11px] font-medium px-3 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary inline-flex items-center gap-1.5 disabled:opacity-50"
>
<RotateCw className={`w-3 h-3 ${reloading ? "animate-spin" : ""}`} />
{t("updateAll.reloadButton")}
</button>
<button
type="button"
onClick={() => void start()}
disabled={starting || running}
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<ArrowUpCircle className="w-3 h-3" />
{running || starting ? t("updateAll.working") : t("updateAll.button")}
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">{t("updateAll.help")}</p>
{batch && (
<div className="mt-2 space-y-1">
{batch.steps.map((step) => {
const look = STEP_LOOK[step.status] ?? STEP_FALLBACK;
return (
<div key={step.key} className="flex items-start gap-1.5 text-[11px]">
<look.Icon className={`w-3 h-3 mt-0.5 shrink-0 ${look.className}`} />
<span className="font-mono text-fg-secondary truncate">{step.label}</span>
{step.note && <span className="text-fg-muted truncate">— {step.note}</span>}
{step.error && (
<span className="text-status-error truncate" title={step.error}>
— {step.error}
</span>
)}
{step.key === "reload" && step.lanes_total !== undefined && (
<span className="text-fg-muted">
{t("updateAll.reloadedCount", { count: step.lanes_total })}
</span>
)}
</div>
);
})}
{batch.status === "abandoned" && (
<p className="text-[10px] text-status-warning">{t("updateAll.abandoned")}</p>
)}
{batch.bootstrap_error && (
<p className="text-[10px] text-status-error">
{t("updateAll.bootstrapFailed", { error: batch.bootstrap_error })}
</p>
)}
{running && <p className="text-[10px] text-fg-muted">{t("updateAll.restartWarning")}</p>}
</div>
)}
</div>
);
}
function PluginsPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcPluginsResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [input, setInput] = useState("");
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
const [availableError, setAvailableError] = useState<string | null>(null);
// The available inventory comes from the CLI, which may not be installed —
// a failure here is a normal state, not a page error.
const loadAvailable = useCallback(async () => {
try {
const res = await api.ccConfig.pluginsAvailable();
setAvailable(res.available);
setAvailableError(null);
} catch (err: unknown) {
setAvailable([]);
setAvailableError(err instanceof Error ? err.message : "unknown error");
}
}, []);
useEffect(() => {
void loadAvailable();
}, [loadAvailable, data]);
const submit = useCallback(async () => {
const value = input.trim();
if (!value) return;
const isSource = looksLikeMarketplaceSource(value);
const ok = await run(
"add",
() => (isSource ? api.ccConfig.addMarketplace(value) : api.ccConfig.installPlugin(value)),
isSource
? t("manage.marketplaceAdded", { name: value })
: t("manage.installed", { name: value })
);
if (ok) {
setInput("");
void loadAvailable();
}
}, [input, loadAvailable, run, t]);
if (!data) return <SkeletonRows n={4} />;
const needle = search.toLowerCase();
const filtered = data.plugins.filter((p) => !search || p.key.toLowerCase().includes(needle));
const installedKeys = new Set(data.plugins.map((p) => p.key));
const installable = (available ?? []).filter(
(a) =>
!installedKeys.has(a.pluginId) &&
(!search ||
a.pluginId.toLowerCase().includes(needle) ||
(a.description || "").toLowerCase().includes(needle))
);
return (
<div className="space-y-3">
<ExplainerBanner
title={t("explain.plugins.title")}
body={t("explain.plugins.body")}
howTo={t("explain.plugins.install")}
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
/>
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
{t("manage.installTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
placeholder={t("manage.installPlaceholder")}
aria-label={t("manage.installTitle")}
className="h-8 flex-1 min-w-60 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => void submit()}
disabled={!input.trim() || busy === "add"}
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.installButton")}
</button>
</div>
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">
{t("manage.installHelp")}
</p>
</div>
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate">{data.manifestPath}</span>
{!data.manifestExists && (
<span className="ml-auto text-status-warning">
{t("plugins.manifestMissing", { path: "" })}
</span>
)}
</div>
{filtered.length === 0 ? (
<Empty />
) : (
filtered.map((p) => <PluginCard key={p.key} plugin={p} busy={busy} run={run} />)
)}
{installable.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("manage.availableTitle")}
</h3>
<div className="space-y-2">
{installable.map((a) => (
<div
key={a.pluginId}
className="rounded-lg border border-border bg-surface-2 px-4 py-2.5 flex items-start gap-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-fg-primary">{a.name}</span>
{a.marketplaceName && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{a.marketplaceName}
</span>
)}
</div>
{a.description && (
<p className="mt-1 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{a.description}
</p>
)}
</div>
<ActionButton
icon={Plus}
disabled={busy === a.pluginId}
onClick={() =>
void run(
a.pluginId,
() => api.ccConfig.installPlugin(a.pluginId),
t("manage.installed", { name: a.pluginId })
)
}
>
{busy === a.pluginId ? t("manage.working") : t("manage.install")}
</ActionButton>
</div>
))}
</div>
</div>
)}
{availableError && (
<div className="rounded-md border border-status-warning/30 bg-status-warning/5 px-3 py-2 text-[11px] text-status-warning/90 flex items-center gap-2">
<Info className="w-3.5 h-3.5 flex-shrink-0" />
{t("manage.cliUnavailable", { message: availableError })}
</div>
)}
</div>
);
}
function PluginCard({
plugin: p,
busy,
run,
}: {
plugin: CcPlugin;
busy: string | null;
run: (id: string, fn: () => Promise<unknown>, message: string) => Promise<boolean>;
}) {
const { t } = useTranslation("ccConfig");
// Uninstall takes two clicks rather than a modal: the action is reversible
// (reinstall), so an accidental single click is the only thing to guard.
const [confirmUninstall, setConfirmUninstall] = useState(false);
// Which contribution surface is expanded, if any. One at a time: the card
// sits in a list, and two open lists push everything below off the screen.
const [openSurface, setOpenSurface] = useState<string | null>(null);
const meta = p.contributes?.pluginJson;
const description = meta?.description;
const contribCounts: { key: string; count: number; label: string; items?: CcPluginItem[] }[] = [];
if (p.contributes) {
const items = p.contributes.items;
if (p.contributes.skills > 0)
contribCounts.push({
key: "skills",
count: p.contributes.skills,
label: t("plugins.skills", { count: p.contributes.skills }),
items: items?.skills,
});
if (p.contributes.agents > 0)
contribCounts.push({
key: "agents",
count: p.contributes.agents,
label: t("plugins.agents", { count: p.contributes.agents }),
items: items?.agents,
});
if (p.contributes.commands > 0)
contribCounts.push({
key: "commands",
count: p.contributes.commands,
label: t("plugins.commands", { count: p.contributes.commands }),
items: items?.commands,
});
if (p.contributes.outputStyles > 0)
contribCounts.push({
key: "outputStyles",
count: p.contributes.outputStyles,
label: t("plugins.outputStyles", { count: p.contributes.outputStyles }),
items: items?.outputStyles,
});
// Hooks have no per-item list: a plugin's hooks are matcher/command pairs
// declared inline in plugin.json, not addressable artifacts with names.
if (p.contributes.hooks > 0)
contribCounts.push({
key: "hooks",
count: p.contributes.hooks,
label: t("plugins.hooks", { count: p.contributes.hooks }),
});
}
const opened = contribCounts.find((c) => c.key === openSurface && c.items?.length);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-fg-primary">{p.name}</span>
{p.marketplace && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{p.marketplace}
</span>
)}
{p.version && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
v{p.version}
</span>
)}
<ScopeBadge scope={p.scope} />
{p.enabled === true && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-status-success/10 text-status-success border border-status-success/30 inline-flex items-center gap-1">
<CircleDot className="w-3 h-3" />
{t("plugins.enabled")}
</span>
)}
{p.enabled === false && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-4/10 text-fg-secondary border border-border-light/30 inline-flex items-center gap-1">
<CircleSlash className="w-3 h-3" />
{t("plugins.disabled")}
</span>
)}
{!p.installPathExists && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-status-warning/10 text-status-warning border border-status-warning/30 inline-flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t("plugins.missing")}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed">{description}</p>
)}
{contribCounts.length > 0 && (
<div className="mt-2.5">
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("plugins.contributes")}
</div>
<div className="flex flex-wrap gap-1.5">
{contribCounts.map((c) =>
c.items?.length ? (
<button
key={c.key}
type="button"
aria-expanded={openSurface === c.key}
data-testid={`plugin-contrib-${p.key}-${c.key}`}
onClick={() => setOpenSurface((cur) => (cur === c.key ? null : c.key))}
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border inline-flex items-center gap-1 transition-colors ${
openSurface === c.key
? "bg-accent/10 text-accent border-accent/30"
: "bg-surface-3 text-fg-secondary border-border hover:border-border-light hover:text-fg-primary"
}`}
>
{c.label}
<ChevronDown
className={`w-2.5 h-2.5 transition-transform ${
openSurface === c.key ? "rotate-180" : ""
}`}
/>
</button>
) : (
<span
key={c.key}
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border"
>
{c.label}
</span>
)
)}
</div>
{opened && (
<ul
data-testid={`plugin-contrib-list-${p.key}`}
className="mt-2 rounded border border-border bg-surface-1 divide-y divide-border"
>
{opened.items?.map((item) => (
<li key={item.file} className="px-2.5 py-1.5">
<span className="font-mono text-[11px] text-fg-primary">
{opened.key === "commands" ? `/${item.name}` : item.name}
</span>
{item.description && (
<p className="mt-0.5 text-[11px] text-fg-muted leading-snug line-clamp-2">
{item.description}
</p>
)}
</li>
))}
</ul>
)}
</div>
)}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
{meta?.author?.name && (
<div>
<span className="text-fg-muted">{t("plugins.author")}:</span> {meta.author.name}
</div>
)}
{meta?.license && (
<div>
<span className="text-fg-muted">{t("plugins.license")}:</span> {meta.license}
</div>
)}
{p.installedAt && (
<div>
<span className="text-fg-muted">{t("plugins.installedAt")}:</span>{" "}
{new Date(p.installedAt).toLocaleString()}
</div>
)}
{p.lastUpdated && (
<div>
<span className="text-fg-muted">{t("plugins.lastUpdated")}:</span>{" "}
{new Date(p.lastUpdated).toLocaleString()}
</div>
)}
{p.gitCommitSha && (
<div className="col-span-2">
<span className="text-fg-muted">SHA:</span>{" "}
<span className="font-mono">{p.gitCommitSha.slice(0, 12)}</span>
</div>
)}
{meta?.homepage && (
<div className="col-span-2 truncate">
<span className="text-fg-muted">{t("plugins.homepage")}:</span>{" "}
<a
href={meta.homepage}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{meta.homepage}
</a>
</div>
)}
</div>
{p.installPath && (
<div className="mt-2 flex items-center gap-2">
<span className="font-mono text-[10px] text-fg-muted truncate flex-1">
{p.installPath}
</span>
<CopyButton value={p.installPath} />
</div>
)}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
{p.enabled === false ? (
<ActionButton
icon={CircleDot}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "enable"),
t("manage.enabled", { name: p.key })
)
}
>
{t("manage.enable")}
</ActionButton>
) : (
<ActionButton
icon={CircleSlash}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "disable"),
t("manage.disabled", { name: p.key })
)
}
>
{t("manage.disable")}
</ActionButton>
)}
<ActionButton
icon={RefreshCw}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
// reload=true: updating one plugin reloads the open sessions
// right away. The batch reloads once at the end instead.
() => api.ccConfig.pluginAction(p.key, "update", true),
t("manage.updated", { name: p.key })
)
}
>
{t("manage.update")}
</ActionButton>
<ActionButton
icon={Trash2}
danger
disabled={busy === p.key}
onClick={() => {
if (!confirmUninstall) {
setConfirmUninstall(true);
return;
}
setConfirmUninstall(false);
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "uninstall"),
t("manage.uninstalled", { name: p.key })
);
}}
>
{busy === p.key
? t("manage.working")
: confirmUninstall
? t("manage.confirmUninstall")
: t("manage.uninstall")}
</ActionButton>
</div>
</div>
</div>
</div>
);
}
// ── MCP servers ───────────────────────────────────────────────────────
/** Splits a pasted command line on whitespace — good enough for the common
* `npx -y @scope/server --flag value` case. Skipped a full shell-arg
* parser (quoted args with embedded spaces won't survive); add one if a
* real server needs it. */
function splitCommandLine(value: string): [string, string[]] {
const parts = value.trim().split(/\s+/).filter(Boolean);
return [parts[0] || "", parts.slice(1)];
}
function McpPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcMcpResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [name, setName] = useState("");
const [scope, setScope] = useState<CcMcpScope>("user");
const [transport, setTransport] = useState<"stdio" | "http">("stdio");
const [commandLine, setCommandLine] = useState("");
const [url, setUrl] = useState("");
const canSubmit =
name.trim().length > 0 && (transport === "stdio" ? commandLine.trim() : url.trim());
const submit = useCallback(async () => {
const n = name.trim();
if (!n) return;
const args: CcMcpAddArgs =
transport === "http"
? { name: n, scope, transport: "http", url: url.trim() }
: (() => {
const [command, cmdArgs] = splitCommandLine(commandLine);
return { name: n, scope, command, args: cmdArgs };
})();
const ok = await run("add", () => api.ccConfig.mcpAdd(args), t("manage.mcpAdded", { name: n }));
if (ok) {
setName("");
setCommandLine("");
setUrl("");
}
}, [commandLine, name, run, scope, t, transport, url]);
if (!data) return <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") }]}
/>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5 space-y-2">
<div className="text-[11px] font-semibold text-fg-secondary">{t("manage.addMcpTitle")}</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("manage.addMcpNamePlaceholder")}
aria-label={t("mcp.name")}
className="h-8 w-40 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<select
value={scope}
onChange={(e) => setScope(e.target.value as CcMcpScope)}
aria-label={t("mcp.scope")}
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<option value="user">{t("mcp.scopeUser")}</option>
<option value="local">{t("mcp.scopeLocal")}</option>
</select>
<div className="flex rounded-md border border-border overflow-hidden">
{(["stdio", "http"] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTransport(k)}
className={`h-8 px-2.5 text-[11px] font-medium ${
transport === k
? "bg-accent/20 text-accent"
: "bg-surface-1 text-fg-secondary hover:bg-surface-3"
}`}
>
{k === "stdio" ? t("manage.addMcpTransportStdio") : t("manage.addMcpTransportHttp")}
</button>
))}
</div>
</div>
{transport === "stdio" ? (
<input
value={commandLine}
onChange={(e) => setCommandLine(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
placeholder={t("manage.addMcpCommandPlaceholder")}
aria-label={t("mcp.command")}
className="h-8 w-full bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
) : (
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
placeholder={t("manage.addMcpUrlPlaceholder")}
aria-label={t("mcp.url")}
className="h-8 w-full bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
)}
<div className="flex items-center justify-between">
<p className="text-[10px] text-fg-muted leading-relaxed">{t("manage.addMcpHelp")}</p>
<button
type="button"
onClick={() => void submit()}
disabled={!canSubmit || busy === "add"}
className="h-8 shrink-0 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.addMcpButton")}
</button>
</div>
</div>
{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}
scope="user"
run={run}
busy={busy}
t={t}
/>
))}
</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}
scope="local"
run={run}
busy={busy}
t={t}
/>
))}
</div>
</div>
)}
</div>
);
}
function McpCard({
server,
scope,
run,
busy,
t,
}: {
server: CcMcpServer;
scope: CcMcpScope;
run: (id: string, fn: () => Promise<unknown>, successMessage: string) => Promise<boolean>;
busy: string | null;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const [confirmRemove, setConfirmRemove] = useState(false);
const busyId = `${scope}:${server.name}`;
// The reader decides this: entries from ~/.claude/settings.json have no CLI
// scope that addresses them, so a Remove button would look live and never
// work. Reading it off a flag rather than matching `source` keeps a reworded
// display string from silently bringing that button back.
const removable = server.removable !== false;
return (
<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">
{removable ? (
<ActionButton
icon={Trash2}
danger
disabled={busy === busyId}
onClick={() => {
if (!confirmRemove) {
setConfirmRemove(true);
return;
}
setConfirmRemove(false);
void run(
busyId,
() => api.ccConfig.mcpRemove(server.name, scope),
t("manage.mcpRemoved", { name: server.name })
);
}}
>
{busy === busyId
? t("manage.working")
: confirmRemove
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
) : (
<p className="text-[10px] text-fg-muted">{t("mcp.notRemovable")}</p>
)}
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<span className="text-fg-muted min-w-20">{label}:</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</div>
);
}
// ── Hooks ─────────────────────────────────────────────────────────────
/**
* `readHooks` FLATTENS settings.json's `[{matcher, hooks:[…]}]` into one row
* per command; this rebuilds the nested shape the file actually stores,
* regrouping consecutive rows that share a matcher. Rows the editor never
* created (an unknown event's raw objects) are filtered out upstream — those
* events are simply never sent, so they stay untouched on disk.
*/
function toNestedHooks(entries: CcHookEntry[]): unknown[] {
const groups: { matcher: string; hooks: Record<string, unknown>[] }[] = [];
for (const e of entries) {
const matcher = e.matcher || "*";
let group = groups.find((g) => g.matcher === matcher);
if (!group) {
group = { matcher, hooks: [] };
groups.push(group);
}
const hook: Record<string, unknown> = {
type: e.type || "command",
command: e.command || "",
};
if (e.timeout != null) hook.timeout = e.timeout;
group.hooks.push(hook);
}
return groups;
}
/** A hook row the editor can round-trip — i.e. one `readHooks` flattened. */
function isEditableHookEntry(entry: unknown): entry is CcHookEntry {
return !!entry && typeof entry === "object" && "command" in (entry as object);
}
function HooksPanel({
sources,
scripts,
search,
onOpen,
onToast,
onChanged,
}: {
sources: CcHookSource[] | null;
scripts: CcHookScripts | null;
search: string;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
if (!sources) return <SkeletonRows n={3} />;
return (
<div className="space-y-4">
<ExplainerBanner
title={t("explain.hooks.title")}
body={t("explain.hooks.body")}
howTo={t("explain.hooks.howTo")}
commands={[
{ cmd: t("explain.hooks.cmd1"), note: t("explain.hooks.cmd1Note") },
{ cmd: t("explain.hooks.cmd2"), note: t("explain.hooks.cmd2Note") },
{ cmd: t("explain.hooks.cmd3"), note: t("explain.hooks.cmd3Note") },
]}
/>
{sources.map((src) => (
<HookSourceBlock
key={src.scope}
source={src}
search={search}
onOpen={onOpen}
onToast={onToast}
onChanged={onChanged}
/>
))}
{scripts && scripts.items.length > 0 && (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-fg-primary">{t("hookScripts.title")}</div>
<p className="mt-1 text-[11px] text-fg-muted leading-relaxed">
{t("hookScripts.subtitle")}
</p>
<div className="mt-1 font-mono text-[10px] text-fg-muted">{scripts.dir}</div>
</div>
<div className="p-3 space-y-1.5">
{scripts.items.map((s) => (
<button
key={s.file}
onClick={() => onOpen(s.file)}
className="w-full text-left rounded-md border border-border bg-surface-1 hover:bg-surface-3 px-3 py-1.5 inline-flex items-center gap-2"
>
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary flex-1 truncate">
{s.name}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(s.size)}</span>
<span className="text-[10px] text-fg-muted hidden md:inline">
{new Date(s.mtime).toLocaleDateString()}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
/**
* One settings layer's hooks, readable and editable. Saving writes ONLY the
* `hooks.<Event>` keys this editor owns, through `PATCH /settings`'s key
* merge — every other key in the file, and every event whose entries the
* editor could not parse, is left exactly as it was.
*/
function HookSourceBlock({
source: src,
search,
onOpen,
onToast,
onChanged,
}: {
source: CcHookSource;
search: string;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [draft, setDraft] = useState<{ event: string; entries: CcHookEntry[] }[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const editableEvents = useMemo(
() =>
Object.entries(src.hooks).filter(([, entries]) =>
(entries as unknown[]).every(isEditableHookEntry)
),
[src.hooks]
);
const readOnlyEvents = useMemo(
() =>
Object.entries(src.hooks).filter(
([, entries]) => !(entries as unknown[]).every(isEditableHookEntry)
),
[src.hooks]
);
const startEdit = () => {
setErr(null);
setDraft(
editableEvents.map(([event, entries]) => ({
event,
entries: entries.map((e) => ({ ...e })),
}))
);
};
const save = async () => {
if (!draft) return;
const set: Record<string, unknown> = {};
const seen = new Set<string>();
for (const { event, entries } of draft) {
const name = event.trim();
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
setErr(t("manage.hookBadEvent", { event: event || "-" }));
return;
}
if (seen.has(name)) {
setErr(t("manage.hookDupEvent", { event: name }));
return;
}
seen.add(name);
if (entries.some((e) => !e.command || !e.command.trim())) {
setErr(t("manage.hookEmptyCommand", { event: name }));
return;
}
set[`hooks.${name}`] = toNestedHooks(entries);
}
// An event dropped from the draft is removed from the file; one that was
// never editable here is not in either list, so it is never touched.
const unset = editableEvents
.map(([event]) => event)
.filter((event) => !seen.has(event))
.map((event) => `hooks.${event}`);
setErr(null);
const ok = await run(
"save",
() =>
api.ccConfig.patchSettings({
scope: src.scope,
set,
...(unset.length ? { unset } : {}),
}),
t("manage.hooksSaved", { file: src.file })
);
if (ok) setDraft(null);
};
const updateEntry = (ei: number, hi: number, field: keyof CcHookEntry, value: string) =>
setDraft((d) =>
d
? d.map((g, i) =>
i === ei
? {
...g,
entries: g.entries.map((h, j) =>
j === hi
? {
...h,
[field]: field === "timeout" ? (value ? Number(value) : null) : value,
}
: h
),
}
: g
)
: d
);
const filteredEvents = search
? Object.entries(src.hooks).filter(([event]) =>
event.toLowerCase().includes(search.toLowerCase())
)
: Object.entries(src.hooks);
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={src.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
{src.file}
</span>
{src.exists ? (
<button
onClick={() => onOpen(src.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
) : (
<span className="text-[11px] text-fg-muted">{t("hooks.fileMissing")}</span>
)}
{draft ? (
<div className="inline-flex items-center gap-1.5">
<ActionButton icon={X} onClick={() => setDraft(null)} disabled={busy === "save"}>
{t("edit.cancel")}
</ActionButton>
<ActionButton icon={Save} onClick={() => void save()} disabled={busy === "save"}>
{busy === "save" ? t("edit.saving") : t("edit.save")}
</ActionButton>
</div>
) : (
<ActionButton icon={Pencil} onClick={startEdit}>
{t("edit.editButton")}
</ActionButton>
)}
</div>
{err && (
<div className="mx-3 mt-3 rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span>{err}</span>
</div>
)}
{draft ? (
<div className="p-3 space-y-3">
{draft.map((g, ei) => (
<div key={ei} className="rounded-md border border-border bg-surface-1">
<div className="border-b border-border px-2.5 py-1.5 flex items-center gap-2">
<Wrench className="w-3 h-3 text-fg-muted flex-shrink-0" />
<input
value={g.event}
onChange={(e) =>
setDraft((d) =>
d ? d.map((x, i) => (i === ei ? { ...x, event: e.target.value } : x)) : d
)
}
placeholder={t("manage.hookEvent")}
aria-label={t("manage.hookEvent")}
className="h-7 flex-1 min-w-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => setDraft((d) => (d ? d.filter((_, i) => i !== ei) : d))}
title={t("manage.hookRemoveEvent")}
aria-label={t("manage.hookRemoveEvent")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="divide-y divide-border">
{g.entries.map((h, hi) => (
<div key={hi} className="px-2.5 py-2 space-y-1.5">
<div className="flex items-center gap-2">
<input
value={h.matcher || ""}
onChange={(e) => updateEntry(ei, hi, "matcher", e.target.value)}
placeholder={t("hooks.matcher")}
aria-label={t("hooks.matcher")}
className="h-7 w-32 flex-shrink-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<input
value={h.timeout == null ? "" : String(h.timeout)}
onChange={(e) => updateEntry(ei, hi, "timeout", e.target.value)}
placeholder={t("hooks.timeout")}
aria-label={t("hooks.timeout")}
inputMode="numeric"
className="h-7 w-24 flex-shrink-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() =>
setDraft((d) =>
d
? d.map((x, i) =>
i === ei
? { ...x, entries: x.entries.filter((_, j) => j !== hi) }
: x
)
: d
)
}
title={t("manage.hookRemoveEntry")}
aria-label={t("manage.hookRemoveEntry")}
className="h-7 w-7 ml-auto flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<input
value={h.command || ""}
onChange={(e) => updateEntry(ei, hi, "command", e.target.value)}
placeholder={t("hooks.command")}
aria-label={t("hooks.command")}
className="h-7 w-full bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
</div>
))}
<div className="px-2.5 py-1.5">
<ActionButton
icon={Plus}
onClick={() =>
setDraft((d) =>
d
? d.map((x, i) =>
i === ei
? {
...x,
entries: [
...x.entries,
{ matcher: "*", type: "command", command: "", timeout: null },
],
}
: x
)
: d
)
}
>
{t("manage.hookAddEntry")}
</ActionButton>
</div>
</div>
</div>
))}
<button
type="button"
onClick={() =>
setDraft((d) => [
...(d ?? []),
{
event: "",
entries: [{ matcher: "*", type: "command", command: "", timeout: null }],
},
])
}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("manage.hookAddEvent")}
</button>
{readOnlyEvents.length > 0 && (
<p className="text-[10px] text-fg-muted leading-relaxed">
{t("manage.hookUneditable", {
events: readOnlyEvents.map(([e]) => e).join(", "),
})}
</p>
)}
</div>
) : (
<div className="p-3">
{filteredEvents.length === 0 ? (
<div className="text-xs text-fg-muted px-1 py-2">{t("hooks.noHooks")}</div>
) : (
<div className="space-y-3">
{filteredEvents.map(([event, entries]) => (
<div key={event}>
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5 inline-flex items-center gap-2">
<Wrench className="w-3 h-3 text-fg-muted" />
{event}
<span className="text-[10px] text-fg-muted">({entries.length})</span>
</div>
<div className="space-y-1.5 pl-5">
{entries.map((h, idx) => (
<div
key={`${event}-${idx}`}
className="rounded-md border border-border bg-surface-1 px-2.5 py-1.5 text-[11px]"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-fg-muted">
{t("hooks.matcher")}={h.matcher}
</span>
<span className="text-[10px] text-fg-muted">·</span>
<span className="font-mono text-[10px] text-fg-muted">{h.type}</span>
{h.timeout != null && (
<span className="text-[10px] text-fg-muted">{h.timeout}ms</span>
)}
</div>
{h.command && (
<div className="mt-1 font-mono text-[11px] text-fg-secondary break-all">
{h.command}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ── Settings ──────────────────────────────────────────────────────────
// The settings that the TUI's `/config` editor manages, in display order.
// Surfaced as a resolved at-a-glance summary so the user sees what `/config`
// set (model, verbose, theme, …) without hunting through the raw JSON files.
// Keys map 1:1 to settings.json keys per https://code.claude.com/docs/en/settings.
const CONFIG_OPTION_GROUPS: { title: string; keys: { key: string; label: string }[] }[] = [
{
title: "Model & reasoning",
keys: [
{ key: "model", label: "Model" },
{ key: "effortLevel", label: "Effort level" },
{ key: "alwaysThinkingEnabled", label: "Always thinking" },
],
},
{
title: "Output & display",
keys: [
{ key: "outputStyle", label: "Output style" },
{ key: "verbose", label: "Verbose output" },
{ key: "theme", label: "Theme" },
{ key: "language", label: "Language" },
{ key: "spinnerTipsEnabled", label: "Spinner tips" },
{ key: "autoScrollEnabled", label: "Auto-scroll" },
],
},
{
title: "Session & input",
keys: [
{ key: "autoCompactEnabled", label: "Auto-compact" },
{ key: "fileCheckpointingEnabled", label: "File checkpointing" },
{ key: "editorMode", label: "Editor mode" },
{ key: "preferredNotifChannel", label: "Notifications" },
{ key: "awaySummaryEnabled", label: "Away summary" },
],
},
];
/**
* Resolve each /config option across the settings sources (project-local >
* project > user precedence - later sources in the array win) and render a
* compact summary. Unset options show as "default" so the view reflects the
* effective configuration, not just whatever happens to be written to a file.
*/
function CurrentConfigPanel({ sources }: { sources: CcSettingsSource[] }) {
// Build effective map: { key → { value, scope } }. Sources arrive ordered
// user → project → project-local, so a later hit overrides an earlier one.
const effective = new Map<string, { value: unknown; scope: CcSettingsSource["scope"] }>();
for (const src of sources) {
if (!src.exists || !src.data || typeof src.data !== "object") continue;
const data = src.data as Record<string, unknown>;
for (const group of CONFIG_OPTION_GROUPS) {
for (const { key } of group.keys) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
effective.set(key, { value: data[key], scope: src.scope });
}
}
}
}
const setCount = effective.size;
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<SettingsIcon className="w-3.5 h-3.5 text-violet-700/80 dark:text-violet-300/80" />
<span className="text-sm font-medium text-fg-primary">Current configuration</span>
<span className="text-[11px] text-fg-muted ml-auto">
{setCount} option{setCount !== 1 ? "s" : ""} set · the rest use defaults
</span>
</div>
<div className="p-3 space-y-3">
{CONFIG_OPTION_GROUPS.map((group) => (
<div key={group.title}>
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{group.title}
</div>
<div className="rounded-md border border-border bg-surface-1 divide-y divide-border">
{group.keys.map(({ key, label }) => {
const hit = effective.get(key);
return (
<div
key={key}
className="px-3 py-1.5 grid grid-cols-[150px_1fr_auto] gap-3 items-center"
>
<div className="text-[11px] text-fg-secondary truncate">{label}</div>
<div className="min-w-0">
{hit ? (
<SettingsValue value={hit.value} />
) : (
<span className="text-[10px] text-fg-muted italic">default</span>
)}
</div>
{hit ? (
<ScopeBadge scope={hit.scope} />
) : (
<span className="text-[10px] text-fg-muted">-</span>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
function SettingsPanel({
sources,
statusline,
onOpen,
onToast,
onChanged,
}: {
sources: CcSettingsSource[] | null;
statusline: CcStatusline | null;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
if (!sources) return <SkeletonRows n={3} />;
return (
<div className="space-y-3">
<ExplainerBanner
title={t("explain.settings.title")}
body={t("explain.settings.body")}
howTo={t("explain.settings.howTo")}
commands={[
{ cmd: t("explain.settings.cmd1"), note: t("explain.settings.cmd1Note") },
{ cmd: t("explain.settings.cmd2"), note: t("explain.settings.cmd2Note") },
{ cmd: t("explain.settings.cmd3"), note: t("explain.settings.cmd3Note") },
]}
/>
<CurrentConfigPanel sources={sources} />
<div className="rounded-md border border-status-warning/30 bg-status-warning/5 px-3 py-2 text-[11px] text-status-warning/90 flex items-center gap-2">
<Info className="w-3.5 h-3.5 flex-shrink-0" />
{t("common.redactedNotice")}
</div>
{statusline && (statusline.config || statusline.scripts.length > 0) && (
<StatuslineBlock data={statusline} onOpen={onOpen} />
)}
{sources.map((src) => (
<SettingsBlock
key={src.scope}
source={src}
onOpen={onOpen}
onToast={onToast}
onChanged={onChanged}
/>
))}
</div>
);
}
function StatuslineBlock({ data, onOpen }: { data: CcStatusline; onOpen: (p: string) => void }) {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-fg-primary">{t("statusline.title")}</div>
</div>
<div className="p-3 space-y-3">
{data.config ? (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t("statusline.configured")}
</div>
<div className="rounded-md border border-border bg-surface-1 px-3 py-2 text-[11px] font-mono text-fg-secondary">
<span className="text-fg-muted">type:</span> {data.config.type ?? "-"}
{data.config.command && (
<>
<br />
<span className="text-fg-muted">command:</span> {data.config.command}
</>
)}
</div>
</div>
) : (
<div className="text-xs text-fg-muted">{t("statusline.noStatusline")}</div>
)}
{data.scripts.length > 0 && (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t("statusline.scripts")}
</div>
<div className="space-y-1.5">
{data.scripts.map((s) => (
<button
key={s.file}
onClick={() => onOpen(s.file)}
className="w-full text-left rounded-md border border-border bg-surface-1 hover:bg-surface-3 px-3 py-1.5 inline-flex items-center gap-2"
>
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary flex-1 truncate">
{s.file}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(s.size)}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}
/** A key whose value the API redacted cannot be edited here — echoing the
* placeholder back would destroy the real secret (the server rejects it too). */
function isRedacted(value: unknown): boolean {
try {
return JSON.stringify(value)?.includes("<redacted>") ?? false;
} catch {
return false;
}
}
type SettingsDraftRow = { key: string; text: string; original: string; isNew: boolean };
function SettingsBlock({
source,
onOpen,
onToast,
onChanged,
}: {
source: CcSettingsSource;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [showRaw, setShowRaw] = useState(false);
const [draft, setDraft] = useState<SettingsDraftRow[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const data = (source.data as Record<string, unknown> | null | undefined) || {};
// Redacted keys stay out of the draft entirely, so a save can never mention
// them; they keep rendering read-only below the editor.
const editableKeys = Object.entries(data).filter(([, v]) => !isRedacted(v));
const redactedKeys = Object.entries(data).filter(([, v]) => isRedacted(v));
const startEdit = () => {
setErr(null);
setDraft(
editableKeys.map(([key, value]) => {
const text = JSON.stringify(value, null, 2);
return { key, text, original: text, isNew: false };
})
);
};
const save = async () => {
if (!draft) return;
const set: Record<string, unknown> = {};
const seen = new Set<string>();
for (const row of draft) {
const key = row.key.trim();
if (!key) {
setErr(t("manage.settingsEmptyKey"));
return;
}
if (seen.has(key)) {
setErr(t("manage.settingsDupKey", { key }));
return;
}
seen.add(key);
// Only send what actually changed — an untouched key is left alone.
if (!row.isNew && row.text === row.original) continue;
try {
set[key] = JSON.parse(row.text);
} catch {
setErr(t("manage.settingsBadJson", { key }));
return;
}
}
const unset = editableKeys.map(([key]) => key).filter((key) => !seen.has(key));
if (Object.keys(set).length === 0 && unset.length === 0) {
setDraft(null);
return;
}
setErr(null);
const ok = await run(
"save",
() =>
api.ccConfig.patchSettings({
scope: source.scope,
...(Object.keys(set).length ? { set } : {}),
...(unset.length ? { unset } : {}),
}),
t("manage.settingsSaved", { file: source.file })
);
if (ok) setDraft(null);
};
return (
<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>
)}
{draft ? (
<div className="inline-flex items-center gap-1.5">
<ActionButton icon={X} onClick={() => setDraft(null)} disabled={busy === "save"}>
{t("edit.cancel")}
</ActionButton>
<ActionButton icon={Save} onClick={() => void save()} disabled={busy === "save"}>
{busy === "save" ? t("edit.saving") : t("edit.save")}
</ActionButton>
</div>
) : (
<ActionButton icon={Pencil} onClick={startEdit}>
{t("edit.editButton")}
</ActionButton>
)}
</div>
{err && (
<div className="mx-3 mt-3 rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span>{err}</span>
</div>
)}
{draft ? (
<div className="p-3 space-y-2">
{draft.map((row, i) => (
<div key={i} className="rounded-md border border-border bg-surface-1 px-2.5 py-2">
<div className="flex items-center gap-2">
<input
value={row.key}
onChange={(e) =>
setDraft((d) =>
d ? d.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)) : d
)
}
placeholder={t("manage.settingsKeyPlaceholder")}
aria-label={t("manage.settingsKey")}
className="h-7 flex-1 min-w-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => setDraft((d) => (d ? d.filter((_, j) => j !== i) : d))}
title={t("manage.settingsRemoveKey")}
aria-label={t("manage.settingsRemoveKey")}
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>
<textarea
value={row.text}
onChange={(e) =>
setDraft((d) =>
d ? d.map((r, j) => (j === i ? { ...r, text: e.target.value } : r)) : d
)
}
rows={Math.min(8, row.text.split("\n").length || 1)}
spellCheck={false}
aria-label={t("manage.settingsValue")}
className="mt-1.5 w-full bg-surface-2 border border-border rounded px-2 py-1 text-[11px] font-mono text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
</div>
))}
<button
type="button"
onClick={() =>
setDraft((d) => [...(d ?? []), { key: "", text: '""', original: "", isNew: true }])
}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("manage.settingsAddKey")}
</button>
<p className="text-[10px] text-fg-muted leading-relaxed">{t("manage.settingsHelp")}</p>
{redactedKeys.length > 0 && (
<p className="text-[10px] text-fg-muted leading-relaxed inline-flex items-center gap-1.5">
<Lock className="w-3 h-3 flex-shrink-0" />
{t("manage.settingsRedactedKeys", {
keys: redactedKeys.map(([k]) => k).join(", "),
})}
</p>
)}
</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-700 dark:text-teal-300 hover:text-teal-800 dark: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-700 dark: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-700 dark: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-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
{item.name}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(item.size)}</span>
<MemoryAutoActions
item={item}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
/>
</div>
<pre className="px-3 py-2 text-[10.5px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-40 overflow-auto">
{renderMemoryIndex(item.preview, onJump, t("memory.jumpTo"))}
{item.truncated && <span className="text-fg-muted italic">{"\n…"}</span>}
</pre>
</div>
);
}
function MemoryFactRow({
item,
onOpen,
onEditAuto,
onDeleteAuto,
highlighted,
rowRef,
}: MemoryAutoItemProps & {
highlighted?: boolean;
rowRef?: (el: HTMLDivElement | null) => void;
}) {
const desc = memoryDescription(item);
return (
<div
ref={rowRef}
className={`flex items-start gap-2.5 rounded-md border px-3 py-2 transition-colors ${
highlighted
? "border-teal-400/70 bg-teal-500/10 ring-1 ring-teal-400/50"
: "border-border bg-surface-1 hover:border-border/80"
}`}
>
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<span className="font-mono text-[11px] text-fg-secondary truncate block">{item.name}</span>
{desc && (
<p className="mt-0.5 text-[11px] text-fg-muted leading-snug line-clamp-2">{desc}</p>
)}
</div>
<span className="text-[10px] text-fg-muted flex-shrink-0 mt-0.5">
{formatBytes(item.size)}
</span>
<div className="mt-0.5">
<MemoryAutoActions
item={item}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
/>
</div>
</div>
);
}
// ── Marketplaces ──────────────────────────────────────────────────────
function MarketplacesPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcMarketplacesResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [source, setSource] = useState("");
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const add = useCallback(async () => {
const value = source.trim();
if (!value) return;
const ok = await run(
"add",
() => api.ccConfig.addMarketplace(value),
t("manage.marketplaceAdded", { name: value })
);
if (ok) setSource("");
}, [run, source, t]);
if (!data) return <SkeletonRows n={3} />;
const filtered = data.items.filter(
(m) =>
!search ||
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.marketplaceName || "").toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-3">
<ExplainerBanner
title={t("explain.marketplaces.title")}
body={t("explain.marketplaces.body")}
howTo={t("marketplaces.manifest")}
commands={[{ cmd: t("marketplaces.addCmd"), note: "" }]}
/>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
{t("manage.addMarketplaceTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void add();
}}
placeholder={t("manage.addMarketplacePlaceholder")}
aria-label={t("manage.addMarketplaceTitle")}
className="h-8 flex-1 min-w-60 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => void add()}
disabled={!source.trim() || busy === "add"}
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.addMarketplaceButton")}
</button>
</div>
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">
{t("manage.addMarketplaceHelp")}
</p>
</div>
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate">{data.knownPath}</span>
</div>
{filtered.length === 0 ? (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("marketplaces.noMarketplaces")}
</div>
) : (
filtered.map((m) => (
<div key={m.name} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Store className="w-3.5 h-3.5 text-fg-muted" />
<span className="font-mono text-sm text-fg-primary">{m.name}</span>
{m.marketplaceName && m.marketplaceName !== m.name && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{m.marketplaceName}
</span>
)}
{m.pluginCount != null && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{t("marketplaces.pluginCount")}: {m.pluginCount}
</span>
)}
</div>
{m.marketplaceDescription && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{m.marketplaceDescription}
</p>
)}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
{m.source && (
<div className="col-span-2 truncate">
<span className="text-fg-muted">{t("marketplaces.source")}:</span>{" "}
<span className="font-mono">
{m.source.source === "github" && m.source.repo
? `github.com/${m.source.repo}`
: m.source.url || m.source.repo || JSON.stringify(m.source)}
</span>
</div>
)}
{m.marketplaceOwner?.name && (
<div>
<span className="text-fg-muted">{t("marketplaces.owner")}:</span>{" "}
{m.marketplaceOwner.name}
</div>
)}
{m.lastUpdated && (
<div>
<span className="text-fg-muted">{t("marketplaces.lastUpdated")}:</span>{" "}
{new Date(m.lastUpdated).toLocaleString()}
</div>
)}
</div>
{m.installLocation && (
<div className="mt-2 flex items-center gap-2">
<span className="font-mono text-[10px] text-fg-muted truncate flex-1">
{m.installLocation}
</span>
<CopyButton value={m.installLocation} />
</div>
)}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
<ActionButton
icon={RefreshCw}
disabled={busy === m.name}
onClick={() =>
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "update"),
t("manage.marketplaceUpdated", { name: m.name })
)
}
>
{t("manage.update")}
</ActionButton>
<ActionButton
icon={Trash2}
danger
disabled={busy === m.name}
onClick={() => {
if (confirmRemove !== m.name) {
setConfirmRemove(m.name);
return;
}
setConfirmRemove(null);
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "remove"),
t("manage.marketplaceRemoved", { name: m.name })
);
}}
>
{busy === m.name
? t("manage.working")
: confirmRemove === m.name
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
</div>
</div>
))
)}
</div>
);
}
// ── Keybindings ───────────────────────────────────────────────────────
function KeybindingsPanel({
data,
search,
onSaved,
onToast,
}: {
data: CcKeybindings | null;
search: string;
onSaved: () => void;
onToast: (toast: NonNullable<Toast>) => void;
}) {
const { t } = useTranslation("ccConfig");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<CcKeybindingGroup[]>([]);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const startEdit = useCallback(() => {
const groups = data?.groups ?? [];
// Deep clone so edits never mutate the fetched data.
setDraft(
groups.map((g) => ({ context: g.context, bindings: g.bindings.map((b) => ({ ...b })) }))
);
setErr(null);
setEditing(true);
}, [data]);
const cancelEdit = useCallback(() => {
setEditing(false);
setDraft([]);
setErr(null);
}, []);
const updateContext = (gi: number, value: string) =>
setDraft((d) => d.map((g, i) => (i === gi ? { ...g, context: value } : g)));
const removeContext = (gi: number) => setDraft((d) => d.filter((_, i) => i !== gi));
const addContext = () =>
setDraft((d) => [...d, { context: "", bindings: [{ key: "", action: "" }] }]);
const updateBinding = (gi: number, bi: number, field: "key" | "action", value: string) =>
setDraft((d) =>
d.map((g, i) =>
i === gi
? { ...g, bindings: g.bindings.map((b, j) => (j === bi ? { ...b, [field]: value } : b)) }
: g
)
);
const removeBinding = (gi: number, bi: number) =>
setDraft((d) =>
d.map((g, i) => (i === gi ? { ...g, bindings: g.bindings.filter((_, j) => j !== bi) } : g))
);
const addBinding = (gi: number) =>
setDraft((d) =>
d.map((g, i) => (i === gi ? { ...g, bindings: [...g.bindings, { key: "", action: "" }] } : g))
);
const handleSave = useCallback(async () => {
const groups: CcKeybindingGroup[] = draft.map((g) => ({
context: g.context.trim(),
bindings: g.bindings.map((b) => ({ key: b.key.trim(), action: b.action.trim() })),
}));
// Mirror the server-side validation so users get instant, local feedback.
const seen = new Set<string>();
for (const g of groups) {
if (!g.context) return setErr(t("keybindings.errContext"));
if (seen.has(g.context))
return setErr(t("keybindings.errDupContext", { context: g.context }));
seen.add(g.context);
const keys = new Set<string>();
for (const b of g.bindings) {
if (!b.key || !b.action) return setErr(t("keybindings.errEmpty", { context: g.context }));
if (keys.has(b.key))
return setErr(t("keybindings.errDupKey", { key: b.key, context: g.context }));
keys.add(b.key);
}
}
setSaving(true);
setErr(null);
try {
const result = await api.ccConfig.writeKeybindings(groups);
onToast({
kind: "success",
message: result.created
? t("edit.saveSuccessNew")
: t("edit.saveSuccess", { path: result.backupPath || "-" }),
});
setEditing(false);
setDraft([]);
onSaved();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "unknown error";
setErr(msg);
onToast({ kind: "error", message: t("edit.writeError", { message: msg }) });
} finally {
setSaving(false);
}
}, [draft, onSaved, onToast, t]);
if (!data) return <SkeletonRows n={3} />;
const headerBar = (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted flex-wrap">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate flex-1 min-w-0">{data.file}</span>
{data.docs && (
<a
href={data.docs}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-accent hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
{t("keybindings.docsLink")}
</a>
)}
{editing ? (
<div className="inline-flex items-center gap-1.5">
<button
type="button"
onClick={cancelEdit}
disabled={saving}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary disabled:opacity-50"
>
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Save className="w-3 h-3" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
) : (
<button
type="button"
onClick={startEdit}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
)}
</div>
);
// ── Edit mode ────────────────────────────────────────────────────────
if (editing) {
return (
<div className="space-y-3">
{headerBar}
{err && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span>{err}</span>
</div>
)}
{draft.map((g, gi) => (
<div key={gi} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-3 py-2 flex items-center gap-2">
<span className="text-[11px] text-fg-muted flex-shrink-0">
{t("keybindings.context")}
</span>
<input
value={g.context}
onChange={(e) => updateContext(gi, e.target.value)}
placeholder={t("keybindings.contextPlaceholder")}
className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => removeContext(gi)}
title={t("keybindings.removeContext")}
aria-label={t("keybindings.removeContext")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="divide-y divide-border">
{g.bindings.map((b, bi) => (
<div key={bi} className="px-3 py-1.5 flex items-center gap-2">
<input
value={b.key}
onChange={(e) => updateBinding(gi, bi, "key", e.target.value)}
placeholder={t("keybindings.key")}
className="h-7 w-40 flex-shrink-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<span className="text-fg-muted flex-shrink-0">→</span>
<input
value={b.action}
onChange={(e) => updateBinding(gi, bi, "action", e.target.value)}
placeholder={t("keybindings.action")}
className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => removeBinding(gi, bi)}
title={t("keybindings.removeBinding")}
aria-label={t("keybindings.removeBinding")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="px-3 py-1.5">
<button
type="button"
onClick={() => addBinding(gi)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("keybindings.addBinding")}
</button>
</div>
</div>
</div>
))}
<button
type="button"
onClick={addContext}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("keybindings.addContext")}
</button>
</div>
);
}
// ── Read-only mode ───────────────────────────────────────────────────
if (!data.exists) {
return (
<div className="space-y-3">
{headerBar}
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("keybindings.missing", { path: data.file })}
</div>
</div>
);
}
const q = search.toLowerCase();
return (
<div className="space-y-3">
{headerBar}
{data.groups.map((g) => {
const filtered = g.bindings.filter(
(b) =>
!q ||
b.key.toLowerCase().includes(q) ||
b.action.toLowerCase().includes(q) ||
g.context.toLowerCase().includes(q)
);
if (filtered.length === 0) return null;
return (
<div key={g.context} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2 text-xs font-medium text-fg-secondary">
{t("keybindings.context")}: <span className="text-fg-primary">{g.context}</span>
<span className="ml-2 text-[10px] text-fg-muted">({filtered.length})</span>
</div>
<div className="divide-y divide-border">
{filtered.map((b) => (
<div key={b.key} className="px-4 py-1.5 flex items-center gap-3">
<kbd className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-surface-3 border border-border text-fg-secondary min-w-20 text-center">
{b.key}
</kbd>
<span className="font-mono text-[11px] text-fg-secondary">{b.action}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
// ── Read-only explainer banner ─────────────────────────────────────────
interface ExplainerBannerProps {
title: string;
body: string;
howTo: string;
commands: { cmd: string; note: string }[];
}
function ExplainerBanner({ title, body, howTo, commands }: ExplainerBannerProps) {
return (
<div className="rounded-lg border border-status-warning/30 bg-status-warning/[0.04] px-4 py-3">
<div className="flex items-start gap-2">
<Lock className="w-4 h-4 text-status-warning flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1 space-y-2">
<div className="text-sm font-medium text-status-warning">{title}</div>
<p className="text-xs text-fg-secondary leading-relaxed">{body}</p>
{commands.length > 0 && (
<div className="pt-1">
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{howTo}
</div>
<div className="space-y-1.5">
{commands.map((c, i) => (
<CommandSnippet key={i} command={c.cmd} label={c.note} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Inline copyable command ────────────────────────────────────────────
function CommandSnippet({ command, label }: { command: string; label?: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
{label && (
<span className="text-[10px] text-fg-muted hidden md:inline truncate">{label}</span>
)}
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
);
}
// ── Auth profiles modal ────────────────────────────────────────────────
//
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
// subagents, memory, plugins, marketplaces and settings all stay exactly where
// they are — that is the whole point of the feature.
//
// The dashboard cannot log a new account in (only `claude auth login` can mint
// tokens), so adding a profile is always "log in, then snapshot what the CLI
// wrote" — hence a save button on the live account rather than an add-account
// flow. There is no name field: the server derives the identifier, and the user
// edits the display label in place by clicking it. That keeps the one string
// the user types free of filename rules, so spaces and diacritics are fine.
// The save button only appears while the live account is unsaved, because one
// account is one profile — a second copy would just report itself active too.
function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: Toast) => void }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
/** Quota per profile, keyed by `name`, fetched for every profile once the
* list arrives — the whole point of the panel is comparing accounts, and one
* button per row made the user do that by hand. The cost is bounded: the
* live account is read from the CLI and never refreshed, and any other
* profile is refreshed only when its token has actually expired, which
* leaves it valid for hours of further opens. */
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
/** Per profile, so one slow account does not freeze the other rows. */
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const load = useCallback(async () => {
try {
const r = await api.ccConfig.profiles();
setItems(r.items);
setActive(r.active);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
/** Run one profile mutation, then refresh the list. */
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => {
setBusy(key);
try {
await fn();
onToast({ kind: "success", message: success });
await load();
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : "unknown error",
});
} finally {
setBusy(null);
}
},
[load, onToast]
);
const checkUsage = useCallback(async (name: string) => {
setUsageBusy((prev) => ({ ...prev, [name]: true }));
setUsageError((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
try {
const result = await api.ccConfig.profileUsage(name);
setUsage((prev) => ({ ...prev, [name]: result }));
} catch (err: unknown) {
setUsageError((prev) => ({
...prev,
[name]: err instanceof Error ? err.message : "unknown error",
}));
} finally {
setUsageBusy((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}
}, []);
// Every profile's quota, as soon as there is a list to ask about. Once per
// profile per opening: a retry is the row's own button, so a failed account
// is not re-asked on every render.
useEffect(() => {
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage]);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
const copies = (p: CcProfile) =>
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<UserCog className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("profiles.title")}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border space-y-2">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("profiles.warning")}</span>
</p>
</div>
<div className="overflow-auto p-4 space-y-3">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("profiles.activeLabel")}
</div>
{active ? (
<>
<div className="text-sm text-fg-primary">
{active.email || active.displayName || active.accountUuid}
</div>
<IdentityLine identity={active} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Save className="w-3 h-3" />
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
</button>
</div>
)}
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("profiles.empty")}
</div>
)}
{items?.map((p) => (
<div key={p.name} className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
{renaming === p.name ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (e.key !== "Enter") return;
const next = draft.trim();
setRenaming(null);
if (!next || next === p.label) return;
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
);
}}
maxLength={60}
aria-label={t("profiles.renameLabel")}
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
) : (
<button
onClick={() => {
setDraft(p.label);
setRenaming(p.name);
}}
title={t("profiles.renameHint")}
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
>
{p.label}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
</button>
)}
{p.active && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
{t("profiles.activeBadge")}
</span>
)}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`switch:${p.name}`,
() => api.ccConfig.activateProfile(p.name),
t("profiles.switchSuccess", { name: p.label })
)
}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{/* Deletable unless it is the ONLY saved copy of the live
account — a redundant copy has to be removable, or an
accidental duplicate could never be cleaned up. */}
{(!p.active || copies(p) > 1) && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`del:${p.name}`,
() => api.ccConfig.deleteProfile(p.name),
t("profiles.deleteSuccess", { name: p.label })
)
}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
<div>
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
<CommandSnippet command="claude auth login" />
</div>
</div>
</div>
</div>
);
}
/**
* One profile's quota: a button until asked, then the two windows Anthropic
* reports — the 5-hour session allowance and the 7-day one.
*
* Deliberately not fetched with the profile list. Reading a saved profile's
* usage means refreshing its OAuth token, and that rotates the credentials on
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
* on a render or a poll.
*/
function ProfileUsageLine({
usage,
error,
busy,
disabled,
onCheck,
}: {
usage?: CcProfileUsage;
error?: string;
busy: boolean;
disabled: boolean;
onCheck: () => void;
}) {
const { t } = useTranslation("ccConfig");
const pct = (w: CcUsageWindow | null | undefined) =>
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
const resetTitle = (w: CcUsageWindow | null | undefined) =>
w && w.resetsAt
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
: undefined;
if (error) {
return (
<div className="mt-1 flex items-center gap-2 text-[10px]">
<span className="text-status-danger">{error}</span>
<button
onClick={onCheck}
disabled={disabled}
className="text-fg-muted hover:text-accent disabled:opacity-50"
>
{t("profiles.usageRetry")}
</button>
</div>
);
}
if (!usage) {
return (
<button
onClick={onCheck}
disabled={disabled}
title={t("profiles.usageHint")}
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
</button>
);
}
return (
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
<span title={resetTitle(usage.fiveHour)}>
{t("profiles.usageFiveHour")}{" "}
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
</span>
<span title={resetTitle(usage.sevenDay)}>
{t("profiles.usageSevenDay")}{" "}
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
</span>
<button
onClick={onCheck}
disabled={disabled}
className="hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
</button>
</div>
);
}
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
Boolean
);
if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
// ── Backups modal ──────────────────────────────────────────────────────
function BackupsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcBackup[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.ccConfig
.backups()
.then((r) => setItems(r.items))
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-3xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<History className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("backups.title")}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("backups.subtitle")}</p>
</div>
<div className="overflow-auto p-4 space-y-2">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{items === null && !error && <SkeletonRows n={4} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("backups.empty")}
</div>
)}
{items?.map((b) => (
<BackupRow key={b.backupPath} backup={b} />
))}
</div>
</div>
</div>
);
}
function BackupRow({ backup }: { backup: CcBackup }) {
const { t } = useTranslation("ccConfig");
// Heuristic restore: rename the backup back to the active path. We don't
// shell out from the dashboard for this (too risky to silently overwrite
// a current active version) - show the user a copyable mv command instead.
const restoreCmd = `mv ${shellEscape(backup.backupPath)} ${shellEscape(deriveActivePath(backup))}`;
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
<ScopeBadge scope={backup.scope} />
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{backup.type}
</span>
<span className="font-mono text-xs text-fg-primary truncate flex-1 min-w-0">
{backup.name}
</span>
<span className="text-[10px] text-fg-muted">{new Date(backup.mtime).toLocaleString()}</span>
{backup.size != null && (
<span className="text-[10px] text-fg-muted">{formatBytes(backup.size)}</span>
)}
</div>
<div className="mt-1.5 font-mono text-[10px] text-fg-muted truncate">{backup.backupPath}</div>
<div className="mt-2">
<div className="text-[10px] text-fg-muted mb-1">{t("backups.restoreHint")}</div>
<CommandSnippet command={restoreCmd} />
</div>
</div>
);
}
// Best-effort: derive the active path the backup would restore to. We strip
// the trailing `.<ISO>.bak` segment from the basename and put the result back
// under the right active subdir. If the format doesn't match, fall back to
// "(unknown)" - the user can still copy the backup path itself.
function deriveActivePath(b: CcBackup): string {
// Strip ".<ISO>.bak" suffix from the basename.
const m = b.name.match(/^(.+?)\.[^.]+\.bak$/);
const baseName = m ? m[1] : b.name;
const backupDir = b.backupPath.replace(/\/[^/]+$/, ""); // dirname
// Backups live at <root>/cc-config-backups/<type>/<name>.<ts>.bak
// Active lives at <root>/<type>/<baseName> (or .../skills/<baseName>/SKILL.md handled at use-time)
const rootMatch = backupDir.match(/^(.*)\/cc-config-backups\/([^/]+)$/);
if (rootMatch) {
const root = rootMatch[1];
const type = rootMatch[2];
return `${root}/${type}/${baseName}`;
}
// memory backups live at <projectRoot>/.cc-config-backups/memory/<name>.<ts>.bak
const memMatch = backupDir.match(/^(.*)\/\.cc-config-backups\/memory$/);
if (memMatch) return `${memMatch[1]}/${baseName}`;
// auto-memory backups live at <memoryDir>/.cc-config-backups/auto-memory/<name>.<ts>.bak
const autoMatch = backupDir.match(/^(.*)\/\.cc-config-backups\/auto-memory$/);
if (autoMatch) return `${autoMatch[1]}/${baseName}`;
return "<active path>";
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}