Files
AgentHub/client/src/pages/CcConfig.tsx
T
nntrivi2001 50ac0f0daf fix(client): copies over plain HTTP, Vietnamese IME Enter, stale replies; react-router 7
- Every Copy goes through copyText: on http://<lan-ip> navigator.clipboard
  is undefined, so the push token (shown once), snapshot links, tool paths and
  hosts copied nothing, silently or with a TypeError.
- Inputs that submit on Enter use isSubmitEnter, so committing an IME
  composition (Vietnamese) no longer saves a half-typed pet name, jumps the
  settings search, or submits a cut-off plugin/MCP/marketplace spec.
- LaneChangesPanel, Workspace features/proof and AiToolConfig drop a reply
  that lands after the next request started: opening file B before file A's
  read returned showed (and let the editor save) A's text under B's header.
- Error-state Retry buttons spin through useSpin; ImageLightbox uses useDialog.
- react-router-dom 6.30 -> 7.18.4 (GHSA-wrjc-x8rr-h8h6, GHSA-337j-9hxr-rhxg);
  screen snapshots gain only Link's data-discover attribute.
- StatusBar test follows v2.8.17's "Settings → AI tools → Profiles" text.
2026-10-22 12:17:18 +07:00

4876 lines
177 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. Its Overview also holds the
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
* The toolbar's Switch profile button links to Settings → AI tools → Profiles
* (`ToolProfilesSettings`), where every AI tool's login profiles live.
* Every card with action buttons (plugins, marketplaces, MCP servers, memory
* files, and the `MdItemList` artifacts) also opens a
* right-click menu that repeats those buttons — same handlers, same
* confirm path — plus Copy name/path.
* @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 { Link, useSearchParams } from "react-router-dom";
import { useSettingsHref } from "../lib/settingsLink";
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,
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 { showToast, type Toast } from "../components/config/ToastNotice";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import { useUnsavedGuard } from "../hooks/useUnsavedGuard";
import { useArmed } from "../hooks/useArmed";
import { FileItemMenu, MdItemList } from "../components/config/MdItemList";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
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,
CcScope,
CcSettingsSource,
CcStatusline,
} from "../lib/api";
import { isSubmitEnter } from "../lib/keys";
/** How an inline editor reports unsaved edits to the page. */
type OnDirty = (id: string, dirty: boolean) => void;
/**
* Report an inline editor's unsaved state to the page (tab switches ask) and
* to the browser (closing the tab asks).
*/
function useReportDirty(id: string, dirty: boolean, onDirty: OnDirty | undefined): void {
useEffect(() => {
onDirty?.(id, dirty);
return () => onDirty?.(id, false);
}, [id, dirty, onDirty]);
useUnsavedGuard(dirty);
}
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;
/** i18n key of the tab's group label (Content · Extensions · Config). */
group?: string;
}
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills", group: "tabGroups.content" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents", group: "tabGroups.content" },
{ key: "commands", icon: Slash, i18nKey: "tabs.commands", group: "tabGroups.content" },
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles", group: "tabGroups.content" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory", group: "tabGroups.content" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins", group: "tabGroups.extensions" },
{ key: "marketplaces", icon: Store, i18nKey: "tabs.marketplaces", group: "tabGroups.extensions" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp", group: "tabGroups.extensions" },
{ key: "hooks", icon: Webhook, i18nKey: "tabs.hooks", group: "tabGroups.extensions" },
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings", group: "tabGroups.config" },
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings", group: "tabGroups.config" },
];
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,
};
/** Which page-state keys each tab displays (their load errors show there). */
const TAB_KEYS: Record<TabKey, (keyof PageState)[]> = {
overview: ["overview"],
skills: ["skills"],
agents: ["agents"],
commands: ["commands"],
outputStyles: ["outputStyles"],
plugins: ["plugins"],
marketplaces: ["marketplaces"],
mcp: ["mcp"],
hooks: ["hooks", "hookScripts"],
keybindings: ["keybindings"],
settings: ["settings", "statusline"],
memory: ["memory"],
};
/** The four lists the All / User / Project toggle actually filters. */
const SCOPED_TABS: TabKey[] = ["skills", "agents", "commands", "outputStyles"];
const SCOPED_KEYS: (keyof PageState)[] = ["skills", "agents", "commands", "outputStyles"];
const ALL_KEYS = Object.keys(EMPTY_STATE) as (keyof PageState)[];
/** One loader per page-state key, so a change refetches only what it touched. */
function loadKey(key: keyof PageState, scope: CcScope): Promise<unknown> {
switch (key) {
case "overview":
return api.ccConfig.overview();
case "skills":
return api.ccConfig.skills(scope).then((r) => r.items);
case "agents":
return api.ccConfig.agents(scope).then((r) => r.items);
case "commands":
return api.ccConfig.commands(scope).then((r) => r.items);
case "outputStyles":
return api.ccConfig.outputStyles(scope).then((r) => r.items);
case "plugins":
return api.ccConfig.plugins();
case "marketplaces":
return api.ccConfig.marketplaces();
case "mcp":
return api.ccConfig.mcp();
case "hooks":
return api.ccConfig.hooks().then((r) => r.items);
case "keybindings":
return api.ccConfig.keybindings();
case "settings":
return api.ccConfig.settings().then((r) => r.items);
case "memory":
return api.ccConfig.memory().then((r) => r.items);
case "statusline":
return api.ccConfig.statusline();
case "hookScripts":
return api.ccConfig.hookScripts();
}
}
/**
* The page-state keys a `cc_config_changed` message touches. The watcher sends
* the changed `paths`, a dashboard mutation its artifact `type`; anything we
* cannot place reloads everything. The overview's counts always reload.
* @param msg The websocket message's data.
*/
export function keysForChange(msg: {
source?: string;
paths?: unknown;
type?: unknown;
}): (keyof PageState)[] {
const out = new Set<keyof PageState>(["overview"]);
const all = () => ALL_KEYS;
if (Array.isArray(msg.paths) && msg.paths.length > 0) {
for (const raw of msg.paths) {
const p = String(raw).replace(/\\/g, "/");
if (/\/skills(\/|$)/.test(p)) out.add("skills");
else if (/\/agents(\/|$)/.test(p)) out.add("agents");
else if (/\/commands(\/|$)/.test(p)) out.add("commands");
else if (/\/output-styles(\/|$)/.test(p)) out.add("outputStyles");
else if (/\/hooks(\/|$)/.test(p)) (out.add("hooks"), out.add("hookScripts"));
else if (/keybindings\.json$/.test(p)) out.add("keybindings");
else if (/settings(\.local)?\.json$/.test(p))
(out.add("settings"), out.add("hooks"), out.add("statusline"), out.add("hookScripts"));
else if (/CLAUDE\.md$|\/memory\//.test(p)) out.add("memory");
else if (/\/plugins(\/|$)/.test(p)) (out.add("plugins"), out.add("marketplaces"));
else if (/\.claude\.json$/.test(p))
(out.add("mcp"), out.add("marketplaces"), out.add("plugins"));
else return all();
}
return [...out];
}
switch (msg.type) {
case "skills":
case "agents":
case "commands":
case "memory":
case "keybindings":
out.add(msg.type);
return [...out];
case "output-styles":
out.add("outputStyles");
return [...out];
case "auto-memory":
out.add("memory");
return [...out];
case "settings":
return [...out, "settings", "hooks", "statusline", "hookScripts"];
case "profile":
return [...out];
default:
return all();
}
}
/** A project root inside Claude Code's plugin cache is replaced on every
* plugin update, so nothing written there survives. */
export function isPluginCachePath(p: string | null | undefined): boolean {
return !!p && /\/\.claude\/plugins\/cache(\/|$)/.test(p.replace(/\\/g, "/"));
}
export function CcConfig() {
const { t } = useTranslation("ccConfig");
// The tab lives in the URL (`?tab=`, replaced) so search results, deep links
// and Back land on it.
const [params, setParams] = useSearchParams();
const rawTab = params.get("tab");
const urlTab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview";
// Editors inside a tab report unsaved edits here (by id), so leaving the tab
// asks first instead of silently dropping the draft.
const dirtyIds = useRef(new Set<string>());
const setDirty = useCallback((id: string, d: boolean) => {
if (d) dirtyIds.current.add(id);
else dirtyIds.current.delete(id);
}, []);
const writeTab = useCallback(
(k: TabKey) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (k === "overview") next.delete("tab");
else next.set("tab", k);
return next;
},
{ replace: true }
),
[setParams]
);
// The tab shown follows the URL, but only once an unsaved edit is let go:
// the URL also changes from outside the tab bar (a settings search result,
// Back), and those must ask too. Kept, the URL goes back to the shown tab.
const [tab, setShownTab] = useState<TabKey>(urlTab);
useEffect(() => {
if (urlTab === tab) return;
let live = true;
void (async () => {
if (dirtyIds.current.size > 0 && !(await confirmDiscard())) {
if (live) writeTab(tab);
return;
}
if (!live) return;
dirtyIds.current.clear();
setShownTab(urlTab);
})();
return () => {
live = false;
};
}, [urlTab, tab, writeTab]);
const setTab = useCallback((k: TabKey) => writeTab(k), [writeTab]);
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
const [scope, setScope] = useState<CcScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<Record<keyof PageState, string>>>({});
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
// One search box per tab: a filter typed on Skills must not hide Plugins.
const [searches, setSearches] = useState<Partial<Record<TabKey, string>>>({});
const search = searches[tab] ?? "";
const setSearch = (v: string) => setSearches((s) => ({ ...s, [tab]: v }));
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 setToast = showToast;
const [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const scopeRef = useRef(scope);
scopeRef.current = scope;
/** Reload some page-state keys; one failing endpoint never blanks the rest. */
const fetchKeys = useCallback(async (keys: (keyof PageState)[]) => {
setLoading(true);
const results = await Promise.allSettled(keys.map((k) => loadKey(k, scopeRef.current)));
const patch: Partial<PageState> = {};
const errs: Partial<Record<keyof PageState, string>> = {};
results.forEach((r, i) => {
const k = keys[i]!;
if (r.status === "fulfilled") (patch as Record<string, unknown>)[k] = r.value;
else errs[k] = r.reason instanceof Error ? r.reason.message : "unknown error";
});
setData((d) => ({ ...d, ...patch }));
setErrors((e) => {
const next = { ...e };
for (const k of keys) delete next[k];
return { ...next, ...errs };
});
setLastUpdated(new Date());
setLoading(false);
}, []);
const fetchAll = useCallback(() => fetchKeys(ALL_KEYS), [fetchKeys]);
useEffect(() => {
void fetchAll();
}, [fetchAll]);
// A scope change reloads only the four lists it filters.
const firstScope = useRef(true);
useEffect(() => {
if (firstScope.current) {
firstScope.current = false;
return;
}
void fetchKeys(SCOPED_KEYS);
}, [scope, fetchKeys]);
// Live updates - refetch what the server says changed (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);
const pendingKeys = useRef(new Set<keyof PageState>());
useEffect(() => {
return eventBus.subscribe((msg) => {
if (msg.type !== "cc_config_changed") return;
for (const k of keysForChange((msg.data ?? {}) as Parameters<typeof keysForChange>[0]))
pendingKeys.current.add(k);
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
refetchTimerRef.current = setTimeout(() => {
refetchTimerRef.current = null;
const keys = [...pendingKeys.current];
pendingKeys.current.clear();
void fetchKeys(keys);
}, 250);
});
}, [fetchKeys]);
useEffect(() => {
return () => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
};
}, []);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const projectRoot = data.overview?.roots.projectRoot ?? null;
// Project files under the plugin cache are wiped by the next plugin update.
const projectDisabled = isPluginCachePath(projectRoot);
useEffect(() => {
if (projectDisabled && scope === "project") setScope("all");
}, [projectDisabled, scope]);
const tabErrors = TAB_KEYS[tab].map((k) => errors[k]).filter(Boolean) as string[];
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" && !projectDisabled ? "project" : "user");
setEditor({ mode: "create", type, defaultScope, template });
},
[scope, t, projectDisabled]
);
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}
onRefresh={() => {
void fetchAll();
setAgenthubKey((k) => k + 1);
}}
onOpenBackups={() => setBackupsOpen(true)}
wsConnected={wsConnected}
/>
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
<div
id="cc-tabpanel"
role="tabpanel"
aria-labelledby={`cc-tab-${tab}`}
className="rounded-xl border border-border bg-surface-1"
>
{tabErrors.length > 0 && (
<div
role="alert"
className="m-4 mb-0 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: tabErrors.join(" · ") })}</span>
</div>
)}
{tab !== "overview" && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
<div className="flex flex-1 min-w-[12rem] items-center gap-2 rounded-md px-1 focus-within:ring-2 focus-within:ring-accent/40">
<Search className="w-4 h-4 text-fg-muted" aria-hidden />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
aria-label={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>
)}
</div>
{SCOPED_TABS.includes(tab) && (
<ScopeToggle
value={scope}
onChange={setScope}
projectRoot={projectRoot}
projectDisabled={projectDisabled}
/>
)}
{isMutable(tab) && tab !== "memory" && (
<button
type="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
type="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}
onDirty={setDirty}
/>
</div>
</div>
{/* AgentHub's own Claude Code settings follow the overview, outside the
list's search box and scope. */}
{tab === "overview" && (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
)}
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && (
<EditorModal
state={editor}
fetchContent={(p) => api.ccConfig.file(p)}
onClose={() => setEditor(null)}
onSave={handleSave}
projectDisabled={projectDisabled}
/>
)}
{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}
/>
)}
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
</div>
);
}
// ── Header ────────────────────────────────────────────────────────────
interface HeaderProps {
loading: boolean;
lastUpdated: Date | null;
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
}
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
const { t } = useTranslation("ccConfig");
const settingsHref = useSettingsHref();
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-2 min-w-0 flex-1">
{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 flex-shrink-0">
<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 flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</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">
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
<Link
to={settingsHref("agents#tool-profiles")}
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")}
</Link>
<button
type="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
type="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>
);
}
/** All / User / Project for the four scoped lists, naming the folder "Project"
* resolves to — and refusing it when that folder is the plugin cache. */
function ScopeToggle({
value,
onChange,
projectRoot,
projectDisabled,
}: {
value: CcScope;
onChange: (s: CcScope) => void;
projectRoot: string | null;
projectDisabled: boolean;
}) {
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="flex items-center gap-2 min-w-0">
<div
role="group"
aria-label={t("common.scopeLabel")}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5 flex-shrink-0"
>
{opts.map((o) => {
const disabled = o.v === "project" && projectDisabled;
return (
<button
key={o.v}
type="button"
aria-pressed={value === o.v}
disabled={disabled}
title={
disabled
? t("scope.projectInCache")
: o.v === "project" && projectRoot
? projectRoot
: undefined
}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{o.label}
</button>
);
})}
</div>
{projectRoot && (
<span
className={`truncate font-mono text-[10px] ${projectDisabled ? "text-status-warning" : "text-fg-muted"}`}
title={projectDisabled ? t("scope.projectInCache") : projectRoot}
>
{t("scope.projectIs", { path: projectRoot })}
</span>
)}
</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, group }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
group: group ? t(group) : undefined,
}))}
current={current}
onSelect={onSelect}
testIdPrefix="cc-tab"
panelId="cc-tabpanel"
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;
/** An editor reports unsaved edits (by id), so a tab switch asks first. */
onDirty: OnDirty;
}
function TabPanel({
tab,
data,
search,
onOpenFile,
onEdit,
onDelete,
onCreateMemory,
onEditAuto,
onDeleteAuto,
onCreateAuto,
onChanged,
onToast,
onDirty,
}: 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}
onDirty={onDirty}
/>
);
case "hooks":
return (
<HooksPanel
sources={data.hooks}
scripts={data.hookScripts}
search={search}
onOpen={onOpenFile}
onToast={onToast}
onChanged={onChanged}
onDirty={onDirty}
/>
);
case "keybindings":
return (
<KeybindingsPanel
data={data.keybindings}
search={search}
onSaved={onChanged}
onToast={onToast}
onDirty={onDirty}
/>
);
case "settings":
return (
<SettingsPanel
sources={data.settings}
statusline={data.statusline}
onOpen={onOpenFile}
onToast={onToast}
onChanged={onChanged}
onDirty={onDirty}
/>
);
case "memory":
return (
<MemoryPanel
items={data.memory}
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
onCreate={onCreateMemory}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onCreateAuto={onCreateAuto}
/>
);
default:
return null;
}
}
// ── Overview ──────────────────────────────────────────────────────────
// Tone palette - each tone is { iconBg, iconText, border, accentBar }.
// Used by both root rows and summary stat tiles for a consistent color story.
type Tone =
| "sky"
| "emerald"
| "violet"
| "amber"
| "fuchsia"
| "cyan"
| "pink"
| "indigo"
| "orange"
| "teal"
| "slate"
| "rose";
const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring: string }> = {
sky: {
iconBg: "bg-sky-500/10",
iconText: "text-sky-700 dark:text-sky-300",
bar: "bg-sky-500/40",
ring: "ring-sky-500/20",
},
emerald: {
iconBg: "bg-status-success/10",
iconText: "text-status-success",
bar: "bg-status-success/40",
ring: "ring-status-success/20",
},
violet: {
iconBg: "bg-violet-500/10",
iconText: "text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/40",
ring: "ring-violet-500/20",
},
amber: {
iconBg: "bg-status-warning/10",
iconText: "text-status-warning",
bar: "bg-status-warning/40",
ring: "ring-status-warning/20",
},
fuchsia: {
iconBg: "bg-fuchsia-500/10",
iconText: "text-fuchsia-700 dark:text-fuchsia-300",
bar: "bg-fuchsia-500/40",
ring: "ring-fuchsia-500/20",
},
cyan: {
iconBg: "bg-cyan-500/10",
iconText: "text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/40",
ring: "ring-cyan-500/20",
},
pink: {
iconBg: "bg-pink-500/10",
iconText: "text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/40",
ring: "ring-pink-500/20",
},
indigo: {
iconBg: "bg-indigo-500/10",
iconText: "text-indigo-700 dark:text-indigo-300",
bar: "bg-indigo-500/40",
ring: "ring-indigo-500/20",
},
orange: {
iconBg: "bg-orange-500/10",
iconText: "text-orange-700 dark:text-orange-300",
bar: "bg-orange-500/40",
ring: "ring-orange-500/20",
},
teal: {
iconBg: "bg-teal-500/10",
iconText: "text-teal-700 dark:text-teal-300",
bar: "bg-teal-500/40",
ring: "ring-teal-500/20",
},
slate: {
iconBg: "bg-surface-4/10",
iconText: "text-fg-secondary",
bar: "bg-surface-4/40",
ring: "ring-border-light/20",
},
rose: {
iconBg: "bg-rose-500/10",
iconText: "text-rose-700 dark:text-rose-300",
bar: "bg-rose-500/40",
ring: "ring-rose-500/20",
},
};
function OverviewPanel({ overview }: { overview: CcOverview | null }) {
const { t } = useTranslation("ccConfig");
if (!overview) return <SkeletonRows n={4} />;
const { roots, counts } = overview;
return (
<div className="space-y-5">
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="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);
const availableCtx = useContextMenu<CcAvailablePlugin>();
const install = (a: CcAvailablePlugin) =>
void run(
a.pluginId,
() => api.ccConfig.installPlugin(a.pluginId),
t("manage.installed", { name: a.pluginId })
);
// 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 (isSubmitEnter(e)) 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}
onContextMenu={(e) => availableCtx.open(e, a)}
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={() => install(a)}>
{busy === a.pluginId ? t("manage.working") : t("manage.install")}
</ActionButton>
</div>
))}
</div>
{availableCtx.menu && (
<ContextMenu
x={availableCtx.menu.x}
y={availableCtx.menu.y}
onClose={availableCtx.close}
testId="available-plugin-menu"
>
<MenuItem
disabled={busy === availableCtx.menu.item.pluginId}
onSelect={() => {
availableCtx.close();
install(availableCtx.menu!.item);
}}
>
{t("manage.install")}
</MenuItem>
<MenuItem
onSelect={() => {
availableCtx.close();
void copyText(availableCtx.menu!.item.pluginId);
}}
>
{t("common.copyName")}
</MenuItem>
</ContextMenu>
)}
</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.
// useArmed: the armed state disarms by itself after 3 s, so a stray click
// minutes later can never complete the uninstall.
const uninstallArm = useArmed();
const confirmUninstall = uninstallArm.isArmed("uninstall");
const setConfirmUninstall = (on: boolean) =>
on ? uninstallArm.arm("uninstall") : uninstallArm.disarm();
// 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 ctx = useContextMenu<true>();
const toggle = () =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, p.enabled === false ? "enable" : "disable"),
t(p.enabled === false ? "manage.enabled" : "manage.disabled", { name: p.key })
);
const update = () =>
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 })
);
const uninstall = () => {
if (!confirmUninstall) {
setConfirmUninstall(true);
return;
}
setConfirmUninstall(false);
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "uninstall"),
t("manage.uninstalled", { name: p.key })
);
};
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
data-testid={`plugin-card-${p.key}`}
onContextMenu={(e) => ctx.open(e, true)}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="plugin-menu">
<MenuItem
disabled={busy === p.key}
onSelect={() => {
ctx.close();
toggle();
}}
>
{p.enabled === false ? t("manage.enable") : t("manage.disable")}
</MenuItem>
<MenuItem
disabled={busy === p.key}
onSelect={() => {
ctx.close();
update();
}}
>
{t("manage.update")}
</MenuItem>
<MenuItem
onSelect={() => {
ctx.close();
void copyText(p.key);
}}
>
{t("common.copyName")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
disabled={busy === p.key}
testId="plugin-menu-uninstall"
// Two clicks here too: the first arms (the card's button shows
// it), only the second uninstalls.
onSelect={() => {
if (confirmUninstall) ctx.close();
uninstall();
}}
>
{confirmUninstall ? t("manage.confirmUninstall") : t("manage.uninstall")}
</MenuItem>
</ContextMenu>
)}
<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={toggle}>
{t("manage.enable")}
</ActionButton>
) : (
<ActionButton icon={CircleSlash} disabled={busy === p.key} onClick={toggle}>
{t("manage.disable")}
</ActionButton>
)}
<ActionButton icon={RefreshCw} disabled={busy === p.key} onClick={update}>
{t("manage.update")}
</ActionButton>
<ActionButton icon={Trash2} danger disabled={busy === p.key} onClick={uninstall}>
{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,
onDirty,
}: {
data: CcMcpResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
onDirty?: OnDirty;
}) {
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("");
useReportDirty("mcp:add", !!(name.trim() || commandLine.trim() || url.trim()), onDirty);
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="select h-8 py-0 text-[12px]"
>
<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"
aria-pressed={transport === k}
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 (isSubmitEnter(e)) 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 (isSubmitEnter(e)) 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 removeArm = useArmed();
const confirmRemove = removeArm.isArmed("remove");
const setConfirmRemove = (on: boolean) => (on ? removeArm.arm("remove") : removeArm.disarm());
const ctx = useContextMenu<true>();
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;
const remove = () => {
if (!confirmRemove) {
setConfirmRemove(true);
return;
}
setConfirmRemove(false);
void run(
busyId,
() => api.ccConfig.mcpRemove(server.name, scope),
t("manage.mcpRemoved", { name: server.name })
);
};
return (
<div
onContextMenu={(e) => ctx.open(e, true)}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="mcp-menu">
<MenuItem
onSelect={() => {
ctx.close();
void copyText(server.name);
}}
>
{t("common.copyName")}
</MenuItem>
{removable && (
<>
<MenuSeparator />
<MenuItem
danger
disabled={busy === busyId}
// Two clicks, like the card's Remove button.
onSelect={() => {
if (confirmRemove) ctx.close();
remove();
}}
>
{confirmRemove ? t("manage.confirmRemove") : t("manage.remove")}
</MenuItem>
</>
)}
</ContextMenu>
)}
<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={remove}>
{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,
onDirty,
}: {
sources: CcHookSource[] | null;
scripts: CcHookScripts | null;
search: string;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
onDirty?: OnDirty;
}) {
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}
onDirty={onDirty}
/>
))}
{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,
onDirty,
}: {
source: CcHookSource;
search: string;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
onDirty?: OnDirty;
}) {
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);
// Snapshot taken at Edit: the events this editor owns and the file's hooks
// as they were. Save unsets only events from THIS snapshot, so an event
// written meanwhile (a session, a terminal) is never deleted.
const base = useRef<{ events: string[]; hooks: string; draft: string } | null>(null);
const dirty = draft !== null && JSON.stringify(draft) !== base.current?.draft;
useReportDirty(`hooks:${src.scope}`, dirty, onDirty);
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);
const next = editableEvents.map(([event, entries]) => ({
event,
entries: entries.map((e) => ({ ...e })),
}));
base.current = {
events: editableEvents.map(([event]) => event),
hooks: JSON.stringify(src.hooks),
draft: JSON.stringify(next),
};
setDraft(next);
};
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;
}
// An event the editor could not parse is left on disk untouched; a new
// row with its name would replace those entries.
if (readOnlyEvents.some(([e]) => e === name)) {
setErr(t("manage.hookReadOnlyEvent", { 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 — or appeared after Edit — is never touched.
const unset = (base.current?.events ?? [])
.filter((event) => !seen.has(event))
.map((event) => `hooks.${event}`);
if (
base.current &&
JSON.stringify(src.hooks) !== base.current.hooks &&
!(await confirmDialog({
title: t("edit.changedOnDiskTitle"),
message: t("manage.hooksChangedBody"),
confirmLabel: t("edit.overwrite"),
}))
)
return;
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={async () => {
if (dirty && !(await confirmDiscard())) return;
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",
keys: [
{ key: "model", label: "Model" },
{ key: "effortLevel", label: "Effort level" },
{ key: "alwaysThinkingEnabled", label: "Always thinking" },
],
},
{
title: "output",
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",
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[] }) {
const { t } = useTranslation("ccConfig");
// Build effective map: { key → { value, scope } }. Sources arrive ordered
// user → project → project-local, so a later hit overrides an earlier one.
const effective = new Map<string, { value: unknown; scope: CcSettingsSource["scope"] }>();
for (const src of sources) {
if (!src.exists || !src.data || typeof src.data !== "object") continue;
const data = src.data as Record<string, unknown>;
for (const group of CONFIG_OPTION_GROUPS) {
for (const { key } of group.keys) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
effective.set(key, { value: data[key], scope: src.scope });
}
}
}
}
const setCount = effective.size;
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<SettingsIcon className="w-3.5 h-3.5 text-violet-700/80 dark:text-violet-300/80" />
<span className="text-sm font-medium text-fg-primary">{t("currentConfig.title")}</span>
<span className="text-[11px] text-fg-muted ml-auto">
{t("currentConfig.setCount", { count: setCount })}
</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">
{t(`currentConfig.groups.${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" title={key}>
{t(`currentConfig.keys.${key}`, { defaultValue: label })}
</div>
<div className="min-w-0">
{hit ? (
<SettingsValue value={hit.value} />
) : (
<span className="text-[10px] text-fg-muted italic">
{t("currentConfig.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,
onDirty,
}: {
sources: CcSettingsSource[] | null;
statusline: CcStatusline | null;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
onDirty?: OnDirty;
}) {
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}
onDirty={onDirty}
/>
))}
</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;
}
}
/** `origKey` is the key the row was opened with — a renamed row must still be
* sent, or its value would be dropped with the unset old key. */
type SettingsDraftRow = {
key: string;
origKey: string;
text: string;
original: string;
isNew: boolean;
};
function SettingsBlock({
source,
onOpen,
onToast,
onChanged,
onDirty,
}: {
source: CcSettingsSource;
onOpen: (p: string) => void;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
onDirty?: OnDirty;
}) {
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);
// Snapshot taken at Edit: the keys this editor owns and the file's data as it
// was. Save unsets only keys from THIS snapshot, so a key written meanwhile
// (the live CLI rewrites settings.json) is never deleted.
const base = useRef<{ keys: string[]; data: string; draft: string } | null>(null);
const dirty = draft !== null && JSON.stringify(draft) !== base.current?.draft;
useReportDirty(`settings:${source.scope}`, dirty, onDirty);
const data = (source.data as Record<string, unknown> | null | undefined) || {};
// Redacted keys stay out of the draft and render read-only below the editor;
// `save` also refuses a new or renamed row that names one, since `set` would
// replace the real secret (e.g. the whole `env` object) with the typed value.
const editableKeys = Object.entries(data).filter(([, v]) => !isRedacted(v));
const redactedKeys = Object.entries(data).filter(([, v]) => isRedacted(v));
const startEdit = () => {
setErr(null);
const next = editableKeys.map(([key, value]) => {
const text = JSON.stringify(value, null, 2);
return { key, origKey: key, text, original: text, isNew: false };
});
base.current = {
keys: editableKeys.map(([key]) => key),
data: JSON.stringify(data),
draft: JSON.stringify(next),
};
setDraft(next);
};
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;
}
if (redactedKeys.some(([k]) => k === key)) {
setErr(t("manage.settingsRedactedKey", { key }));
return;
}
seen.add(key);
// Only send what actually changed — an untouched key is left alone.
if (!row.isNew && key === row.origKey && row.text === row.original) continue;
try {
set[key] = JSON.parse(row.text);
} catch {
setErr(t("manage.settingsBadJson", { key }));
return;
}
}
const unset = (base.current?.keys ?? []).filter((key) => !seen.has(key));
if (Object.keys(set).length === 0 && unset.length === 0) {
setDraft(null);
return;
}
if (
base.current &&
JSON.stringify(data) !== base.current.data &&
!(await confirmDialog({
title: t("edit.changedOnDiskTitle"),
message: t("manage.settingsChangedBody"),
confirmLabel: t("edit.overwrite"),
}))
)
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
type="button"
aria-pressed={showRaw}
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 ? t("settings.structured") : t("settings.rawJson")}
</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={async () => {
if (dirty && !(await confirmDiscard())) return;
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: "", origKey: "", text: '""', original: "", isNew: true },
])
}
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("manage.settingsAddKey")}
</button>
<p className="text-[10px] text-fg-muted leading-relaxed">{t("manage.settingsHelp")}</p>
{redactedKeys.length > 0 && (
<p className="text-[10px] text-fg-muted leading-relaxed inline-flex items-center gap-1.5">
<Lock className="w-3 h-3 flex-shrink-0" />
{t("manage.settingsRedactedKeys", {
keys: redactedKeys.map(([k]) => k).join(", "),
})}
</p>
)}
</div>
) : (
source.exists &&
(showRaw ? (
<pre className="p-3 text-[11px] font-mono text-fg-secondary overflow-auto max-h-96">
{JSON.stringify(source.data, null, 2)}
</pre>
) : (
<SettingsKeyValueList data={source.data as Record<string, unknown> | null | undefined} />
))
)}
</div>
);
}
function SettingsKeyValueList({ data }: { data: Record<string, unknown> | null | undefined }) {
if (!data || typeof data !== "object") {
return <div className="p-3 text-xs text-fg-muted">-</div>;
}
const entries = Object.entries(data);
if (entries.length === 0) {
return <div className="p-3 text-xs text-fg-muted">{}</div>;
}
return (
<div className="divide-y divide-border">
{entries.map(([k, v]) => (
<div
key={k}
className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-1 md:gap-3 items-start"
>
<div className="font-mono text-[11px] text-fg-secondary truncate">{k}</div>
<div className="min-w-0">
<SettingsValue value={v} />
</div>
</div>
))}
</div>
);
}
function SettingsValue({ value }: { value: unknown }) {
if (value === null || value === undefined)
return <span className="text-[11px] text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
return (
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
value
? "bg-status-success/10 text-status-success border-status-success/30"
: "bg-surface-4/10 text-fg-secondary border-border-light/30"
}`}
>
{value ? "true" : "false"}
</span>
);
}
if (typeof value === "number") {
return <span className="font-mono text-[11px] text-fg-secondary">{value}</span>;
}
if (typeof value === "string") {
return <span className="font-mono text-[11px] text-fg-secondary break-all">{value}</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-[11px] text-fg-muted">[]</span>;
return (
<div className="flex flex-wrap gap-1">
{value.map((item, i) => (
<span
key={i}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border break-all"
>
{typeof item === "object" ? JSON.stringify(item) : String(item)}
</span>
))}
</div>
);
}
// object
const obj = value as Record<string, unknown>;
return (
<div className="space-y-0.5">
{Object.entries(obj).map(([k, v]) => (
<div key={k} className="font-mono text-[11px]">
<span className="text-fg-muted">{k}:</span>{" "}
<span className="text-fg-secondary break-all">
{typeof v === "object" ? JSON.stringify(v) : String(v)}
</span>
</div>
))}
</div>
);
}
// ── Memory ────────────────────────────────────────────────────────────
interface MemoryPanelProps {
items: CcMemoryItem[] | null;
search: string;
onOpen: (p: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
onCreate: (scope: "user" | "project") => void;
onEditAuto: (item: CcMemoryItem) => void;
onDeleteAuto: (item: CcMemoryItem) => void;
onCreateAuto: (project: string) => void;
}
// Strip a leading markdown heading then take a short snippet — used when a
// per-fact memory file has no frontmatter description.
function memoryDescription(m: CcMemoryItem): string {
return (
m.frontmatter?.description ||
m.preview
.replace(/^#+\s.*\n/, "")
.trim()
.slice(0, 200)
);
}
// Reduce a markdown link target (as written inside MEMORY.md, e.g.
// `./feedback_x.md#section` or `feedback_x.md`) to the bare filename we can
// match against a fact file's `name`. Tolerant of URL-encoding and anchors.
function normalizeMemoryTarget(target: string): string {
let v = target.trim();
const hash = v.indexOf("#");
if (hash >= 0) v = v.slice(0, hash);
try {
v = decodeURIComponent(v);
} catch {
/* leave as-is when not valid percent-encoding */
}
const slash = v.lastIndexOf("/");
if (slash >= 0) v = v.slice(slash + 1);
return v.trim();
}
// Render a MEMORY.md preview with its `[label](target.md)` markdown links
// turned into clickable buttons. Everything else is emitted verbatim so the
// surrounding <pre> keeps the original index layout. Clicking a link asks the
// parent to jump to (scroll + highlight) the matching fact file.
function renderMemoryIndex(
preview: string,
onJump: (target: string) => void,
jumpTitle: string
): React.ReactNode {
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
const lines = preview.split("\n");
return lines.map((line, li) => {
const parts: React.ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
linkRe.lastIndex = 0;
while ((m = linkRe.exec(line)) !== null) {
const full = m[0];
const label = m[1] ?? "";
const capturedTarget = m[2] ?? "";
if (m.index > last) parts.push(line.slice(last, m.index));
parts.push(
<button
key={`${li}-${m.index}`}
type="button"
onClick={() => onJump(capturedTarget)}
title={`${jumpTitle}: ${normalizeMemoryTarget(capturedTarget)}`}
className="text-teal-700 dark:text-teal-300 hover:text-teal-800 dark:hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
>
{label}
</button>
);
last = m.index + full.length;
}
if (last < line.length) parts.push(line.slice(last));
return (
<span key={li}>
{parts.length ? parts : line}
{li < lines.length - 1 ? "\n" : null}
</span>
);
});
}
function MemoryPanel({
items,
search,
onOpen,
onEdit,
onDelete,
onCreate,
onEditAuto,
onDeleteAuto,
onCreateAuto,
}: MemoryPanelProps) {
const { t } = useTranslation("ccConfig");
const ctx = useContextMenu<CcMemoryItem & { scope: "user" | "project" }>();
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 */}
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => {
const m = ctx.menu!.item;
onEdit("memory", { scope: m.scope, name: "", filePath: m.file });
}}
onDelete={() => {
const m = ctx.menu!.item;
onDelete("memory", m.scope, undefined, m.file);
}}
/>
)}
{primary.map((m) => (
<div
key={m.scope}
onContextMenu={(e) => ctx.open(e, m)}
className="rounded-lg border border-border bg-surface-2"
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={m.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
{m.file}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(m.size)}</span>
<button
onClick={() => onOpen(m.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
<button
onClick={() => onEdit("memory", { scope: m.scope, name: "", filePath: m.file })}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
<button
onClick={() => onDelete("memory", m.scope, undefined, m.file)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
>
<Trash2 className="w-3 h-3" />
{t("edit.deleteButton")}
</button>
</div>
<pre className="p-3 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-72 overflow-auto">
{m.preview}
{m.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
</div>
))}
{showMissing &&
missingScopes.map((s) => (
<div
key={`missing-${s}`}
className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-4 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<ScopeBadge scope={s} />
<span className="text-xs text-fg-muted">{t("memory.missing")}</span>
</div>
<button
onClick={() => onCreate(s)}
className="text-[11px] font-medium px-2.5 py-1 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
</div>
))}
{/* Per-project file-based memory (~/.claude/projects/<slug>/memory/) */}
{totalAuto > 0 && (
<section className="pt-1">
<div className="flex items-center gap-2 mb-1">
<BookOpen className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300" />
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-secondary">
{t("memory.autoTitle")}
</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-semibold bg-surface-3 text-fg-secondary">
{q ? `${autoFiltered.length}/${totalAuto}` : totalAuto}
</span>
</div>
<p className="text-[11px] text-fg-muted mb-2.5 leading-relaxed">
{t("memory.autoSubtitle")}
</p>
{groups.length === 0 ? (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("memory.noMatches")}
</div>
) : (
<div className="space-y-2">
{groups.map(([project, files]) => (
<MemoryProjectGroup
key={project}
project={project}
files={files}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onCreateAuto={onCreateAuto}
defaultOpen={!!q || groups.length === 1}
/>
))}
</div>
)}
</section>
)}
</div>
);
}
interface MemoryProjectGroupProps {
project: string;
files: CcMemoryItem[];
onOpen: (p: string) => void;
onEditAuto: (item: CcMemoryItem) => void;
onDeleteAuto: (item: CcMemoryItem) => void;
onCreateAuto: (project: string) => void;
defaultOpen: boolean;
}
function MemoryProjectGroup({
project,
files,
onOpen,
onEditAuto,
onDeleteAuto,
onCreateAuto,
defaultOpen,
}: MemoryProjectGroupProps) {
const { t } = useTranslation("ccConfig");
const [open, setOpen] = useState(defaultOpen);
// Re-sync when the search-driven default flips (expand on search, collapse
// when cleared). User toggles within a stable search state are preserved.
useEffect(() => {
setOpen(defaultOpen);
}, [defaultOpen]);
const indexFiles = files.filter((f) => f.isIndex);
const factFiles = files.filter((f) => !f.isIndex);
const ctx = useContextMenu<CcMemoryItem>();
// 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">
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => onEditAuto(ctx.menu!.item)}
onDelete={() => onDeleteAuto(ctx.menu!.item)}
/>
)}
<div className="flex items-center gap-1 pr-2 hover:bg-surface-3 transition-colors">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-2.5 text-left flex-1 min-w-0"
>
<ChevronDown
className={`w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform ${
open ? "" : "-rotate-90"
}`}
/>
<FolderTree className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-xs text-fg-secondary truncate flex-1 min-w-0">
{project}
</span>
<span className="text-[10px] text-fg-muted flex-shrink-0">
{t("memory.fileCount", { count: files.length })}
</span>
</button>
<button
onClick={() => onCreateAuto(project)}
title={t("memory.newFile")}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1 flex-shrink-0"
>
<Plus className="w-2.5 h-2.5" />
{t("memory.newFile")}
</button>
</div>
{open && (
<div className="border-t border-border p-2.5 space-y-2.5">
{indexFiles.length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5 px-1">
{t("memory.indexFiles")}
</div>
<div className="space-y-1.5">
{indexFiles.map((m) => (
<MemoryIndexCard
key={m.file}
item={m}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onJump={handleJump}
onMenu={ctx.open}
/>
))}
</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}
onMenu={ctx.open}
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,
onMenu,
}: MemoryAutoItemProps & {
onJump: (target: string) => void;
onMenu: (e: React.MouseEvent, item: CcMemoryItem) => void;
}) {
const { t } = useTranslation("ccConfig");
return (
<div
onContextMenu={(e) => onMenu(e, item)}
className="rounded-md border border-teal-500/20 bg-teal-500/5"
>
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
<BookOpen className="w-3 h-3 text-teal-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
{item.name}
</span>
<span className="text-[10px] text-fg-muted">{formatBytes(item.size)}</span>
<MemoryAutoActions
item={item}
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
/>
</div>
<pre className="px-3 py-2 text-[10.5px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-40 overflow-auto">
{renderMemoryIndex(item.preview, onJump, t("memory.jumpTo"))}
{item.truncated && <span className="text-fg-muted italic">{"\n…"}</span>}
</pre>
</div>
);
}
function MemoryFactRow({
item,
onOpen,
onEditAuto,
onDeleteAuto,
onMenu,
highlighted,
rowRef,
}: MemoryAutoItemProps & {
onMenu: (e: React.MouseEvent, item: CcMemoryItem) => void;
highlighted?: boolean;
rowRef?: (el: HTMLDivElement | null) => void;
}) {
const desc = memoryDescription(item);
return (
<div
ref={rowRef}
onContextMenu={(e) => onMenu(e, item)}
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 removeArm = useArmed();
const confirmRemove = removeArm.armed;
const setConfirmRemove = (name: string | null) =>
name ? removeArm.arm(name) : removeArm.disarm();
const ctx = useContextMenu<CcMarketplacesResponse["items"][number]>();
const update = (name: string) =>
void run(
name,
() => api.ccConfig.marketplaceAction(name, "update"),
t("manage.marketplaceUpdated", { name })
);
const remove = (name: string) => {
if (confirmRemove !== name) {
setConfirmRemove(name);
return;
}
setConfirmRemove(null);
void run(
name,
() => api.ccConfig.marketplaceAction(name, "remove"),
t("manage.marketplaceRemoved", { name })
);
};
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 (isSubmitEnter(e)) 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}
onContextMenu={(e) => ctx.open(e, m)}
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={() => update(m.name)}
>
{t("manage.update")}
</ActionButton>
<ActionButton
icon={Trash2}
danger
disabled={busy === m.name}
onClick={() => remove(m.name)}
>
{busy === m.name
? t("manage.working")
: confirmRemove === m.name
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
</div>
</div>
))
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="marketplace-menu">
<MenuItem
disabled={busy === ctx.menu.item.name}
onSelect={() => {
ctx.close();
update(ctx.menu!.item.name);
}}
>
{t("manage.update")}
</MenuItem>
<MenuItem
onSelect={() => {
ctx.close();
void copyText(ctx.menu!.item.name);
}}
>
{t("common.copyName")}
</MenuItem>
{ctx.menu.item.installLocation && (
<MenuItem
onSelect={() => {
ctx.close();
void copyText(ctx.menu!.item.installLocation!);
}}
>
{t("common.copyPath")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem
danger
disabled={busy === ctx.menu.item.name}
// Two clicks, like the card's Remove button.
onSelect={() => {
if (confirmRemove === ctx.menu!.item.name) ctx.close();
remove(ctx.menu!.item.name);
}}
>
{confirmRemove === ctx.menu.item.name ? t("manage.confirmRemove") : t("manage.remove")}
</MenuItem>
</ContextMenu>
)}
</div>
);
}
// ── Keybindings ───────────────────────────────────────────────────────
function KeybindingsPanel({
data,
search,
onSaved,
onToast,
onDirty,
}: {
data: CcKeybindings | null;
search: string;
onSaved: () => void;
onToast: (toast: NonNullable<Toast>) => void;
onDirty?: OnDirty;
}) {
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 opened = useRef("");
useReportDirty("keybindings", editing && JSON.stringify(draft) !== opened.current, onDirty);
const startEdit = useCallback(() => {
const groups = data?.groups ?? [];
// Deep clone so edits never mutate the fetched data.
const next = groups.map((g) => ({
context: g.context,
bindings: g.bindings.map((b) => ({ ...b })),
}));
opened.current = JSON.stringify(next);
setDraft(next);
setErr(null);
setEditing(true);
}, [data]);
const cancelEdit = useCallback(async () => {
if (JSON.stringify(draft) !== opened.current && !(await confirmDiscard())) return;
setEditing(false);
setDraft([]);
setErr(null);
}, [draft]);
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
type="button"
onClick={async () => {
if (await copyText(command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
);
}
// ── Backups modal ──────────────────────────────────────────────────────
function BackupsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcBackup[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.ccConfig
.backups()
.then((r) => setItems(r.items))
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
return (
<div
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="cc-backups-title"
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 id="cc-backups-title" className="text-sm font-medium text-fg-primary flex-1">
{t("backups.title")}
</span>
<button
type="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: copy the backup back over 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 command instead.
const restoreCmd = restoreCommand(
backup.backupPath,
deriveActivePath(backup),
backup.type === "skills"
);
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>";
}
/**
* The shell command that restores a backup. `cp -a`, never `mv`: the backup
* survives a wrong restore, and a skill backup is a DIRECTORY — `mv dir active`
* onto an existing skill dir moves the backup INSIDE it instead of over it, so
* a directory's CONTENTS are copied into the (re-created) active dir.
* @param isDir Whether the backup is a directory (a skill).
*/
export function restoreCommand(backupPath: string, activePath: string, isDir: boolean): string {
const from = shellEscape(backupPath);
const to = shellEscape(activePath);
return isDir ? `mkdir -p ${to} && cp -a ${from}/. ${to}/` : `cp -a ${from} ${to}`;
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
export function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}