Files
AgentHub/client/src/pages/CcConfig.tsx
T
nntrivi2001 1aee080fca fix(cc-config): read the live account's quota from the CLI, and ask for every account at once
The quota check failed with `invalid_grant` for the account the user was
logged into - the one profile they would try first. An OAuth refresh token is
single use, so every refresh Claude Code performs on its own account retires
the copy sitting in that account's saved profile: the stored credentials of
the LIVE account are the one set guaranteed to be rejected, however recently
the profile was saved. Confirmed on a real install - the saved profile held a
refresh token three rotations behind `.credentials.json`.

`cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against
the live credentials and, on a hit, the usage call uses what the CLI is
holding. That token is never refreshed and that file is never written:
rotating it would log the user out of their own terminal. An expired live
token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it -
and a genuinely spent refresh token is now reported as ESTALE with what to do
about it, instead of echoing Anthropic's raw 400.

Opening the Profiles modal also asks for every saved account at once, rather
than one button per row: comparing accounts is the only reason the panel
exists. The guard against rewriting credentials over and over is no longer
"one row at a time" but the rules above - the live account costs a plain read,
and any other profile refreshes only once its token has actually expired,
which then leaves it valid for hours of further opens. Each row resolves on
its own, so a superseded account still lets its neighbours report.
2026-09-17 23:17:06 +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-300",
bar: "bg-sky-500/40",
ring: "ring-sky-500/20",
},
emerald: {
iconBg: "bg-status-success/10",
iconText: "text-status-success",
bar: "bg-status-success/40",
ring: "ring-status-success/20",
},
violet: {
iconBg: "bg-violet-500/10",
iconText: "text-violet-300",
bar: "bg-violet-500/40",
ring: "ring-violet-500/20",
},
amber: {
iconBg: "bg-status-warning/10",
iconText: "text-status-warning",
bar: "bg-status-warning/40",
ring: "ring-status-warning/20",
},
fuchsia: {
iconBg: "bg-fuchsia-500/10",
iconText: "text-fuchsia-300",
bar: "bg-fuchsia-500/40",
ring: "ring-fuchsia-500/20",
},
cyan: {
iconBg: "bg-cyan-500/10",
iconText: "text-cyan-300",
bar: "bg-cyan-500/40",
ring: "ring-cyan-500/20",
},
pink: {
iconBg: "bg-pink-500/10",
iconText: "text-pink-300",
bar: "bg-pink-500/40",
ring: "ring-pink-500/20",
},
indigo: {
iconBg: "bg-indigo-500/10",
iconText: "text-indigo-300",
bar: "bg-indigo-500/40",
ring: "ring-indigo-500/20",
},
orange: {
iconBg: "bg-orange-500/10",
iconText: "text-orange-300",
bar: "bg-orange-500/40",
ring: "ring-orange-500/20",
},
teal: {
iconBg: "bg-teal-500/10",
iconText: "text-teal-300",
bar: "bg-teal-500/40",
ring: "ring-teal-500/20",
},
slate: {
iconBg: "bg-surface-4/10",
iconText: "text-fg-secondary",
bar: "bg-surface-4/40",
ring: "ring-border-light/20",
},
rose: {
iconBg: "bg-rose-500/10",
iconText: "text-rose-300",
bar: "bg-rose-500/40",
ring: "ring-rose-500/20",
},
};
function OverviewPanel({ overview }: { overview: CcOverview | null }) {
const { t } = useTranslation("ccConfig");
if (!overview) return <SkeletonRows n={4} />;
const { roots, counts } = overview;
return (
<div className="space-y-5">
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="sky"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
<RootRow
icon={FileText}
tone="amber"
label={t("overview.claudeJson")}
value={roots.claudeJson}
/>
</div>
</section>
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("overview.summary")}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
<SummaryStat
tone="fuchsia"
icon={Sparkles}
label={t("tabs.skills")}
user={counts.skills.user}
project={counts.skills.project}
/>
<SummaryStat
tone="sky"
icon={UserRound}
label={t("tabs.agents")}
user={counts.agents.user}
project={counts.agents.project}
/>
<SummaryStat
tone="cyan"
icon={Slash}
label={t("tabs.commands")}
user={counts.commands.user}
project={counts.commands.project}
/>
<SummaryStat
tone="pink"
icon={Palette}
label={t("tabs.outputStyles")}
user={counts.outputStyles.user}
project={counts.outputStyles.project}
/>
<SummaryStat
tone="indigo"
icon={Server}
label={t("tabs.mcp")}
user={counts.mcpServers.user}
project={counts.mcpServers.project}
/>
<SummaryStat
tone="emerald"
icon={PlugZap}
label={t("tabs.plugins")}
value={counts.plugins}
/>
<SummaryStat
tone="amber"
icon={Store}
label={t("tabs.marketplaces")}
value={counts.marketplaces}
/>
<SummaryStat
tone="orange"
icon={Webhook}
label={t("tabs.hooks")}
value={Object.values(counts.hooks).reduce((a, b) => a + b, 0)}
/>
<SummaryStat
tone="rose"
icon={Keyboard}
label={t("tabs.keybindings")}
value={counts.keybindings}
/>
<SummaryStat
tone="slate"
icon={SettingsIcon}
label={t("tabs.settings")}
value={counts.settingsFiles}
/>
<SummaryStat tone="teal" icon={BookOpen} label={t("tabs.memory")} value={counts.memory} />
</div>
</section>
</div>
);
}
interface SummaryStatProps {
tone: Tone;
icon: typeof Sparkles;
label: string;
// Either a single value, OR a user/project pair (which is summed for the headline number).
value?: number;
user?: number;
project?: number;
}
function SummaryStat({ tone, icon: Icon, label, value, user, project }: SummaryStatProps) {
const { t } = useTranslation("ccConfig");
const T = TONES[tone];
const total = value !== undefined ? value : (user ?? 0) + (project ?? 0);
const showBreakdown = user !== undefined && project !== undefined;
return (
<div className={`relative rounded-lg border border-border bg-surface-2 overflow-hidden`}>
{/* Left accent bar */}
<div className={`absolute left-0 top-0 bottom-0 w-1 ${T.bar}`} aria-hidden />
<div className="pl-3.5 pr-3 py-2.5">
<div className="flex items-center gap-2">
<span
className={`w-6 h-6 rounded-md ${T.iconBg} flex items-center justify-center flex-shrink-0`}
>
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
</span>
<span className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary truncate">
{label}
</span>
</div>
<div className="mt-1.5 flex items-baseline gap-2">
<span className="text-xl font-semibold text-fg-primary tabular-nums">{total}</span>
{showBreakdown && (
<span className="text-[10px] text-fg-muted truncate">
{user} {t("overview.user")} · {project} {t("overview.project")}
</span>
)}
</div>
</div>
</div>
);
}
function RootRow({
icon: Icon,
tone,
label,
value,
}: {
icon: typeof FolderTree;
tone: Tone;
label: string;
value: string;
}) {
const T = TONES[tone];
return (
<div className="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden">
<div className={`absolute left-0 top-0 bottom-0 w-1 ${T.bar}`} aria-hidden />
<span
className={`w-7 h-7 rounded-md ${T.iconBg} flex items-center justify-center flex-shrink-0 ml-1.5`}
>
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
</span>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary">
{label}
</div>
<div className="font-mono text-[11px] text-fg-secondary truncate">{value}</div>
</div>
<CopyButton value={value} />
</div>
);
}
// ── Plugins ───────────────────────────────────────────────────────────
/**
* Shared runner for the `claude`-CLI-backed actions (plugins, marketplaces)
* and the settings key-merge. Tracks which row is mid-flight so its buttons
* can disable, and funnels both outcomes into the page's toast.
*/
function useConfigAction(onToast: (t: NonNullable<Toast>) => void, onChanged: () => void) {
const [busy, setBusy] = useState<string | null>(null);
const run = useCallback(
async (id: string, fn: () => Promise<unknown>, successMessage: string) => {
setBusy(id);
try {
await fn();
onToast({ kind: "success", message: successMessage });
onChanged();
return true;
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : "unknown error",
});
return false;
} finally {
setBusy(null);
}
},
[onChanged, onToast]
);
return { busy, run };
}
/**
* Does this look like a marketplace SOURCE (`owner/repo`, an http(s) URL, or an
* absolute path) rather than a plugin spec (`name` / `name@marketplace`)? Lets
* one input field accept either — pasting a GitHub link registers the
* marketplace, typing a spec installs the plugin. The server re-validates; this
* only decides which endpoint to call.
*/
function looksLikeMarketplaceSource(value: string): boolean {
const v = value.trim();
if (!v) return false;
if (/^https?:\/\//i.test(v)) return true;
if (v.startsWith("/")) return true;
return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v);
}
/** Small action button used across the plugin/marketplace cards. */
function ActionButton({
onClick,
disabled,
danger,
icon: Icon,
children,
}: {
onClick: () => void;
disabled?: boolean;
danger?: boolean;
icon: typeof Sparkles;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`h-7 text-[11px] font-medium px-2.5 rounded-md border inline-flex items-center gap-1.5 disabled:opacity-50 ${
danger
? "border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger"
: "border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary"
}`}
>
<Icon className="w-3 h-3" />
{children}
</button>
);
}
/** 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-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-300 hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
>
{label}
</button>
);
last = m.index + full.length;
}
if (last < line.length) parts.push(line.slice(last));
return (
<span key={li}>
{parts.length ? parts : line}
{li < lines.length - 1 ? "\n" : null}
</span>
);
});
}
function MemoryPanel({
items,
search,
onOpen,
onEdit,
onDelete,
onCreate,
onEditAuto,
onDeleteAuto,
onCreateAuto,
}: MemoryPanelProps) {
const { t } = useTranslation("ccConfig");
const q = search.trim().toLowerCase();
const { primary, autoFiltered, groups, missingScopes } = useMemo(() => {
const list = items ?? [];
const primaryItems = list.filter(
(m): m is CcMemoryItem & { scope: "user" | "project" } =>
m.scope === "user" || m.scope === "project"
);
const autoItems = list.filter((m) => m.scope === "auto-memory");
const matchesAuto = (m: CcMemoryItem) => {
if (!q) return true;
const blob = [m.name, m.project, m.frontmatter?.description, m.frontmatter?.name, m.preview]
.filter(Boolean)
.join(" ")
.toLowerCase();
return blob.includes(q);
};
// Search applies to the whole tab — match the CLAUDE.md cards on their
// scope label, path, and body too so the filter is consistent.
const primaryFilteredItems = primaryItems.filter((m) => {
if (!q) return true;
return [m.scope, m.file, m.preview].join(" ").toLowerCase().includes(q);
});
const filtered = autoItems.filter(matchesAuto);
// Group surviving auto-memory files by their project dir.
const byProject = new Map<string, CcMemoryItem[]>();
for (const m of filtered) {
const key = m.project || "(unknown)";
if (!byProject.has(key)) byProject.set(key, []);
byProject.get(key)!.push(m);
}
const grouped = [...byProject.entries()].sort((a, b) => a[0].localeCompare(b[0]));
const present = new Set(primaryItems.map((m) => m.scope));
const missing = (["user", "project"] as const).filter((s) => !present.has(s));
return {
primary: primaryFilteredItems,
autoFiltered: filtered,
groups: grouped,
missingScopes: missing,
};
}, [items, q]);
if (!items) return <SkeletonRows n={2} />;
const totalAuto = items.filter((m) => m.scope === "auto-memory").length;
// The "create missing CLAUDE.md" prompts only make sense when not filtering.
const showMissing = !q;
return (
<div className="space-y-3">
{/* Primary CLAUDE.md memory (user + project) — editable */}
{primary.map((m) => (
<div key={m.scope} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={m.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
{m.file}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(m.size)}</span>
<button
onClick={() => onOpen(m.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
<button
onClick={() => onEdit("memory", { scope: m.scope, name: "", filePath: m.file })}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
<button
onClick={() => onDelete("memory", m.scope, undefined, m.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
>
<Trash2 className="w-3 h-3" />
{t("edit.deleteButton")}
</button>
</div>
<pre className="p-3 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-72 overflow-auto">
{m.preview}
{m.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
</div>
))}
{showMissing &&
missingScopes.map((s) => (
<div
key={`missing-${s}`}
className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-4 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<ScopeBadge scope={s} />
<span className="text-xs text-fg-muted">{t("memory.missing")}</span>
</div>
<button
onClick={() => onCreate(s)}
className="text-[11px] font-medium px-2.5 py-1 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
</div>
))}
{/* Per-project file-based memory (~/.claude/projects/<slug>/memory/) */}
{totalAuto > 0 && (
<section className="pt-1">
<div className="flex items-center gap-2 mb-1">
<BookOpen className="w-3.5 h-3.5 text-teal-300" />
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-secondary">
{t("memory.autoTitle")}
</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-semibold bg-surface-3 text-fg-secondary">
{q ? `${autoFiltered.length}/${totalAuto}` : totalAuto}
</span>
</div>
<p className="text-[11px] text-fg-muted mb-2.5 leading-relaxed">
{t("memory.autoSubtitle")}
</p>
{groups.length === 0 ? (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("memory.noMatches")}
</div>
) : (
<div className="space-y-2">
{groups.map(([project, files]) => (
<MemoryProjectGroup
key={project}
project={project}
files={files}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onCreateAuto={onCreateAuto}
defaultOpen={!!q || groups.length === 1}
/>
))}
</div>
)}
</section>
)}
</div>
);
}
interface MemoryProjectGroupProps {
project: string;
files: CcMemoryItem[];
onOpen: (p: string) => void;
onEditAuto: (item: CcMemoryItem) => void;
onDeleteAuto: (item: CcMemoryItem) => void;
onCreateAuto: (project: string) => void;
defaultOpen: boolean;
}
function MemoryProjectGroup({
project,
files,
onOpen,
onEditAuto,
onDeleteAuto,
onCreateAuto,
defaultOpen,
}: MemoryProjectGroupProps) {
const { t } = useTranslation("ccConfig");
const [open, setOpen] = useState(defaultOpen);
// Re-sync when the search-driven default flips (expand on search, collapse
// when cleared). User toggles within a stable search state are preserved.
useEffect(() => {
setOpen(defaultOpen);
}, [defaultOpen]);
const indexFiles = files.filter((f) => f.isIndex);
const factFiles = files.filter((f) => !f.isIndex);
// Wiring for "click an index entry → jump to its fact file". Fact rows
// register their DOM node keyed by filename; the index links look them up.
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [highlighted, setHighlighted] = useState<string | null>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (highlightTimer.current) clearTimeout(highlightTimer.current);
},
[]
);
const rowKey = useCallback((m: CcMemoryItem) => m.name || normalizeMemoryTarget(m.file), []);
const handleJump = useCallback(
(target: string) => {
const name = normalizeMemoryTarget(target);
const el = rowRefs.current.get(name);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlighted(name);
if (highlightTimer.current) clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlighted(null), 2200);
return;
}
// Target isn't currently in view (e.g. filtered out by search) — open the
// underlying file directly if we can resolve it within this project.
const match = files.find((f) => (f.name || normalizeMemoryTarget(f.file)) === name);
if (match) onOpen(match.file);
},
[files, onOpen]
);
return (
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
<div className="flex items-center gap-1 pr-2 hover:bg-surface-3 transition-colors">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-2.5 text-left flex-1 min-w-0"
>
<ChevronDown
className={`w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform ${
open ? "" : "-rotate-90"
}`}
/>
<FolderTree className="w-3.5 h-3.5 text-teal-300 flex-shrink-0" />
<span className="font-mono text-xs text-fg-secondary truncate flex-1 min-w-0">
{project}
</span>
<span className="text-[10px] text-fg-muted flex-shrink-0">
{t("memory.fileCount", { count: files.length })}
</span>
</button>
<button
onClick={() => onCreateAuto(project)}
title={t("memory.newFile")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1 flex-shrink-0"
>
<Plus className="w-2.5 h-2.5" />
{t("memory.newFile")}
</button>
</div>
{open && (
<div className="border-t border-border p-2.5 space-y-2.5">
{indexFiles.length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5 px-1">
{t("memory.indexFiles")}
</div>
<div className="space-y-1.5">
{indexFiles.map((m) => (
<MemoryIndexCard
key={m.file}
item={m}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onJump={handleJump}
/>
))}
</div>
</div>
)}
{factFiles.length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5 px-1">
{t("memory.factFiles", { count: factFiles.length })}
</div>
<div className="space-y-1">
{factFiles.map((m) => {
const key = rowKey(m);
return (
<MemoryFactRow
key={m.file}
item={m}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
highlighted={highlighted === key}
rowRef={(el) => {
if (el) rowRefs.current.set(key, el);
else rowRefs.current.delete(key);
}}
/>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
}
interface MemoryAutoItemProps {
item: CcMemoryItem;
onOpen: (p: string) => void;
onEditAuto: (item: CcMemoryItem) => void;
onDeleteAuto: (item: CcMemoryItem) => void;
}
// Compact View / Edit / Delete button cluster shared by index + fact rows.
function MemoryAutoActions({ item, onOpen, onEditAuto, onDeleteAuto }: MemoryAutoItemProps) {
const { t } = useTranslation("ccConfig");
return (
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onOpen(item.file)}
title={t("common.viewSource")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1"
>
<ExternalLink className="w-2.5 h-2.5" />
</button>
<button
onClick={() => onEditAuto(item)}
title={t("edit.editButton")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1"
>
<Pencil className="w-2.5 h-2.5" />
</button>
<button
onClick={() => onDeleteAuto(item)}
title={t("edit.deleteButton")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</div>
);
}
function MemoryIndexCard({
item,
onOpen,
onEditAuto,
onDeleteAuto,
onJump,
}: MemoryAutoItemProps & { onJump: (target: string) => void }) {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
<BookOpen className="w-3 h-3 text-teal-300 flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
{item.name}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(item.size)}</span>
<MemoryAutoActions
item={item}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
/>
</div>
<pre className="px-3 py-2 text-[10.5px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-40 overflow-auto">
{renderMemoryIndex(item.preview, onJump, t("memory.jumpTo"))}
{item.truncated && <span className="text-fg-muted italic">{"\n…"}</span>}
</pre>
</div>
);
}
function MemoryFactRow({
item,
onOpen,
onEditAuto,
onDeleteAuto,
highlighted,
rowRef,
}: MemoryAutoItemProps & {
highlighted?: boolean;
rowRef?: (el: HTMLDivElement | null) => void;
}) {
const desc = memoryDescription(item);
return (
<div
ref={rowRef}
className={`flex items-start gap-2.5 rounded-md border px-3 py-2 transition-colors ${
highlighted
? "border-teal-400/70 bg-teal-500/10 ring-1 ring-teal-400/50"
: "border-border bg-surface-1 hover:border-border/80"
}`}
>
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<span className="font-mono text-[11px] text-fg-secondary truncate block">{item.name}</span>
{desc && (
<p className="mt-0.5 text-[11px] text-fg-muted leading-snug line-clamp-2">{desc}</p>
)}
</div>
<span className="text-[10px] text-fg-muted flex-shrink-0 mt-0.5">
{formatBytes(item.size)}
</span>
<div className="mt-0.5">
<MemoryAutoActions
item={item}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
/>
</div>
</div>
);
}
// ── Marketplaces ──────────────────────────────────────────────────────
function MarketplacesPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcMarketplacesResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [source, setSource] = useState("");
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const add = useCallback(async () => {
const value = source.trim();
if (!value) return;
const ok = await run(
"add",
() => api.ccConfig.addMarketplace(value),
t("manage.marketplaceAdded", { name: value })
);
if (ok) setSource("");
}, [run, source, t]);
if (!data) return <SkeletonRows n={3} />;
const filtered = data.items.filter(
(m) =>
!search ||
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.marketplaceName || "").toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-3">
<ExplainerBanner
title={t("explain.marketplaces.title")}
body={t("explain.marketplaces.body")}
howTo={t("marketplaces.manifest")}
commands={[{ cmd: t("marketplaces.addCmd"), note: "" }]}
/>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
{t("manage.addMarketplaceTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void add();
}}
placeholder={t("manage.addMarketplacePlaceholder")}
aria-label={t("manage.addMarketplaceTitle")}
className="h-8 flex-1 min-w-60 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => void add()}
disabled={!source.trim() || busy === "add"}
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.addMarketplaceButton")}
</button>
</div>
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">
{t("manage.addMarketplaceHelp")}
</p>
</div>
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate">{data.knownPath}</span>
</div>
{filtered.length === 0 ? (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("marketplaces.noMarketplaces")}
</div>
) : (
filtered.map((m) => (
<div key={m.name} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Store className="w-3.5 h-3.5 text-fg-muted" />
<span className="font-mono text-sm text-fg-primary">{m.name}</span>
{m.marketplaceName && m.marketplaceName !== m.name && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{m.marketplaceName}
</span>
)}
{m.pluginCount != null && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{t("marketplaces.pluginCount")}: {m.pluginCount}
</span>
)}
</div>
{m.marketplaceDescription && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{m.marketplaceDescription}
</p>
)}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
{m.source && (
<div className="col-span-2 truncate">
<span className="text-fg-muted">{t("marketplaces.source")}:</span>{" "}
<span className="font-mono">
{m.source.source === "github" && m.source.repo
? `github.com/${m.source.repo}`
: m.source.url || m.source.repo || JSON.stringify(m.source)}
</span>
</div>
)}
{m.marketplaceOwner?.name && (
<div>
<span className="text-fg-muted">{t("marketplaces.owner")}:</span>{" "}
{m.marketplaceOwner.name}
</div>
)}
{m.lastUpdated && (
<div>
<span className="text-fg-muted">{t("marketplaces.lastUpdated")}:</span>{" "}
{new Date(m.lastUpdated).toLocaleString()}
</div>
)}
</div>
{m.installLocation && (
<div className="mt-2 flex items-center gap-2">
<span className="font-mono text-[10px] text-fg-muted truncate flex-1">
{m.installLocation}
</span>
<CopyButton value={m.installLocation} />
</div>
)}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
<ActionButton
icon={RefreshCw}
disabled={busy === m.name}
onClick={() =>
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "update"),
t("manage.marketplaceUpdated", { name: m.name })
)
}
>
{t("manage.update")}
</ActionButton>
<ActionButton
icon={Trash2}
danger
disabled={busy === m.name}
onClick={() => {
if (confirmRemove !== m.name) {
setConfirmRemove(m.name);
return;
}
setConfirmRemove(null);
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "remove"),
t("manage.marketplaceRemoved", { name: m.name })
);
}}
>
{busy === m.name
? t("manage.working")
: confirmRemove === m.name
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
</div>
</div>
))
)}
</div>
);
}
// ── Keybindings ───────────────────────────────────────────────────────
function KeybindingsPanel({
data,
search,
onSaved,
onToast,
}: {
data: CcKeybindings | null;
search: string;
onSaved: () => void;
onToast: (toast: NonNullable<Toast>) => void;
}) {
const { t } = useTranslation("ccConfig");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<CcKeybindingGroup[]>([]);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const startEdit = useCallback(() => {
const groups = data?.groups ?? [];
// Deep clone so edits never mutate the fetched data.
setDraft(
groups.map((g) => ({ context: g.context, bindings: g.bindings.map((b) => ({ ...b })) }))
);
setErr(null);
setEditing(true);
}, [data]);
const cancelEdit = useCallback(() => {
setEditing(false);
setDraft([]);
setErr(null);
}, []);
const updateContext = (gi: number, value: string) =>
setDraft((d) => d.map((g, i) => (i === gi ? { ...g, context: value } : g)));
const removeContext = (gi: number) => setDraft((d) => d.filter((_, i) => i !== gi));
const addContext = () =>
setDraft((d) => [...d, { context: "", bindings: [{ key: "", action: "" }] }]);
const updateBinding = (gi: number, bi: number, field: "key" | "action", value: string) =>
setDraft((d) =>
d.map((g, i) =>
i === gi
? { ...g, bindings: g.bindings.map((b, j) => (j === bi ? { ...b, [field]: value } : b)) }
: g
)
);
const removeBinding = (gi: number, bi: number) =>
setDraft((d) =>
d.map((g, i) => (i === gi ? { ...g, bindings: g.bindings.filter((_, j) => j !== bi) } : g))
);
const addBinding = (gi: number) =>
setDraft((d) =>
d.map((g, i) => (i === gi ? { ...g, bindings: [...g.bindings, { key: "", action: "" }] } : g))
);
const handleSave = useCallback(async () => {
const groups: CcKeybindingGroup[] = draft.map((g) => ({
context: g.context.trim(),
bindings: g.bindings.map((b) => ({ key: b.key.trim(), action: b.action.trim() })),
}));
// Mirror the server-side validation so users get instant, local feedback.
const seen = new Set<string>();
for (const g of groups) {
if (!g.context) return setErr(t("keybindings.errContext"));
if (seen.has(g.context))
return setErr(t("keybindings.errDupContext", { context: g.context }));
seen.add(g.context);
const keys = new Set<string>();
for (const b of g.bindings) {
if (!b.key || !b.action) return setErr(t("keybindings.errEmpty", { context: g.context }));
if (keys.has(b.key))
return setErr(t("keybindings.errDupKey", { key: b.key, context: g.context }));
keys.add(b.key);
}
}
setSaving(true);
setErr(null);
try {
const result = await api.ccConfig.writeKeybindings(groups);
onToast({
kind: "success",
message: result.created
? t("edit.saveSuccessNew")
: t("edit.saveSuccess", { path: result.backupPath || "-" }),
});
setEditing(false);
setDraft([]);
onSaved();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "unknown error";
setErr(msg);
onToast({ kind: "error", message: t("edit.writeError", { message: msg }) });
} finally {
setSaving(false);
}
}, [draft, onSaved, onToast, t]);
if (!data) return <SkeletonRows n={3} />;
const headerBar = (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted flex-wrap">
<FileText className="w-3.5 h-3.5" />
<span className="font-mono truncate flex-1 min-w-0">{data.file}</span>
{data.docs && (
<a
href={data.docs}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-accent hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
{t("keybindings.docsLink")}
</a>
)}
{editing ? (
<div className="inline-flex items-center gap-1.5">
<button
type="button"
onClick={cancelEdit}
disabled={saving}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary disabled:opacity-50"
>
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Save className="w-3 h-3" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
) : (
<button
type="button"
onClick={startEdit}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
)}
</div>
);
// ── Edit mode ────────────────────────────────────────────────────────
if (editing) {
return (
<div className="space-y-3">
{headerBar}
{err && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span>{err}</span>
</div>
)}
{draft.map((g, gi) => (
<div key={gi} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-3 py-2 flex items-center gap-2">
<span className="text-[11px] text-fg-muted flex-shrink-0">
{t("keybindings.context")}
</span>
<input
value={g.context}
onChange={(e) => updateContext(gi, e.target.value)}
placeholder={t("keybindings.contextPlaceholder")}
className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => removeContext(gi)}
title={t("keybindings.removeContext")}
aria-label={t("keybindings.removeContext")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="divide-y divide-border">
{g.bindings.map((b, bi) => (
<div key={bi} className="px-3 py-1.5 flex items-center gap-2">
<input
value={b.key}
onChange={(e) => updateBinding(gi, bi, "key", e.target.value)}
placeholder={t("keybindings.key")}
className="h-7 w-40 flex-shrink-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<span className="text-fg-muted flex-shrink-0">→</span>
<input
value={b.action}
onChange={(e) => updateBinding(gi, bi, "action", e.target.value)}
placeholder={t("keybindings.action")}
className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
<button
type="button"
onClick={() => removeBinding(gi, bi)}
title={t("keybindings.removeBinding")}
aria-label={t("keybindings.removeBinding")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="px-3 py-1.5">
<button
type="button"
onClick={() => addBinding(gi)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("keybindings.addBinding")}
</button>
</div>
</div>
</div>
))}
<button
type="button"
onClick={addContext}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("keybindings.addContext")}
</button>
</div>
);
}
// ── Read-only mode ───────────────────────────────────────────────────
if (!data.exists) {
return (
<div className="space-y-3">
{headerBar}
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("keybindings.missing", { path: data.file })}
</div>
</div>
);
}
const q = search.toLowerCase();
return (
<div className="space-y-3">
{headerBar}
{data.groups.map((g) => {
const filtered = g.bindings.filter(
(b) =>
!q ||
b.key.toLowerCase().includes(q) ||
b.action.toLowerCase().includes(q) ||
g.context.toLowerCase().includes(q)
);
if (filtered.length === 0) return null;
return (
<div key={g.context} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2 text-xs font-medium text-fg-secondary">
{t("keybindings.context")}: <span className="text-fg-primary">{g.context}</span>
<span className="ml-2 text-[10px] text-fg-muted">({filtered.length})</span>
</div>
<div className="divide-y divide-border">
{filtered.map((b) => (
<div key={b.key} className="px-4 py-1.5 flex items-center gap-3">
<kbd className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-surface-3 border border-border text-fg-secondary min-w-20 text-center">
{b.key}
</kbd>
<span className="font-mono text-[11px] text-fg-secondary">{b.action}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
// ── Read-only explainer banner ─────────────────────────────────────────
interface ExplainerBannerProps {
title: string;
body: string;
howTo: string;
commands: { cmd: string; note: string }[];
}
function ExplainerBanner({ title, body, howTo, commands }: ExplainerBannerProps) {
return (
<div className="rounded-lg border border-status-warning/30 bg-status-warning/[0.04] px-4 py-3">
<div className="flex items-start gap-2">
<Lock className="w-4 h-4 text-status-warning flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1 space-y-2">
<div className="text-sm font-medium text-status-warning">{title}</div>
<p className="text-xs text-fg-secondary leading-relaxed">{body}</p>
{commands.length > 0 && (
<div className="pt-1">
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{howTo}
</div>
<div className="space-y-1.5">
{commands.map((c, i) => (
<CommandSnippet key={i} command={c.cmd} label={c.note} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Inline copyable command ────────────────────────────────────────────
function CommandSnippet({ command, label }: { command: string; label?: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
{label && (
<span className="text-[10px] text-fg-muted hidden md:inline truncate">{label}</span>
)}
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
);
}
// ── Auth profiles modal ────────────────────────────────────────────────
//
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
// subagents, memory, plugins, marketplaces and settings all stay exactly where
// they are — that is the whole point of the feature.
//
// The dashboard cannot log a new account in (only `claude auth login` can mint
// tokens), so adding a profile is always "log in, then snapshot what the CLI
// wrote" — hence a save button on the live account rather than an add-account
// flow. There is no name field: the server derives the identifier, and the user
// edits the display label in place by clicking it. That keeps the one string
// the user types free of filename rules, so spaces and diacritics are fine.
// The save button only appears while the live account is unsaved, because one
// account is one profile — a second copy would just report itself active too.
function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: Toast) => void }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
/** 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, `'\\''`)}'`;
}