Files
AgentHub/client/src/pages/CodexConfig.tsx
T
nntrivi2001 ad6e80192d feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every
item with actions - sidebar/Recents/archived session rows (SessionMenu),
console tabs, pane headers, the Changes panel's files/tree/commits, the
workspace home, sessions outside a workspace (SessionRowMenu: Sessions
table, Dashboard, Activity, Workflow runs), prompt images, every Settings
list and every Claude Code/Codex config list (FileItemMenu). Buttons stay;
a delete keeps its button's confirm; the shell stops portal clicks from
reaching the card that opened it.

Status bar: Workspace $ and Session $ on the Workspace page, from
?lane= on GET /api/pricing/cost and the focused/replayed session's cost.

Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB,
hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created
(replacing the redundant idx_events_session), watchdog reads
sessions.transcript_path, transcript parsed before the hook's write
transaction, GET /api/sessions/:id?events=0 for the web page (reloads only
on its own session, debounced), streamed export, transactional clear-data
and chunked cleanup, subagent import reads tool_use_ids once, runtime logs
rotated past 5 MB.
2026-10-05 09:37:48 +07:00

1914 lines
67 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". Its **Overview** also holds the
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
* Pricing for OpenAI models (the shared `PricingSettings` table).
* Every card with action buttons also opens a right-click menu repeating
* them (same handlers — Delete still asks for confirmation) plus Copy
* name/path.
* @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 { FileItemMenu } from "../components/config/MdItemList";
import {
ContextMenu,
MenuItem,
MenuSeparator,
useContextMenu,
type MenuPoint,
} from "../components/ContextMenu";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
import { PricingSettings } from "../components/settings/PricingSettings";
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
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 { t: tSettings } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("overview");
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
const [hooksKey, setHooksKey] = useState(0);
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={() => {
void fetchAll();
setAgenthubKey((k) => k + 1);
}}
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>
{/* AgentHub's own Codex settings follow the overview. A new CODEX_HOME
re-reads every list, since they all live under it. */}
{tab === "overview" && (
<div key={agenthubKey} className="space-y-8">
<HomeDirSetting
id="codex-home"
title={tSettings("codexHome.title")}
description={tSettings("codexHome.description")}
note={tSettings("codexHome.restartNote")}
placeholder={tSettings("codexHome.placeholder")}
load={() =>
api.settings.codexHome
.get()
.then((r) => ({ value: r.codex_home, envOwned: r.env_owned }))
}
save={(p) =>
api.settings.codexHome.set(p).then((r) => {
void fetchAll();
setHooksKey((k) => k + 1);
return {
value: r.codex_home,
envOwned: r.env_owned,
};
})
}
/>
<CodexHooksSettings key={hooksKey} />
<PricingSettings vendor="openai" id="codex-pricing" />
</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.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]);
const { t } = useTranslation("codexConfig");
const ctx = useContextMenu<NamedArtifactItem>();
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}
onMenu={ctx.open}
/>
))}
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
labels={fileMenuLabels(t)}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => {
const it = ctx.menu!.item;
onEdit(kind, { scope: it.scope, name: it.name, filePath: it.file });
}}
onDelete={() => {
const it = ctx.menu!.item;
onDelete(kind, it.scope, it.name, it.file);
}}
/>
)}
</div>
);
}
/** The Codex page's labels for the shared file-card menu — its buttons' own. */
const fileMenuLabels = (t: (key: string) => string) => ({
open: t("list.viewButton"),
edit: t("list.editButton"),
delete: t("list.deleteButton"),
copyPath: t("list.copyPath"),
});
function ArtifactCard({
item,
kind,
onOpen,
onEdit,
onDelete,
onMenu,
}: {
item: NamedArtifactItem;
kind: "skills" | "agents" | "rules";
onMenu: (e: React.MouseEvent, item: NamedArtifactItem) => void;
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
onContextMenu={(e) => onMenu(e, item)}
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");
const ctx = useContextMenu<CodexMemoryFile | CodexConfigFile>();
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">
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
labels={fileMenuLabels(t)}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => {
const m = ctx.menu!.item;
onEdit(type, { scope: m.scope, name: "", filePath: m.file });
}}
onDelete={() => {
const m = ctx.menu!.item;
onDelete(type, m.scope, undefined, m.file);
}}
/>
)}
{present.map((m) => {
const preview = "preview" in m ? m.preview : "content" in m ? m.content : "";
return (
<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>
{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>
);
}
/** A CLI-backed row as its right-click menu sees it: its name, and its
* Remove button's own action and label. */
type CliRow = { label: string; real: boolean; removeLabel: string; remove: () => void };
/** Copy the row's name, and run its Remove button's action — disabled exactly
* when the button is. */
function CliRowMenu({
x,
y,
item,
busy,
onClose,
}: MenuPoint & { item: CliRow; busy: string | null; onClose: () => void }) {
const { t } = useTranslation("codexConfig");
return (
<ContextMenu x={x} y={y} onClose={onClose} testId="codex-row-menu">
{item.real && (
<>
<MenuItem
onSelect={() => {
onClose();
void navigator.clipboard.writeText(item.label);
}}
>
{t("list.copyName")}
</MenuItem>
<MenuSeparator />
</>
)}
<MenuItem
danger
disabled={busy === item.label || !item.real}
testId="codex-row-menu-remove"
onSelect={() => {
onClose();
item.remove();
}}
>
{item.removeLabel}
</MenuItem>
</ContextMenu>
);
}
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 ctx = useContextMenu<CliRow>();
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"));
const remove = () =>
void run(
label,
() => api.codexConfig.mcpRemove(label),
t("mcp.removed", { name: label })
);
return (
<div
key={label + i}
onContextMenu={(e) =>
ctx.open(e, { label, real, remove, removeLabel: t("mcp.removeButton") })
}
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={remove}
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>
);
})
)}
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
</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 ctx = useContextMenu<CliRow>();
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"));
const remove = () =>
void run(
label,
() => api.codexConfig.pluginAction({ spec: label, action: "remove" }),
t("plugins.removed", { name: label })
);
return (
<div
key={label + i}
onContextMenu={(e) =>
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
}
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={remove}
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"));
const remove = () =>
void run(
label,
() => api.codexConfig.marketplaceAction({ name: label, action: "remove" }),
t("plugins.marketplaceRemoved", { name: label })
);
return (
<div
key={label + i}
onContextMenu={(e) =>
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
}
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={remove}
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>
)}
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
</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, `'\\''`)}'`;
}