feat(codex): create and customize Codex config from /settings

Adds the Codex config panel (skills/agents/rules/memory/config editing,
mcp/plugins/marketplaces add-remove via the codex CLI) as the last piece
of parity with the Claude Code settings tab, plus the codex_config_changed
WebSocket type the client was missing, and docs for the new
/api/codex-config surface.
This commit is contained in:
2026-09-08 21:09:28 +07:00
parent f6973d24d6
commit 8015730adf
9 changed files with 1856 additions and 6 deletions
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus per-AI-tool configuration panels (Claude Code today, Codex/Cursor reserved), with the active tab kept in the `?tool=` query param.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus per-AI-tool configuration panels (Claude Code and Codex today, Cursor reserved), with the active tab kept in the `?tool=` query param.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
@@ -10,6 +10,7 @@ import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } fr
import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
interface ToolTab {
id: string;
@@ -30,7 +31,7 @@ const DASHBOARD_TAB: ToolTab = {
const TOOL_TABS: ToolTab[] = [
DASHBOARD_TAB,
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
{ id: "cursor", labelKey: "tools.cursor", Icon: MousePointerClick },
];
@@ -0,0 +1,127 @@
/**
* @file CodexConfig.test.tsx
* @description Covers the two load-bearing behaviors of the Codex config
* panel: create/edit/delete is offered on the authored surfaces (skills,
* agents, rules, memory, config) and withheld on the CLI-owned ones (mcp,
* plugins); and saves go through `api.codexConfig`, not the Claude client.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// vi.mock factories are hoisted above the rest of the module, so the mock
// object they reference must be created inside vi.hoisted() too.
const codexConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
overview: r({
roots: { codexHome: "/home/.codex", projectCodexDir: "/repo/.codex", projectRoot: "/repo" },
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
rules: { user: 0, project: 0 },
memory: { user: 0, project: 0 },
config: { user: 0, project: 0 },
},
}),
skills: r({ skills: [] }),
agents: r({ agents: [] }),
rules: r({ rules: [] }),
memory: r({ memory: [] }),
config: r({ config: [] }),
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
write: r({ file: "/f", created: true }),
remove: r({ file: "/f" }),
mcp: r({ mcp: [] }),
plugins: r({ plugins: [] }),
marketplaces: r({ marketplaces: [] }),
mcpAdd: r({ ok: true, stdout: "", stderr: "" }),
mcpRemove: r({ ok: true, stdout: "", stderr: "" }),
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAdd: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAction: r({ ok: true, stdout: "", stderr: "" }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
...(actual.api as Record<string, unknown>),
codexConfig: codexConfigMock,
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CodexConfig } from "../CodexConfig";
/** Reset the mock to its defaults, then apply the given overrides (each
* value is wrapped in a resolved-promise mock unless already a mock fn). */
function stubCodexApi(overrides: {
skills?: unknown[];
rules?: { scope: "user" | "project"; name: string; file: string; size: number }[];
mcp?: { servers?: unknown[] };
write?: ReturnType<typeof vi.fn>;
}) {
codexConfigMock.skills.mockResolvedValue({ skills: overrides.skills ?? [] });
codexConfigMock.rules.mockResolvedValue({ rules: overrides.rules ?? [] });
codexConfigMock.mcp.mockResolvedValue({ mcp: overrides.mcp?.servers ?? [] });
if (overrides.write) codexConfigMock.write = overrides.write as never;
}
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
afterEach(() => {
vi.clearAllMocks();
codexConfigMock.write = vi.fn().mockResolvedValue({ file: "/f", created: true }) as never;
codexConfigMock.remove = vi.fn().mockResolvedValue({ file: "/f" }) as never;
});
describe("CodexConfig", () => {
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
});
it("writes an edited rule back through the codex API, not the Claude one", async () => {
const write = vi.fn().mockResolvedValue({ file: "/r/default.rules", created: false });
stubCodexApi({
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
write,
});
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await settle();
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
});
});