/** * @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ĩ */ 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; meta?: Record; } export function CodexConfig() { const { t } = useTranslation("codexConfig"); const { t: tSettings } = useTranslation("settings"); const [tab, setTab] = useState("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("all"); const [data, setData] = useState(EMPTY_STATE); const [loading, setLoading] = useState(false); const [error, setError] = useState(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(null); const [backupsOpen, setBackupsOpen] = useState(false); const [projectMemoryOpen, setProjectMemoryOpen] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); const [search, setSearch] = useState(""); const [viewer, setViewer] = useState<{ path: string; data: CodexFileResponse | null; error: string | null; } | null>(null); const [editor, setEditor] = useState>(null); const [confirmDelete, setConfirmDelete] = useState>(null); const [toast, setToast] = useState(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 | 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 (
{ void fetchAll(); setAgenthubKey((k) => k + 1); }} onOpenBackups={() => setBackupsOpen(true)} wsConnected={wsConnected} /> {error && (
{t("loadError", { message: error })}
)}
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
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 && ( )} {isNamedArtifact(tab) && ( )}
)} {/* 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" && (
)}
{/* AgentHub's own Codex settings follow the overview. A new CODEX_HOME re-reads every list, since they all live under it. */} {tab === "overview" && (
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, }; }) } />
)} {backupsOpen && ( setBackupsOpen(false)} /> )} {viewer && setViewer(null)} />} {editor && ( state={editor} fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)} onClose={() => setEditor(null)} onSave={handleSave} namelessTypes={NAMELESS_TYPES} /> )} {confirmDelete && ( state={confirmDelete} onCancel={() => setConfirmDelete(null)} onConfirm={handleDelete} /> )} {projectMemoryOpen && ( setProjectMemoryOpen(false)} onCreate={handleCreateProjectMemory} /> )} {toast && setToast(null)} />}
); } // ── 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 (

{t("title")}

{wsConnected ? ( {tCommon("live")} ) : ( {tCommon("offline")} )}

{t("subtitle")}

{lastUpdated && ( {t("lastUpdated", { time: formatted })} )}
); } 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 (
{opts.map((o) => ( ))}
); } // ── 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 ( ({ 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) => void; } function TabPanel({ tab, data, search, cliError, onOpenFile, onEdit, onDelete, onCreateScopeFile, onChanged, onToast, }: TabPanelProps) { switch (tab) { case "overview": return ; case "skills": return ( ); case "agents": return ( ); case "rules": return ( ); case "memory": return ( ); case "config": return ( ); case "mcp": return ( ); case "plugins": return ( ); default: return null; } } // ── Overview ────────────────────────────────────────────────────────── function OverviewPanel({ overview }: { overview: CodexOverview | null }) { const { t } = useTranslation("codexConfig"); if (!overview) return ; const { roots, counts } = overview; return (

{t("overview.rootsTitle")}

{t("overview.summary")}

); } 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 (
{label}
{total} {user} {t("overview.user")} · {project} {t("overview.project")}
); } function RootRow({ icon: Icon, label, value, }: { icon: typeof FolderTree; label: string; value: string; }) { return (
{label}
{value}
); } // ── 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(); if (!filtered) return ; if (filtered.length === 0) return ; return (
{filtered.map((it) => ( ))} {ctx.menu && ( 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); }} /> )}
); } /** 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 (
onMenu(e, item)} className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors" >
{item.name}
{description && (

{description}

)}
{item.file}
); } // ── 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(); if (!items) return ; const present = items.filter((m) => m.exists); const missing = (["user", "project"] as const).filter( (s) => !items.some((m) => m.scope === s && m.exists) ); return (
{ctx.menu && ( 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 (
ctx.open(e, m)} className="rounded-lg border border-border bg-surface-2" >
{m.file} {m.size !== undefined && ( {formatBytes(m.size)} )}
{preview && (
                {preview}
              
)}
); })} {missing.map((s) => (
{t(`${type}.missing`)}
))}
); } // ── 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) => void, onChanged: () => void) { const [busy, setBusy] = useState(null); const run = useCallback( async (id: string, fn: () => Promise, 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, 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 (
{t("cli.unavailable", { message })}
); } /** 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 ( {item.real && ( <> { onClose(); void navigator.clipboard.writeText(item.label); }} > {t("list.copyName")} )} { onClose(); item.remove(); }} > {item.removeLabel} ); } function KvRows({ item, skip }: { item: Record; 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 (
{entries.map(([k, v]) => (
{k}:{" "} {String(v)}
))}
); } function McpPanel({ data, search, cliError, onToast, onChanged, }: { data: CodexMcpServer[] | null; search: string; cliError: string | null; onToast: (t: NonNullable) => void; onChanged: () => void; }) { const { t } = useTranslation("codexConfig"); const { busy, run } = useConfigAction(onToast, onChanged); const ctx = useContextMenu(); 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 ? : ; const needle = search.toLowerCase(); const filtered = data.filter((s) => !needle || JSON.stringify(s).toLowerCase().includes(needle)); return (
{t("mcp.addTitle")}
{(["command", "url"] as const).map((m) => ( ))}
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" ? ( <> 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" /> 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" /> ) : ( <> 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" /> 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" /> )}
{filtered.length === 0 ? ( ) : ( 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 (
ctx.open(e, { label, real, remove, removeLabel: t("mcp.removeButton") }) } className="rounded-lg border border-border bg-surface-2 px-4 py-3" >
{label}
); }) )} {ctx.menu && }
); } function PluginsPanel({ plugins, marketplaces, search, cliError, onToast, onChanged, }: { plugins: CodexPlugin[] | null; marketplaces: CodexMarketplace[] | null; search: string; cliError: string | null; onToast: (t: NonNullable) => void; onChanged: () => void; }) { const { t } = useTranslation("codexConfig"); const { busy, run } = useConfigAction(onToast, onChanged); const ctx = useContextMenu(); 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 (
{t("plugins.installTitle")}
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" />
{plugins === null ? ( cliError ? ( ) : ( ) ) : filteredPlugins.length === 0 ? ( ) : ( 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 (
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") }) } className="rounded-lg border border-border bg-surface-2 px-4 py-3" >
{label}
); }) )}
{/* Marketplaces live inside the Plugins tab: the CLI/API treat them as one small add/remove surface, not worth a dedicated top-level tab. */}

{t("plugins.marketplacesTitle")}

{t("plugins.addMarketplaceTitle")}
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" />
{marketplaces === null ? ( cliError ? ( ) : ( ) ) : filteredMarketplaces.length === 0 ? ( ) : (
{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 (
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") }) } className="rounded-lg border border-border bg-surface-2 px-4 py-3" >
{label}
); })}
)}
{plugins !== null && plugins.length === 0 && filteredPlugins.length === 0 && !search && (
{t("plugins.noPlugins")}
)} {ctx.menu && }
); } // ── 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(null); const [error, setError] = useState(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 (
e.stopPropagation()} >
{t("backups.title")}

{t("backups.subtitle")}

{error && (
{error}
)} {items === null && !error && } {items !== null && items.length === 0 && (
{t("backups.empty")}
)} {items?.map((b) => ( ))}
); } 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 (
{scope && } {backup.type} {backup.name} {new Date(backup.mtime).toLocaleString()} {backup.size != null && ( {formatBytes(backup.size)} )}
{backup.file}
{t("backups.restoreHint")}
); } function CommandSnippet({ command }: { command: string }) { const { t } = useTranslation("codexConfig"); const [copied, setCopied] = useState(false); return (
{command}
); } /** 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 /codex-config-backups//..bak (see // backupRootFor in server/lib/codex-mutate.js). memory/config are fixed // single files (AGENTS.md / config.toml) rather than a / 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 ""; 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, `'\\''`)}'`; }