Files
AgentHub/client/src/pages/CodexConfig.tsx
T
nntrivi2001 2942ced3ec style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab
The Codex tab strip wrapped onto a second row while Claude Code's stayed
one scrollable row, so two panels that are meant to be twins read as
different components. Both now render the same ConfigTabBar — the
scroller lifted out of CcConfig, with the fade edges and chevrons that
only appear when there is somewhere to scroll. The deferral note in
CodexConfig that predicted this is gone with it.

The bar also carries role="tablist"/"tab" and aria-selected now: Codex
had them, Claude Code did not, and one of the two had to win.

Cursor's placeholder tab goes away with the machinery that served only
it — the optional Panel, the "soon" badge, the coming-soon card, and
their four i18n keys.

Element.scrollBy and friends move to test-setup: jsdom has none of them,
and the tab bar now calls them from both pages.
2026-09-09 14:44:25 +07:00

1743 lines
61 KiB
TypeScript

/**
* @file CodexConfig.tsx
* @description Codex CLI configuration explorer - the Codex-side counterpart
* of `CcConfig.tsx`. Surfaces skills, agents, rules, AGENTS.md memory,
* config.toml, MCP servers, plugins, and marketplaces the `codex` CLI knows
* about. Create/edit/delete for the low-risk text-file surfaces (skills,
* agents, rules, memory, config); MCP servers, plugins, and marketplaces are
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
* split between editable artifacts and CLI-driven surfaces. Codex has no
* "project-local" scope and no per-project file-based memory - its scope
* union is exactly "user" | "project".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
RefreshCw,
Search,
Sparkles,
UserRound,
FolderTree,
FileText,
BookOpen,
Settings as SettingsIcon,
PlugZap,
Server,
Store,
ExternalLink,
Pencil,
Trash2,
Plus,
X,
AlertCircle,
Info,
History,
Terminal,
Copy,
Check,
} from "lucide-react";
import { api } from "../lib/api";
import { FileViewer } from "../components/config/FileViewer";
import { EditorModal, type EditorState } from "../components/config/EditorModal";
import {
ConfirmDeleteModal,
type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
SkeletonRows,
CopyButton,
formatBytes,
} from "../components/config/configPrimitives";
import type {
CodexAgent,
CodexArtifactType,
CodexBackup,
CodexConfigFile,
CodexFileResponse,
CodexMarketplace,
CodexMcpAddArgs,
CodexMcpServer,
CodexMemoryFile,
CodexMutationResult,
CodexOverview,
CodexPlugin,
CodexRule,
CodexScope,
CodexSkill,
} from "../lib/api";
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
interface TabDef {
key: TabKey;
icon: typeof Sparkles;
i18nKey: string;
}
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
];
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
return tab === "skills" || tab === "agents" || tab === "rules";
}
// Both memory (AGENTS.md) and config (config.toml) are a single fixed file
// per scope - unlike Claude, where only memory is nameless.
const NAMELESS_TYPES = ["memory", "config"];
interface PageState {
overview: CodexOverview | null;
skills: CodexSkill[] | null;
agents: CodexAgent[] | null;
rules: CodexRule[] | null;
memory: CodexMemoryFile[] | null;
config: CodexConfigFile[] | null;
mcp: CodexMcpServer[] | null;
plugins: CodexPlugin[] | null;
marketplaces: CodexMarketplace[] | null;
}
const EMPTY_STATE: PageState = {
overview: null,
skills: null,
agents: null,
rules: null,
memory: null,
config: null,
mcp: null,
plugins: null,
marketplaces: null,
};
/** Common shape shared by CodexSkill/CodexAgent/CodexRule, enough to render
* a list card without knowing which one it is. */
interface NamedArtifactItem {
scope: "user" | "project";
name: string;
file: string;
size: number;
mtime: number;
preview: string;
frontmatter?: Record<string, string>;
meta?: Record<string, unknown>;
}
export function CodexConfig() {
const { t } = useTranslation("codexConfig");
const [tab, setTab] = useState<TabKey>("overview");
const [scope, setScope] = useState<CodexScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// mcp/plugins/marketplaces shell out to the `codex` CLI, which may not be
// installed - that's a normal state for those two tabs, not a page error
// (mirrors CcConfig.tsx's PluginsPanel: "a failure here is a normal state").
const [cliError, setCliError] = useState<string | null>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
const [viewer, setViewer] = useState<{
path: string;
data: CodexFileResponse | null;
error: string | null;
} | null>(null);
const [editor, setEditor] = useState<EditorState<CodexArtifactType, "user" | "project">>(null);
const [confirmDelete, setConfirmDelete] =
useState<ConfirmDeleteState<CodexArtifactType, "user" | "project">>(null);
const [toast, setToast] = useState<Toast>(null);
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(id);
}, [toast]);
const fetchAll = useCallback(async () => {
setLoading(true);
setError(null);
// File-backed reads (skills/agents/rules/memory/config/overview) and the
// CLI-backed ones (mcp/plugins/marketplaces) fail independently: a
// missing `codex` binary must not take down the file-backed tabs too.
const filesDone = Promise.all([
api.codexConfig.overview(),
api.codexConfig.skills(scope),
api.codexConfig.agents(scope),
api.codexConfig.rules(scope),
api.codexConfig.memory(),
api.codexConfig.config(),
])
.then(([overview, skills, agents, rules, memory, config]) => {
setData((d) => ({
...d,
overview,
skills: skills.skills,
agents: agents.agents,
rules: rules.rules,
memory: memory.memory,
config: config.config,
}));
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "unknown error");
});
const cliDone = Promise.all([
api.codexConfig.mcp(),
api.codexConfig.plugins(),
api.codexConfig.marketplaces(),
])
.then(([mcp, plugins, marketplaces]) => {
setCliError(null);
setData((d) => ({
...d,
mcp: mcp.mcp,
plugins: plugins.plugins,
marketplaces: marketplaces.marketplaces,
}));
})
.catch((err: unknown) => {
setCliError(err instanceof Error ? err.message : "unknown error");
});
await Promise.all([filesDone, cliDone]);
setLastUpdated(new Date());
setLoading(false);
}, [scope]);
useEffect(() => {
void fetchAll();
}, [fetchAll]);
// Live updates - refetch whenever the server broadcasts a codex config
// change (dashboard-driven mutations here). Debounced like CcConfig's
// equivalent subscription, since one save can touch a file plus its backup.
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return eventBus.subscribe((msg) => {
if (msg.type !== "codex_config_changed") return;
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
refetchTimerRef.current = setTimeout(() => {
refetchTimerRef.current = null;
void fetchAll();
}, 250);
});
}, [fetchAll]);
useEffect(() => {
return () => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
};
}, []);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const openViewer = useCallback(async (path: string) => {
setViewer({ path, data: null, error: null });
try {
const file = await api.codexConfig.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: CodexArtifactType, overrideScope?: "user" | "project") => {
const template = t(`edit.templates.${type}`);
const defaultScope: "user" | "project" =
overrideScope ?? (scope === "project" ? "project" : "user");
setEditor({ mode: "create", type, defaultScope, template });
},
[scope, t]
);
const openEdit = useCallback(
(
type: CodexArtifactType,
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: CodexArtifactType,
scopeArg: "user" | "project",
name: string | undefined,
path: string
) => {
setConfirmDelete({ type, scope: scopeArg, name, path });
},
[]
);
const handleSave = useCallback(
async (args: {
type: CodexArtifactType;
targetScope: "user" | "project";
name: string | undefined;
content: string;
}) => {
const result: CodexMutationResult = await api.codexConfig.write({
scope: args.targetScope,
type: args.type,
name: args.name,
content: args.content,
});
setEditor(null);
setToast({
kind: "success",
message: result.created
? t("edit.saveSuccessNew")
: t("edit.saveSuccess", { path: result.file }),
});
void fetchAll();
},
[fetchAll, t]
);
// Create AGENTS.md in ANOTHER project's directory — the Codex twin of
// CcConfig's handler; `handleSave` above only ever writes this machine's
// user/project layers.
const handleCreateProjectMemory = useCallback(
async (args: { dir: string; content: string }) => {
const result = await api.codexConfig.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.codexConfig.remove({
scope: confirmDelete.scope,
type: confirmDelete.type,
name: confirmDelete.name,
});
setConfirmDelete(null);
setToast({ kind: "success", message: t("edit.deleteSuccess", { path: result.file }) });
void fetchAll();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown error";
setConfirmDelete(null);
setToast({ kind: "error", message: t("edit.deleteError", { message: msg }) });
}
}, [confirmDelete, fetchAll, t]);
return (
<div className="space-y-5">
<Header
loading={loading}
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={fetchAll}
onOpenBackups={() => setBackupsOpen(true)}
wsConnected={wsConnected}
/>
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("loadError", { message: error })}</span>
</div>
)}
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isNamedArtifact(tab) && (
<button
onClick={() => openCreate(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("list.newButton")}
</button>
)}
</div>
)}
{/* The memory tab hides the search bar, but still needs its own
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
the user/project files listed below. */}
{tab === "memory" && (
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
<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"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
cliError={cliError}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateScopeFile={openCreate}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
</div>
{backupsOpen && (
<BackupsModal roots={data.overview?.roots} onClose={() => setBackupsOpen(false)} />
)}
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && (
<EditorModal<CodexArtifactType, "user" | "project">
state={editor}
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)}
onClose={() => setEditor(null)}
onSave={handleSave}
namelessTypes={NAMELESS_TYPES}
/>
)}
{confirmDelete && (
<ConfirmDeleteModal<CodexArtifactType, "user" | "project">
state={confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
/>
)}
{projectMemoryOpen && (
<ProjectMemoryModal
ns="codexConfig"
filename="AGENTS.md"
template={t("projectMemory.template")}
onClose={() => setProjectMemoryOpen(false)}
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
</div>
);
}
// ── Header ────────────────────────────────────────────────────────────
interface HeaderProps {
loading: boolean;
lastUpdated: Date | null;
scope: CodexScope;
onScopeChange: (s: CodexScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
}
function Header({
loading,
lastUpdated,
scope,
onScopeChange,
onRefresh,
onOpenBackups,
wsConnected,
}: HeaderProps) {
const { t } = useTranslation("codexConfig");
const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated
? lastUpdated.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
: "-";
return (
<header className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Boxes className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<History className="w-3.5 h-3.5" />
{t("backups.openButton")}
</button>
<button
onClick={onRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 disabled:opacity-60 transition-colors"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
<span className="grid">
<span aria-hidden className="invisible col-start-1 row-start-1 whitespace-nowrap">
{t("refreshing")}
</span>
<span className="col-start-1 row-start-1 whitespace-nowrap">
{loading ? t("refreshing") : t("refresh")}
</span>
</span>
</button>
</div>
{lastUpdated && (
<span className="text-[11px] text-fg-muted self-end">
{t("lastUpdated", { time: formatted })}
</span>
)}
</div>
</header>
);
}
function ScopeToggle({
value,
onChange,
}: {
value: CodexScope;
onChange: (s: CodexScope) => void;
}) {
const { t } = useTranslation("codexConfig");
const opts: { v: CodexScope; label: string }[] = [
{ v: "all", label: t("scope.all") },
{ v: "user", label: t("scope.user") },
{ v: "project", label: t("scope.project") },
];
return (
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{opts.map((o) => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
// ── Tabs ──────────────────────────────────────────────────────────────
function Tabs({
current,
onSelect,
counts,
}: {
current: TabKey;
onSelect: (k: TabKey) => void;
counts?: CodexOverview["counts"];
}) {
const { t } = useTranslation("codexConfig");
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 "rules":
return counts.rules.user + counts.rules.project;
case "memory":
return counts.memory.user + counts.memory.project;
case "config":
return counts.config.user + counts.config.project;
default:
return null;
}
};
return (
<ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
}))}
current={current}
onSelect={onSelect}
testIdPrefix="codex-tab"
ariaLabel={t("title")}
/>
);
}
// ── Tab panel switch ─────────────────────────────────────────────────
interface TabPanelProps {
tab: TabKey;
data: PageState;
search: string;
cliError: string | null;
onOpenFile: (path: string) => void;
onEdit: (
type: CodexArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CodexArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
onCreateScopeFile: (type: CodexArtifactType, scope: "user" | "project") => void;
onChanged: () => void;
onToast: (toast: NonNullable<Toast>) => void;
}
function TabPanel({
tab,
data,
search,
cliError,
onOpenFile,
onEdit,
onDelete,
onCreateScopeFile,
onChanged,
onToast,
}: TabPanelProps) {
switch (tab) {
case "overview":
return <OverviewPanel overview={data.overview} />;
case "skills":
return (
<ArtifactList
items={data.skills}
kind="skills"
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
/>
);
case "agents":
return (
<ArtifactList
items={data.agents}
kind="agents"
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
/>
);
case "rules":
return (
<ArtifactList
items={data.rules}
kind="rules"
search={search}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
/>
);
case "memory":
return (
<ScopeFilePanel
type="memory"
items={data.memory}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
onCreate={onCreateScopeFile}
/>
);
case "config":
return (
<ScopeFilePanel
type="config"
items={data.config}
onOpen={onOpenFile}
onEdit={onEdit}
onDelete={onDelete}
onCreate={onCreateScopeFile}
/>
);
case "mcp":
return (
<McpPanel
data={data.mcp}
search={search}
cliError={cliError}
onToast={onToast}
onChanged={onChanged}
/>
);
case "plugins":
return (
<PluginsPanel
plugins={data.plugins}
marketplaces={data.marketplaces}
search={search}
cliError={cliError}
onToast={onToast}
onChanged={onChanged}
/>
);
default:
return null;
}
}
// ── Overview ──────────────────────────────────────────────────────────
function OverviewPanel({ overview }: { overview: CodexOverview | null }) {
const { t } = useTranslation("codexConfig");
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} label={t("overview.codexHome")} value={roots.codexHome} />
<RootRow
icon={FolderTree}
label={t("overview.projectCodexDir")}
value={roots.projectCodexDir}
/>
<RootRow icon={FolderTree} label={t("overview.projectRoot")} value={roots.projectRoot} />
</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
icon={Sparkles}
label={t("tabs.skills")}
user={counts.skills.user}
project={counts.skills.project}
/>
<SummaryStat
icon={UserRound}
label={t("tabs.agents")}
user={counts.agents.user}
project={counts.agents.project}
/>
<SummaryStat
icon={FileText}
label={t("tabs.rules")}
user={counts.rules.user}
project={counts.rules.project}
/>
<SummaryStat
icon={BookOpen}
label={t("tabs.memory")}
user={counts.memory.user}
project={counts.memory.project}
/>
<SummaryStat
icon={SettingsIcon}
label={t("tabs.config")}
user={counts.config.user}
project={counts.config.project}
/>
</div>
</section>
</div>
);
}
function SummaryStat({
icon: Icon,
label,
user,
project,
}: {
icon: typeof Sparkles;
label: string;
user: number;
project: number;
}) {
const { t } = useTranslation("codexConfig");
const total = user + project;
return (
<div className="relative rounded-lg border border-border bg-surface-2 overflow-hidden">
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent/40" 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 bg-accent/10 flex items-center justify-center flex-shrink-0">
<Icon className="w-3.5 h-3.5 text-accent" />
</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>
<span className="text-[10px] text-fg-muted truncate">
{user} {t("overview.user")} · {project} {t("overview.project")}
</span>
</div>
</div>
</div>
);
}
function RootRow({
icon: Icon,
label,
value,
}: {
icon: typeof FolderTree;
label: string;
value: string;
}) {
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 bg-accent/40" aria-hidden />
<span className="w-7 h-7 rounded-md bg-accent/10 flex items-center justify-center flex-shrink-0 ml-1.5">
<Icon className="w-3.5 h-3.5 text-accent" />
</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>
);
}
// ── Named artifacts (skills / agents / rules) ────────────────────────
function ArtifactList({
items,
kind,
search,
onOpen,
onEdit,
onDelete,
}: {
items: NamedArtifactItem[] | null;
kind: "skills" | "agents" | "rules";
search: string;
onOpen: (path: string) => void;
onEdit: (
type: CodexArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CodexArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
}) {
const filtered = useMemo(() => {
if (!items) return null;
const q = search.toLowerCase();
if (!q) return items;
return items.filter((it) => {
const blob = [it.name, it.frontmatter?.description, it.preview].filter(Boolean).join(" ");
return blob.toLowerCase().includes(q);
});
}, [items, search]);
if (!filtered) return <SkeletonRows n={6} />;
if (filtered.length === 0) return <Empty />;
return (
<div className="space-y-2">
{filtered.map((it) => (
<ArtifactCard
key={`${it.scope}:${it.name}`}
item={it}
kind={kind}
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
}
function ArtifactCard({
item,
kind,
onOpen,
onEdit,
onDelete,
}: {
item: NamedArtifactItem;
kind: "skills" | "agents" | "rules";
onOpen: (path: string) => void;
onEdit: (
type: CodexArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CodexArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
}) {
const { t } = useTranslation("codexConfig");
const description =
item.frontmatter?.description ||
(typeof item.meta?.description === "string" ? (item.meta.description as string) : "") ||
(item.preview || "")
.replace(/^#+\s.*\n/, "")
.trim()
.slice(0, 200);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
<ScopeBadge scope={item.scope} />
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{description}
</p>
)}
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{item.file}</div>
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button
onClick={() => onOpen(item.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("list.viewButton")}
</button>
<button
onClick={() =>
onEdit(kind, { scope: item.scope, name: item.name, filePath: item.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("list.editButton")}
</button>
<button
onClick={() => onDelete(kind, item.scope, item.name, item.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("list.deleteButton")}
</button>
</div>
</div>
</div>
);
}
// ── Memory / config (singleton file per scope) ───────────────────────
function ScopeFilePanel({
type,
items,
onOpen,
onEdit,
onDelete,
onCreate,
}: {
type: "memory" | "config";
items: (CodexMemoryFile | CodexConfigFile)[] | null;
onOpen: (path: string) => void;
onEdit: (
type: CodexArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CodexArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
onCreate: (type: CodexArtifactType, scope: "user" | "project") => void;
}) {
const { t } = useTranslation("codexConfig");
if (!items) return <SkeletonRows n={2} />;
const present = items.filter((m) => m.exists);
const missing = (["user", "project"] as const).filter(
(s) => !items.some((m) => m.scope === s && m.exists)
);
return (
<div className="space-y-3">
{present.map((m) => {
const preview = "preview" in m ? m.preview : "content" in m ? m.content : "";
return (
<div key={m.scope} className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={m.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
{m.file}
</span>
{m.size !== undefined && (
<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("list.viewButton")}
</button>
<button
onClick={() => onEdit(type, { 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("list.editButton")}
</button>
<button
onClick={() => onDelete(type, 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("list.deleteButton")}
</button>
</div>
{preview && (
<pre className="p-3 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-72 overflow-auto">
{preview}
</pre>
)}
</div>
);
})}
{missing.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(`${type}.missing`)}</span>
</div>
<button
onClick={() => onCreate(type, 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("list.newButton")}
</button>
</div>
))}
</div>
);
}
// ── CLI-driven actions (MCP / plugins / marketplaces) ────────────────
/** Shared runner for the `codex`-CLI-backed add/remove actions. Tracks which
* row is mid-flight so its buttons can disable, and funnels both outcomes
* into the page's toast. Mirrors CcConfig's useConfigAction. */
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 };
}
/** Best-effort label/keys for the loosely-typed CLI JSON blobs
* (`CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are all `[key: string]: unknown`).
* `real: false` means no stable identifier was found - the synthetic label
* (e.g. "MCP servers 1") can't be sent to remove/*, so callers must disable
* the remove action rather than let it 400. */
function itemLabel(
item: Record<string, unknown>,
index: number,
fallback: string
): { label: string; real: boolean } {
const v = item.name ?? item.id ?? item.spec;
return typeof v === "string" && v
? { label: v, real: true }
: { label: `${fallback} ${index + 1}`, real: false };
}
/** Shown in place of a tab's content when its `codex`-CLI-backed read failed
* (most likely: `codex` isn't installed). Mirrors the "no plugins" warning
* banner style already used lower on this page. */
function CliUnavailable({ message }: { message: string }) {
const { t } = useTranslation("codexConfig");
return (
<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("cli.unavailable", { message })}
</div>
);
}
function KvRows({ item, skip }: { item: Record<string, unknown>; skip: string[] }) {
const entries = Object.entries(item).filter(
([k, v]) =>
!skip.includes(k) &&
(typeof v === "string" || typeof v === "number" || typeof v === "boolean")
);
if (entries.length === 0) return null;
return (
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
{entries.map(([k, v]) => (
<div key={k} className="truncate">
<span className="text-fg-muted">{k}:</span>{" "}
<span className="font-mono text-fg-secondary">{String(v)}</span>
</div>
))}
</div>
);
}
function McpPanel({
data,
search,
cliError,
onToast,
onChanged,
}: {
data: CodexMcpServer[] | null;
search: string;
cliError: string | null;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("codexConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [mode, setMode] = useState<"command" | "url">("command");
const [name, setName] = useState("");
const [command, setCommand] = useState("");
const [argsStr, setArgsStr] = useState("");
const [url, setUrl] = useState("");
const [bearerVar, setBearerVar] = useState("");
const submit = useCallback(async () => {
const trimmedName = name.trim();
if (!trimmedName) return;
const args: CodexMcpAddArgs =
mode === "url"
? { name: trimmedName, url: url.trim(), bearerTokenEnvVar: bearerVar.trim() || undefined }
: {
name: trimmedName,
command: command.trim(),
args: argsStr.trim() ? argsStr.trim().split(/\s+/) : undefined,
};
const ok = await run(
"add",
() => api.codexConfig.mcpAdd(args),
t("mcp.added", { name: trimmedName })
);
if (ok) {
setName("");
setCommand("");
setArgsStr("");
setUrl("");
setBearerVar("");
}
}, [argsStr, bearerVar, command, mode, name, run, t, url]);
if (!data) return cliError ? <CliUnavailable message={cliError} /> : <SkeletonRows n={3} />;
const needle = search.toLowerCase();
const filtered = data.filter((s) => !needle || JSON.stringify(s).toLowerCase().includes(needle));
return (
<div className="space-y-3">
<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("mcp.addTitle")}</div>
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
{(["command", "url"] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-2.5 py-1 text-[11px] font-medium rounded ${
mode === m
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{m === "command" ? t("mcp.modeCommand") : t("mcp.modeUrl")}
</button>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("mcp.nameLabel")}
aria-label={t("mcp.nameLabel")}
className="h-8 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"
/>
{mode === "command" ? (
<>
<input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={t("mcp.commandLabel")}
aria-label={t("mcp.commandLabel")}
className="h-8 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={argsStr}
onChange={(e) => setArgsStr(e.target.value)}
placeholder={t("mcp.argsLabel")}
aria-label={t("mcp.argsLabel")}
className="h-8 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 md:col-span-1"
/>
</>
) : (
<>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("mcp.urlLabel")}
aria-label={t("mcp.urlLabel")}
className="h-8 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={bearerVar}
onChange={(e) => setBearerVar(e.target.value)}
placeholder={t("mcp.bearerLabel")}
aria-label={t("mcp.bearerLabel")}
className="h-8 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>
<button
type="button"
onClick={() => void submit()}
disabled={!name.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("mcp.addButton")}
</button>
</div>
{filtered.length === 0 ? (
<Empty />
) : (
filtered.map((s, i) => {
const { label, real } = itemLabel(s, i, t("tabs.mcp"));
return (
<div key={label + i} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
<KvRows item={s} skip={["name"]} />
</div>
<button
onClick={() =>
void run(
label,
() => api.codexConfig.mcpRemove(label),
t("mcp.removed", { name: label })
)
}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
>
<Trash2 className="w-3 h-3" />
{busy === label ? t("manage.working") : t("mcp.removeButton")}
</button>
</div>
</div>
);
})
)}
</div>
);
}
function PluginsPanel({
plugins,
marketplaces,
search,
cliError,
onToast,
onChanged,
}: {
plugins: CodexPlugin[] | null;
marketplaces: CodexMarketplace[] | null;
search: string;
cliError: string | null;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("codexConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [spec, setSpec] = useState("");
const [mktUrl, setMktUrl] = useState("");
const submitPlugin = useCallback(async () => {
const trimmed = spec.trim();
if (!trimmed) return;
const ok = await run(
"add-plugin",
() => api.codexConfig.pluginAction({ spec: trimmed, action: "add" }),
t("plugins.installed", { name: trimmed })
);
if (ok) setSpec("");
}, [run, spec, t]);
const submitMarketplace = useCallback(async () => {
const trimmed = mktUrl.trim();
if (!trimmed) return;
const ok = await run(
"add-marketplace",
() => api.codexConfig.marketplaceAdd(trimmed),
t("plugins.marketplaceAdded")
);
if (ok) setMktUrl("");
}, [mktUrl, run, t]);
const needle = search.toLowerCase();
const filteredPlugins = (plugins ?? []).filter(
(p) => !needle || JSON.stringify(p).toLowerCase().includes(needle)
);
const filteredMarketplaces = (marketplaces ?? []).filter(
(m) => !needle || JSON.stringify(m).toLowerCase().includes(needle)
);
return (
<div className="space-y-5">
<div className="space-y-3">
<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("plugins.installTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={spec}
onChange={(e) => setSpec(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitPlugin();
}}
placeholder={t("plugins.installPlaceholder")}
aria-label={t("plugins.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 submitPlugin()}
disabled={!spec.trim() || busy === "add-plugin"}
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-plugin" ? t("manage.working") : t("plugins.installButton")}
</button>
</div>
</div>
{plugins === null ? (
cliError ? (
<CliUnavailable message={cliError} />
) : (
<SkeletonRows n={3} />
)
) : filteredPlugins.length === 0 ? (
<Empty />
) : (
filteredPlugins.map((p, i) => {
const { label, real } = itemLabel(p, i, t("tabs.plugins"));
return (
<div
key={label + i}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
<KvRows item={p} skip={["name"]} />
</div>
<button
onClick={() =>
void run(
label,
() => api.codexConfig.pluginAction({ spec: label, action: "remove" }),
t("plugins.removed", { name: label })
)
}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
>
<Trash2 className="w-3 h-3" />
{busy === label ? t("manage.working") : t("plugins.removeButton")}
</button>
</div>
</div>
);
})
)}
</div>
{/* Marketplaces live inside the Plugins tab: the CLI/API treat them as
one small add/remove surface, not worth a dedicated top-level tab. */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2 flex items-center gap-1.5">
<Store className="w-3.5 h-3.5" />
{t("plugins.marketplacesTitle")}
</h3>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5 mb-2">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
{t("plugins.addMarketplaceTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={mktUrl}
onChange={(e) => setMktUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitMarketplace();
}}
placeholder={t("plugins.addMarketplacePlaceholder")}
aria-label={t("plugins.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 submitMarketplace()}
disabled={!mktUrl.trim() || busy === "add-marketplace"}
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-marketplace" ? t("manage.working") : t("plugins.addMarketplaceButton")}
</button>
</div>
</div>
{marketplaces === null ? (
cliError ? (
<CliUnavailable message={cliError} />
) : (
<SkeletonRows n={2} />
)
) : filteredMarketplaces.length === 0 ? (
<Empty />
) : (
<div className="space-y-2">
{filteredMarketplaces.map((m, i) => {
const { label, real } = itemLabel(m, i, t("plugins.marketplacesTitle"));
return (
<div
key={label + i}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
<KvRows item={m} skip={["name"]} />
</div>
<button
onClick={() =>
void run(
label,
() =>
api.codexConfig.marketplaceAction({ name: label, action: "remove" }),
t("plugins.marketplaceRemoved", { name: label })
)
}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
>
<Trash2 className="w-3 h-3" />
{busy === label ? t("manage.working") : t("plugins.removeButton")}
</button>
</div>
</div>
);
})}
</div>
)}
</section>
{plugins !== null && plugins.length === 0 && filteredPlugins.length === 0 && !search && (
<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("plugins.noPlugins")}
</div>
)}
</div>
);
}
// ── Backups modal ─────────────────────────────────────────────────────
//
// Ported from CcConfig.tsx's BackupsModal. `CodexBackup` carries no `scope`
// (the server route doesn't report one - see server/lib/codex-mutate.js's
// listBackups), so scope and the restore path are both derived here from the
// backup file's own path against the known roots from the overview response.
function BackupsModal({
roots,
onClose,
}: {
roots: CodexOverview["roots"] | undefined;
onClose: () => void;
}) {
const { t } = useTranslation("codexConfig");
const [items, setItems] = useState<CodexBackup[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.codexConfig
.backups()
.then((r) => setItems(r.items))
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-3xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<History className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("backups.title")}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("backups.subtitle")}</p>
</div>
<div className="overflow-auto p-4 space-y-2">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{items === null && !error && <SkeletonRows n={4} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("backups.empty")}
</div>
)}
{items?.map((b) => (
<BackupRow key={b.file} backup={b} roots={roots} />
))}
</div>
</div>
</div>
);
}
function BackupRow({
backup,
roots,
}: {
backup: CodexBackup;
roots: CodexOverview["roots"] | undefined;
}) {
const { t } = useTranslation("codexConfig");
const scope = deriveScope(backup.file, roots);
const restoreCmd = `mv ${shellEscape(backup.file)} ${shellEscape(deriveActivePath(backup, roots))}`;
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">
{scope && <ScopeBadge scope={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.file}</div>
<div className="mt-2">
<div className="text-[10px] text-fg-muted mb-1">{t("backups.restoreHint")}</div>
<CommandSnippet command={restoreCmd} />
</div>
</div>
);
}
function CommandSnippet({ command }: { command: string }) {
const { t } = useTranslation("codexConfig");
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>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
);
}
/** Best-effort: which scope a backup file belongs to, by prefix-matching it
* against the known roots. Returns null (badge omitted) if `roots` hasn't
* loaded yet or the path doesn't match either root. */
function deriveScope(
filePath: string,
roots: CodexOverview["roots"] | undefined
): "user" | "project" | null {
if (!roots) return null;
if (filePath.startsWith(roots.codexHome)) return "user";
if (filePath.startsWith(roots.projectCodexDir) || filePath.startsWith(roots.projectRoot)) {
return "project";
}
return null;
}
// Backups live at <root>/codex-config-backups/<type>/<name>.<ts>.bak (see
// backupRootFor in server/lib/codex-mutate.js). memory/config are fixed
// single files (AGENTS.md / config.toml) rather than a <type>/<name> subdir,
// so those two get their well-known active path instead.
function deriveActivePath(b: CodexBackup, roots: CodexOverview["roots"] | undefined): string {
const m = b.name.match(/^(.+?)\.[^.]+\.bak$/);
const baseName = m ? m[1] : b.name;
const scope = deriveScope(b.file, roots);
if (!scope || !roots) return "<active path>";
if (b.type === "memory") {
return scope === "user" ? `${roots.codexHome}/AGENTS.md` : `${roots.projectRoot}/AGENTS.md`;
}
if (b.type === "config") {
return scope === "user"
? `${roots.codexHome}/config.toml`
: `${roots.projectCodexDir}/config.toml`;
}
const root = scope === "user" ? roots.codexHome : roots.projectCodexDir;
return `${root}/${b.type}/${baseName}`;
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}