feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)

Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
@@ -53,6 +53,10 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
routerCapable: false,
install: null,
update: null,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
...over,
});
const TOOLS: AiToolsView = {
@@ -152,6 +152,12 @@ export function LaneConsolePane({
);
const aiTools = useAiTools();
const toolLabel = aiTools.label(laneTool);
// A new run starts on the tool's default model (AI Tools settings), and a
// lane switch to another tool drops the previous tool's pick.
const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? "";
useEffect(() => {
setModel(defaultModel);
}, [laneTool, defaultModel]);
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
+51 -13
View File
@@ -43,7 +43,7 @@ import {
RotateCcw,
Lock,
} from "lucide-react";
import { api, RUN_MODEL_CHOICES, RUN_MODEL_CHOICES_CODEX, RUN_EFFORT_CHOICES } from "../../lib/api";
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES, type ModelChoice } from "../../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
@@ -409,6 +409,8 @@ export function RunSetup(props: RunSetupProps) {
// Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false);
const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Claude and Codex always take a model; a catalog tool only when its file names the flag.
const hasModel = !catalogTool || !!entry?.modelFlag;
// Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible.
@@ -441,8 +443,8 @@ export function RunSetup(props: RunSetupProps) {
</div>
)}
</Field>
{/* A catalog tool takes no model, permission or effort flag. */}
{!catalogTool && (
{/* A catalog tool takes no permission or effort flag, and a model only when it declares one. */}
{hasModel && (
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
@@ -532,7 +534,8 @@ export function RunSetup(props: RunSetupProps) {
// Pass the picked session explicitly: the parent's state
// update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing.
if (s && !props.busy) handleStart(props, s, bypass);
if (s && !props.busy)
handleStart(props, s, bypass, hasModel, entry?.defaultModel);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
@@ -559,7 +562,9 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() => handleStart(props, undefined, canBypass && bypass)}
onSubmit={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5}
slash={props.tool === "claude"}
@@ -615,7 +620,9 @@ export function RunSetup(props: RunSetupProps) {
) : null}
</div>
<button
onClick={() => handleStart(props, undefined, canBypass && bypass)}
onClick={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
disabled={
!props.binaryFound ||
// A resume needs no prompt — the session it continues is the input.
@@ -644,7 +651,13 @@ export function RunSetup(props: RunSetupProps) {
/** `session` overrides `props.resumeSession` for the auto-start fired straight
* out of the picker, before the parent's state has caught up. */
function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean) {
function handleStart(
props: RunSetupProps,
session?: Session,
bypass?: boolean,
hasModel?: boolean,
defaultModel?: string | null
) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
// Any catalog tool: none of Claude's or Codex's own flags apply.
@@ -654,7 +667,12 @@ function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean)
// A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined,
model: other ? undefined : props.model || undefined,
// A resume keeps the session's own model: the tool's default the form
// starts on is not a pick, so only a model the user changed is sent.
model:
!(other ? hasModel : true) || (resume && props.model === (defaultModel ?? ""))
? undefined
: props.model || undefined,
permissionMode: codex || other ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
@@ -1029,7 +1047,7 @@ function SessionPicker({
// the "inherit from settings" choice, so use a non-empty marker.
const MODEL_CUSTOM = "__custom__";
function ModelPicker({
export function ModelPicker({
value,
onChange,
tool,
@@ -1039,11 +1057,31 @@ function ModelPicker({
tool: LaneTool;
}) {
const { t } = useTranslation("run");
const choices = tool === "codex" ? RUN_MODEL_CHOICES_CODEX : RUN_MODEL_CHOICES;
// "Custom" is selected when the value isn't one of our curated IDs.
// The tool's own list (Codex's models cache, Claude's aliases named after
// the newest version, a catalog tool's vendor rows); Claude's static
// aliases only when the server cannot answer.
const [fetched, setFetched] = useState<ModelChoice[] | null>(null);
useEffect(() => {
let live = true;
setFetched(null);
Promise.resolve()
.then(() => api.aiTools.models(tool))
.then((r) => live && setFetched(r.models))
.catch(() => live && setFetched([]));
return () => {
live = false;
};
}, [tool]);
const choices = useMemo<ModelChoice[]>(() => {
const inherit = { id: "", label: "" };
if (fetched?.length) return [inherit, ...fetched];
return tool === "claude" ? RUN_MODEL_CHOICES : [inherit];
}, [fetched, tool]);
// "Custom" is selected when the value isn't one of the listed IDs — judged
// only once the list has arrived, so a listed default never flashes as custom.
const knownIds = useMemo(() => choices.map((c) => c.id), [choices]);
const isCustom = value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(isCustom);
const isCustom = fetched !== null && value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(false);
// Reuse the shared Select so the Model dropdown renders identically to the
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
@@ -10,6 +10,7 @@ import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -28,6 +29,10 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(),
list: vi.fn(),
},
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
run: {
list: vi.fn().mockResolvedValue({ items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
@@ -36,7 +41,6 @@ vi.mock("../../../lib/api", () => ({
},
},
RUN_MODEL_CHOICES: [],
RUN_MODEL_CHOICES_CODEX: [],
RUN_EFFORT_CHOICES: [],
}));
@@ -136,6 +140,31 @@ describe("LaneConsolePane", () => {
);
});
it("starts a run on the tool's default model from AI Tools settings", async () => {
__resetAiTools();
(api.aiTools.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
default: "claude",
tools: [{ id: "claude", label: "Claude Code", defaultModel: "sonnet", modelFlag: true }],
});
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ lane: LANE });
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/type to search/i), {
target: { value: "/workspace/a" },
});
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ model: "sonnet" })
)
);
__resetAiTools();
});
it("returns to the setup form when the pane exits, without a New run click", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
@@ -29,12 +29,37 @@ vi.mock("../../../lib/api", async (importOriginal) => {
list: r({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", resume: false, bypass: false },
{
id: "claude",
label: "Claude Code",
resume: false,
bypass: false,
modelFlag: true,
defaultModel: "sonnet",
},
{ id: "codex", label: "Codex", resume: false, bypass: true },
{ id: "antigravity", label: "Antigravity", resume: true, bypass: true },
{
id: "antigravity",
label: "Antigravity",
resume: true,
bypass: true,
modelFlag: true,
},
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
}),
// Each tool's own model list, as GET /api/ai-tools/:id/models answers it.
models: vi.fn(async (id: string) => {
const lists: Record<string, { id: string; label: string }[]> = {
claude: [
{ id: "sonnet", label: "Sonnet (latest · 5.5)" },
{ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
],
codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }],
antigravity: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
};
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
@@ -145,11 +170,12 @@ describe("RunSetup — selections report through callbacks", () => {
onlyCalled(spies, "onCwdChange");
});
it("reports the model from the picker, including a custom id", () => {
it("reports the model from the picker, including a custom id", async () => {
const { spies } = renderSetup();
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
// what keeps the list from going stale on every model release.
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
// The list is the tool's own, fetched from the server — aliases named
// after the newest version, then pinned versions — never a hardcoded copy.
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
// the free-text box it reveals is what reports.
@@ -374,6 +400,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
});
});
describe("RunSetup — resume keeps the session's model", () => {
const session = { id: "sess-1", cwd: "/Users/tester" } as never;
it("sends no model when it is still the tool's default", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "sonnet", prompt: "" });
await screen.findByText(i18n.t("run:fields.model"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ resumeSessionId: "sess-1", model: undefined })
);
});
it("sends a model the user picked", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "opus", prompt: "" });
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: "opus" }));
});
});
describe("RunSetup — per-tool fields", () => {
it("shows sandbox and approval instead of permission mode for a codex lane", () => {
renderSetup({ tool: "codex" });
@@ -457,20 +504,38 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
});
describe("RunSetup — antigravity lanes", () => {
it("names the lane's CLI in the prompt box and drops the flags `agy` lacks", () => {
const { spies } = renderSetup({ tool: "antigravity", model: "opus", effort: "high" });
it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => {
const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" });
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ model: undefined, effort: undefined, permissionMode: undefined })
expect.objectContaining({
model: "gemini-3.1-pro",
effort: undefined,
permissionMode: undefined,
})
);
});
it("sends no model for a catalog tool whose CLI has no model flag", async () => {
const { spies } = renderSetup({ tool: "gemini", model: "stale", prompt: "go" });
await screen.findByPlaceholderText("Ask Gemini anything…");
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: undefined }));
});
});
describe("RunSetup — codex lanes", () => {
it("offers the models Codex itself lists", async () => {
renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
});
it("starts a fresh codex run instead of resuming the last one", () => {
const { spies } = renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
@@ -76,9 +76,8 @@ function formatTimestamp(iso: string): string {
/**
* Info popover for the Model Pricing section. Hover or focus the icon to see a
* three-section explanation: how prices are applied, how pattern matching
* works, and — for Claude — a reminder that prices must be edited manually
* when Anthropic publishes new rates; another vendor's rows come from
* LiteLLM's price list, so its tab says that instead. All copy is i18n-driven
* works, and where the rates come from (LiteLLM's price list, kept up to date
* daily for the rows marked Auto). All copy is i18n-driven
* (settings.pricing.tooltip.*).
*
* The popover is fixed-positioned and clamped to the viewport so it never
@@ -165,11 +164,9 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t(claude ? "pricing.tooltip.manualUpdates" : "pricing.tooltip.synced")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t(claude ? "pricing.tooltip.manualUpdatesBody" : "pricing.tooltip.syncedBody")}
{t("pricing.tooltip.synced")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">{t("pricing.tooltip.syncedBody")}</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.apiPricing")}
@@ -673,6 +670,14 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
</td>
<td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.source === "sync" && (
<span
title={t("pricing.autoBadgeTitle")}
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
>
{t("pricing.autoBadge")}
</span>
)}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
@@ -54,6 +54,10 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
routerCapable: false,
install: null,
update: null,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
...over,
});
const VIEW: AiToolsView = {
@@ -257,6 +261,28 @@ describe("SettingsHub — AI tool tabs", () => {
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
});
it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude" }),
tool("gemini", "Gemini"),
tool("qwen-code", "Qwen Code", { enabled: false }),
tool("aider", "Aider", { installed: false }),
],
});
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull();
expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull();
});
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
@@ -61,6 +61,18 @@ describe("PricingSettings", () => {
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
});
it("marks a row the automatic sync keeps up to date", async () => {
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [
{ ...rule("gpt-6-sol%", "GPT-6 Sol"), source: "sync" },
{ ...rule("gpt-5.6-sol%", "GPT-5.6 Sol"), source: "user" },
],
});
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-6-sol%");
expect(screen.getAllByText("Auto")).toHaveLength(1);
});
it("says where OpenAI prices come from when there are none", async () => {
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
render(<PricingSettings vendor="openai" />);
+2 -2
View File
@@ -8,7 +8,7 @@
*/
import { useEffect, useSyncExternalStore } from "react";
import { api, type AiTool, type AiToolsView } from "../lib/api";
import { api, type AiTool, type AiToolUpdateBody, type AiToolsView } from "../lib/api";
let view: AiToolsView | null = null;
let inflight: Promise<void> | null = null;
@@ -74,7 +74,7 @@ export function useAiTools() {
label: (id: string) => byId(id)?.label ?? FALLBACK[id] ?? id,
reload: load,
refresh: async () => publish(await api.aiTools.refresh()),
update: async (id: string, body: { enabled?: boolean; default?: true }) =>
update: async (id: string, body: AiToolUpdateBody) =>
publish(await api.aiTools.update(id, body)),
};
}
+19 -4
View File
@@ -31,12 +31,10 @@
"howItWorksBody": "When a session reports usage, the dashboard finds the first pricing rule whose pattern matches the model id and multiplies (input tokens / 1M) and (output tokens / 1M) by the rates below to compute cost. Rules are evaluated top-to-bottom - the first match wins.",
"patternsTitle": "Pattern syntax",
"patternsBody": "Patterns are SQL-style globs. % is a wildcard that matches any characters. For example: claude-opus-4-7% matches every model id that starts with claude-opus-4-7 (e.g. claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku matches any haiku variant. An exact id like claude-sonnet-4-6 only matches itself.",
"manualUpdates": "You must update prices manually",
"manualUpdatesBody": "Anthropic publishes pricing changes on their website - there is no live feed. When a new model launches or rates change, edit the matching rule (or add a new one above the catch-all) so newly recorded sessions are costed correctly. Already-stored sessions keep the price that was applied at ingest time.",
"apiPricing": "Reference only - API rates",
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill.",
"synced": "Prices are filled in automatically",
"syncedBody": "A model a session uses with no rule of its own is priced from LiteLLM's public price list the first time it is seen. That row is never updated afterwards: edit it here when the vendor's rates change. Already-stored sessions keep the price that was applied at ingest time.",
"syncedBody": "Every current Claude, OpenAI and Gemini model is priced from LiteLLM's public price list, checked once a day, as is any other model the moment a session uses it. Rows marked Auto follow that list when a rate changes; a row you edit becomes yours and is never touched again, and a row you delete stays deleted. Already-stored sessions keep the price that was applied at ingest time.",
"apiPricingOtherBody": "These are pay-as-you-go API rates. If you use this CLI on a subscription plan, your actual cost is a flat fee, not per-token - so these figures will usually be higher than what you really pay. Treat them as a usage estimate, not a bill."
},
"cacheWrite5m": "Cache Write (5m)",
@@ -62,7 +60,9 @@
"pattern": "gemini-3.8-flash%",
"name": "Gemini 3.8 Flash"
}
}
},
"autoBadge": "Auto",
"autoBadgeTitle": "Kept at the published rate automatically (LiteLLM's price list, checked daily). Edit it to keep your own rate."
},
"claudeHome": {
"title": "CLAUDE_HOME",
@@ -624,5 +624,20 @@
"unsupported": "Antigravity hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
"failed": "Could not change Antigravity hooks: {{message}}"
},
"toolTab": {
"update": "Update…",
"usage_one": "{{count}} session · ${{cost}}",
"usage_other": "{{count}} sessions · ${{cost}}",
"lanes": "Lanes:",
"defaultModel": "Default model",
"defaultModelHint": "The model a new run in this tool's lanes starts on. The run form can still pick another one.",
"saveDefaultModel": "Save default model",
"config": "Config files",
"configHint": "This tool's own settings files. A save is checked for valid syntax, the old text is backed up in AgentHub's data folder, and nothing is saved if the file changed since you opened it.",
"missing": "This file does not exist yet",
"save": "Save",
"close": "Close",
"saved": "Saved. Backup: {{backup}}"
}
}
+19 -4
View File
@@ -31,12 +31,10 @@
"howItWorksBody": "Khi một phiên báo cáo mức dùng, dashboard tìm quy tắc định giá đầu tiên có mẫu khớp với model id, rồi nhân (token đầu vào / 1 triệu) và (token đầu ra / 1 triệu) với các đơn giá bên dưới để tính chi phí. Quy tắc được duyệt từ trên xuống - quy tắc khớp đầu tiên sẽ được dùng.",
"patternsTitle": "Cú pháp mẫu",
"patternsBody": "Mẫu là dạng glob kiểu SQL. % là ký tự đại diện khớp với bất kỳ chuỗi nào. Ví dụ: claude-opus-4-7% khớp mọi model id bắt đầu bằng claude-opus-4-7 (ví dụ claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku khớp mọi biến thể haiku. Một id chính xác như claude-sonnet-4-6 chỉ khớp với chính nó.",
"manualUpdates": "Bạn phải cập nhật giá thủ công",
"manualUpdatesBody": "Anthropic công bố thay đổi giá trên website của họ - không có nguồn dữ liệu trực tiếp. Khi có model mới ra mắt hoặc đơn giá thay đổi, hãy chỉnh sửa quy tắc tương ứng (hoặc thêm quy tắc mới ở phía trên quy tắc bao quát) để những phiên ghi nhận sau này được tính phí chính xác. Phiên đã lưu vẫn giữ nguyên giá đã áp dụng tại thời điểm nạp.",
"apiPricing": "Chỉ để tham khảo - đơn giá API",
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn.",
"synced": "Giá được điền tự động",
"syncedBody": "Một model mà phiên dùng nhưng chưa có quy tắc riêng sẽ được lấy giá từ bảng giá công khai của LiteLLM ở lần đầu gặp. Dòng đó không bao giờ tự cập nhật lại: hãy sửa ở đây khi nhà cung cấp đổi giá. Các phiên đã lưu vẫn giữ mức giá áp dụng lúc ghi nhận.",
"syncedBody": "Mọi model Claude, OpenAI và Gemini hiện hành được lấy giá từ bảng giá công khai của LiteLLM, kiểm tra mỗi ngày một lần; model khác cũng có giá ngay khi một phiên dùng tới. Dòng có nhãn Tự động sẽ theo bảng giá đó khi giá thay đổi; dòng bạn sửa trở thành của bạn và không bao giờ bị ghi đè, dòng bạn xoá sẽ không quay lại. Phiên đã lưu giữ nguyên giá đã áp dụng lúc ghi nhận.",
"apiPricingOtherBody": "Đây là giá API trả theo mức dùng. Nếu bạn dùng CLI này theo gói thuê bao, chi phí thực là một khoản cố định chứ không tính theo token, nên các con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chúng là ước tính mức dùng, không phải hoá đơn."
},
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
@@ -62,7 +60,9 @@
"pattern": "gemini-3.8-flash%",
"name": "Gemini 3.8 Flash"
}
}
},
"autoBadge": "Tự động",
"autoBadgeTitle": "Tự cập nhật theo giá công bố (bảng giá LiteLLM, kiểm tra mỗi ngày). Sửa dòng này để giữ giá của riêng bạn."
},
"claudeHome": {
"title": "CLAUDE_HOME",
@@ -624,5 +624,20 @@
"unsupported": "Hook của Antigravity từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
"failed": "Không đổi được hook của Antigravity: {{message}}"
},
"toolTab": {
"update": "Cập nhật…",
"usage_one": "{{count}} phiên · ${{cost}}",
"usage_other": "{{count}} phiên · ${{cost}}",
"lanes": "Lane:",
"defaultModel": "Model mặc định",
"defaultModelHint": "Model mà một lần chạy mới trong lane của công cụ này dùng. Ô chọn khi chạy vẫn đổi được.",
"saveDefaultModel": "Lưu model mặc định",
"config": "File cấu hình",
"configHint": "Các file cấu hình của chính công cụ này. Khi lưu, cú pháp được kiểm tra, bản cũ được sao lưu vào thư mục dữ liệu của AgentHub, và sẽ không lưu nếu file đã đổi từ lúc mở.",
"missing": "File này chưa có",
"save": "Lưu",
"close": "Đóng",
"saved": "Đã lưu. Bản sao lưu: {{backup}}"
}
}
+53 -15
View File
@@ -1990,12 +1990,31 @@ export const api = {
list: () => request<AiToolsView>("/ai-tools"),
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
/** PUT /api/ai-tools/:id - enable/disable a tool, or make it the default. */
update: (id: string, body: { enabled?: boolean; default?: true }) =>
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
update: (id: string, body: AiToolUpdateBody) =>
request<AiToolsView>(`/ai-tools/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
/** GET /api/ai-tools/:id/models - the models the tool itself offers. */
models: (id: string) => request<AiToolModels>(`/ai-tools/${encodeURIComponent(id)}/models`),
/** GET /api/ai-tools/:id/usage - how many sessions the tool ran, and their cost. */
usage: (id: string) =>
request<{ sessions: number; cost: number }>(`/ai-tools/${encodeURIComponent(id)}/usage`),
/** GET /api/ai-tools/:id/config - the config files the tool's catalog file lists. */
config: (id: string) =>
request<{ files: AiToolConfigFile[] }>(`/ai-tools/${encodeURIComponent(id)}/config`),
/** GET /api/ai-tools/:id/config/:index - one config file's text and sha. */
readConfig: (id: string, index: number) =>
request<{ path: string; format: AiToolConfigFile["format"]; text: string; sha: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`
),
/** PUT /api/ai-tools/:id/config/:index - save over `baseSha` (409 ESTALE otherwise). */
saveConfig: (id: string, index: number, text: string, baseSha: string) =>
request<{ sha: string; backup: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`,
{ method: "PUT", body: JSON.stringify({ text, baseSha }) }
),
/** POST /api/ai-tools/:id/install - start the catalog's install job. */
install: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/install`, {
@@ -3066,6 +3085,36 @@ export interface AiTool {
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */
update: { kind: "npm" } | { kind: "script" } | { kind: "self"; args: string[] } | null;
/** The CLI takes a model flag (Claude, Codex, or a catalog `model` field). */
modelFlag: boolean;
/** The model a new run starts with; null inherits the CLI's own. */
defaultModel: string | null;
/** Whose priced models the tool offers (its Settings tab's pricing table). */
modelVendor: "claude" | "openai" | "google" | null;
/** How many config files its catalog file lists for the Settings tab. */
configFiles: number;
}
/** One config file a catalog tool's Settings tab may edit. */
export interface AiToolConfigFile {
index: number;
path: string;
format: "json" | "jsonc" | "text";
exists: boolean;
size: number | null;
}
export interface AiToolUpdateBody {
enabled?: boolean;
default?: true;
defaultModel?: string | null;
}
/** GET /api/ai-tools/:id/models - read from the tool's own source. */
export interface AiToolModels {
flag: boolean;
models: ModelChoice[];
defaultModel: string | null;
}
export interface AiToolsView {
@@ -3890,9 +3939,8 @@ export const RUN_EFFORT_CHOICES: EffortChoice[] = [
{ id: "max", label: "Max", hint: "All-out - slowest, most tokens" },
];
// Curated model list. "" means "inherit from settings.json" - no --model flag.
// Static UI data for the Run page's model picker; the first entry inherits the
// configured default and the rest map to concrete `--model` values.
// Claude's fallback model list, used only when GET /api/ai-tools/claude/models
// cannot answer. "" means "inherit from settings.json" - no --model flag.
export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" },
// Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/
@@ -3908,16 +3956,6 @@ export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" },
];
// Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent
// via `codex -m`. "" inherits Codex's own configured default.
export const RUN_MODEL_CHOICES_CODEX: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your Codex config model is" },
{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol", hint: "Highest capability" },
{ id: "gpt-5.6-terra", label: "GPT-5.6 Terra", hint: "Balanced capability and speed" },
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna", hint: "Fastest, lightest" },
{ id: "gpt-5.5", label: "GPT-5.5", hint: "Previous generation" },
];
/** Result of a transcript import run - returned by `api.import.rescan`,
* `scanPath`, and `upload`, and mirrored by the final `import.progress`
* WebSocket message (`phase: "complete"`). The core counters (`imported`/
+5
View File
@@ -1037,6 +1037,11 @@ export interface Analytics {
* applied when a token bucket's `speed` is "fast".
*/
export interface ModelPricing {
/** Who owns the row: `"sync"` = added by the automatic sync and kept at
* LiteLLM's published rate daily, `"user"` = edited here and never touched
* again, null = from before this field (adopted by the sync only while it
* still equals the list). */
source?: "sync" | "user" | null;
/** SQL LIKE-style pattern (`%` wildcard) matched against a token bucket's
* model id; longer/more-specific patterns win ties. Primary key. Examples:
* `"claude-opus-4%"` (specific) beats `"claude-%"` (catch-all). */
+267
View File
@@ -0,0 +1,267 @@
/**
* @file AiToolConfig.tsx
* @description Settings → <tool>: the one tab every installed, enabled
* catalog tool without a hand-written tab gets (Claude Code and Codex keep
* theirs). Shows where the CLI lives, its homepage and an Update link into the
* AI Tools modal; how many sessions it ran, their cost, and its lanes; a
* default model (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a
* model flag; its vendor's pricing table; and the config files its catalog
* file lists, each opened over a sha and saved by the server only if it is
* still that sha, valid, and backed up. Antigravity adds its hooks card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api";
import type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
import { ModelPicker } from "../components/run/RunSetup";
import { PricingSettings } from "../components/settings/PricingSettings";
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
/** Cards one tool adds below the generic ones. */
const EXTRAS: Record<string, ComponentType> = { antigravity: AntigravityHooksSettings };
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** The default-model card: the picker, saved on an explicit click. */
function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const [value, setValue] = useState(saved);
const [error, setError] = useState<string | null>(null);
useEffect(() => setValue(saved), [saved]);
const save = async () => {
setError(null);
try {
await aiTools.update(toolId, { defaultModel: value || null });
} catch (err) {
setError(message(err));
}
};
return (
<div className="space-y-2">
<p className="text-xs text-fg-muted">{t("toolTab.defaultModelHint")}</p>
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-[16rem] flex-1">
<ModelPicker value={value} onChange={setValue} tool={toolId} />
</div>
<button
type="button"
onClick={save}
disabled={value === saved}
className="btn-ghost text-xs disabled:opacity-50"
>
{t("toolTab.saveDefaultModel")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
</div>
);
}
/** The config-files card: pick a declared file, edit it, save over its sha. */
function ConfigFiles({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const [files, setFiles] = useState<AiToolConfigFile[] | null>(null);
const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.aiTools
.config(toolId)
.then((r) => setFiles(r.files))
.catch((err) => setNote({ ok: false, text: message(err) }));
}, [toolId]);
const openFile = async (f: AiToolConfigFile) => {
setNote(null);
try {
const r = await api.aiTools.readConfig(toolId, f.index);
setOpen({ index: f.index, path: r.path, sha: r.sha });
setText(r.text);
} catch (err) {
setNote({ ok: false, text: message(err) });
}
};
const save = async () => {
if (!open) return;
setBusy(true);
setNote(null);
try {
const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha);
setOpen({ ...open, sha: r.sha });
setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) });
} catch (err) {
setNote({ ok: false, text: message(err) });
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3">
<p className="text-xs text-fg-muted">{t("toolTab.configHint")}</p>
<div className="flex flex-wrap gap-2">
{(files ?? []).map((f) => (
<button
key={f.index}
type="button"
onClick={() => openFile(f)}
disabled={!f.exists}
title={f.exists ? undefined : t("toolTab.missing")}
className={`rounded-md border px-2.5 py-1 font-mono text-[11px] disabled:opacity-50 ${
open?.index === f.index
? "border-accent/50 text-fg-primary"
: "border-border text-fg-secondary hover:bg-surface-3"
}`}
>
{f.path}
</button>
))}
</div>
{open && (
<div className="space-y-2">
<textarea
aria-label={open.path}
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={16}
className="w-full rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] text-fg-primary focus:border-accent/50 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={save}
disabled={busy}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{t("toolTab.save")}
</button>
<button type="button" onClick={() => setOpen(null)} className="btn-ghost text-xs">
{t("toolTab.close")}
</button>
</div>
</div>
)}
{note && (
<p className={`text-xs ${note.ok ? "text-fg-muted" : "text-status-danger"}`}>{note.text}</p>
)}
</div>
);
}
function Card({
title,
Icon,
children,
}: {
title: string;
Icon: ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-fg-secondary">
<Icon className="h-4 w-4 text-fg-muted" />
{title}
</h3>
<div className="card p-5">{children}</div>
</section>
);
}
/** The tab for one catalog tool. */
export function AiToolConfig({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const tool = aiTools.byId(toolId);
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
const [lanes, setLanes] = useState<Lane[]>([]);
useEffect(() => {
api.aiTools
.usage(toolId)
.then(setUsage)
.catch(() => setUsage(null));
api.lanes
.list()
.then((r) => setLanes(r.lanes.filter((l) => l.tool === toolId)))
.catch(() => setLanes([]));
}, [toolId]);
if (!tool) return null;
const Extra = EXTRAS[toolId];
return (
<div className="space-y-8">
<div className="card flex flex-wrap items-start gap-4 p-5">
<AiToolIcon tool={tool} className="h-10 w-10" />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-base font-semibold text-fg-primary">{tool.label}</h2>
{tool.homepageUrl && (
<a
href={tool.homepageUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-xs text-accent hover:underline"
>
<ExternalLink className="h-3 w-3" />
{t("aiTools.homepage")}
</a>
)}
{tool.update && (
<Link to={`/settings?tool=${toolId}#ai-tools`} className="btn-ghost text-xs">
<RefreshCw className="h-3.5 w-3.5" />
{t("toolTab.update")}
</Link>
)}
</div>
<p className="break-all font-mono text-[11px] text-fg-muted">{tool.path}</p>
{usage && (
<p className="text-xs text-fg-secondary">
{t("toolTab.usage", { count: usage.sessions, cost: usage.cost.toFixed(2) })}
</p>
)}
{lanes.length > 0 && (
<p className="flex flex-wrap items-center gap-2 text-xs text-fg-secondary">
{t("toolTab.lanes")}
{lanes.map((l) => (
<Link
key={l.id}
to={`/workspace?lane=${l.id}`}
className="text-accent hover:underline"
>
{l.title || l.cwd}
</Link>
))}
</p>
)}
</div>
</div>
{tool.modelFlag && (
<Card title={t("toolTab.defaultModel")} Icon={Cpu}>
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
</Card>
)}
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
{tool.configFiles > 0 && (
<Card title={t("toolTab.config")} Icon={FileCog}>
<ConfigFiles toolId={toolId} />
</Card>
)}
{Extra && <Extra />}
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
/**
* @file AntigravityConfig.tsx
* @description Settings → Antigravity: AgentHub's own settings for the
* Antigravity CLI (`agy`) — Model Pricing for Gemini models (the shared
* `PricingSettings` table) and AgentHub's Antigravity hooks
* (`AntigravityHooksSettings`). agy has no config surface AgentHub edits, so
* there is no explorer and no sub-tab bar, only these cards (#40 part D).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { PricingSettings } from "../components/settings/PricingSettings";
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
/** The Antigravity settings tab. */
export function AntigravityConfig() {
return (
<div className="space-y-8">
<PricingSettings vendor="google" id="antigravity-pricing" />
<AntigravityHooksSettings />
</div>
);
}
+28 -15
View File
@@ -1,6 +1,6 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex have their own; every other installed, enabled catalog tool gets the generic `AiToolConfig` tab, Antigravity's with its hooks card) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ComponentType } from "react";
@@ -11,6 +11,7 @@ import {
Boxes,
KeyRound,
Orbit,
Puzzle,
Router,
Settings as SettingsIcon,
SquareTerminal,
@@ -19,7 +20,7 @@ import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AntigravityConfig } from "./AntigravityConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { useAiTools } from "../hooks/useAiTools";
@@ -27,9 +28,13 @@ import { AiToolsModal } from "../components/settings/AiToolsModal";
interface ToolTab {
id: string;
labelKey: string;
labelKey?: string;
/** A catalog tool's own label, used instead of `labelKey`. */
label?: string;
Icon: ComponentType<{ className?: string }>;
Panel: ComponentType;
Panel?: ComponentType;
/** Set on a generic catalog-tool tab: rendered as `<AiToolConfig toolId>`. */
toolId?: string;
/** The AI Tools catalog id this tab configures; hidden while it is disabled. */
aiTool?: string;
}
@@ -58,13 +63,6 @@ const TOOL_TABS: ToolTab[] = [
Panel: CodexConfig,
aiTool: "codex",
},
{
id: "antigravity",
labelKey: "tools.antigravity",
Icon: Orbit,
Panel: AntigravityConfig,
aiTool: "antigravity",
},
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
{
id: "git-credentials",
@@ -83,9 +81,20 @@ export function SettingsHub() {
const [params, setParams] = useSearchParams();
const requested = params.get("tool");
const aiTools = useAiTools();
const tabs = TOOL_TABS.filter(
const fixed = TOOL_TABS.filter(
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
);
// Every other installed, enabled tool, after Codex; a fixed tab's id wins.
const catalogTabs: ToolTab[] = aiTools.tools
.filter((x) => x.installed && x.enabled && !x.launcher && !fixed.some((f) => f.id === x.id))
.map((x) => ({
id: x.id,
label: x.label,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
toolId: x.id,
}));
const at = fixed.findIndex((tab) => tab.id === "ai-router");
const tabs = [...fixed.slice(0, at), ...catalogTabs, ...fixed.slice(at)];
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const location = useLocation();
const navigate = useNavigate();
@@ -107,10 +116,10 @@ export function SettingsHub() {
ariaLabel={t("tools.label", "Tools")}
activeId={active.id}
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
tabs={tabs.map(({ id, labelKey, Icon }) => ({
tabs={tabs.map(({ id, labelKey, label, Icon }) => ({
id,
Icon,
label: t(labelKey),
label: label ?? t(labelKey ?? id),
}))}
actions={
<button
@@ -125,7 +134,11 @@ export function SettingsHub() {
}
/>
<active.Panel />
{active.toolId ? (
<AiToolConfig key={active.toolId} toolId={active.toolId} />
) : (
active.Panel && <active.Panel />
)}
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
</div>
);
@@ -0,0 +1,141 @@
/**
* @file AiToolConfig.test.tsx
* @description The generic per-tool Settings tab: the tool's path, homepage
* and an Update link into the AI Tools modal; its session count, cost and
* lanes; a default-model picker saved through PUT /api/ai-tools/:id; and its
* catalog-declared config files, edited over the sha they were opened at with
* the server's refusal shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig";
vi.mock("../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn(),
update: vi.fn(),
models: vi.fn(),
usage: vi.fn(),
config: vi.fn(),
readConfig: vi.fn(),
saveConfig: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
lanes: { list: vi.fn() },
},
RUN_MODEL_CHOICES: [],
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
}));
const GEMINI: AiTool = {
id: "gemini",
label: "Gemini",
command: "gemini",
homepageUrl: "https://github.com/google-gemini/gemini-cli",
installed: true,
path: "/usr/bin/gemini",
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "flag" },
resume: false,
bypass: true,
routerCapable: false,
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
modelFlag: true,
defaultModel: null,
modelVendor: "google",
configFiles: 2,
};
beforeEach(() => {
__resetAiTools();
const view = { default: "claude", tools: [GEMINI] };
vi.mocked(api.aiTools.list)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.update)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.models)
.mockReset()
.mockResolvedValue({
flag: true,
models: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
defaultModel: null,
});
vi.mocked(api.aiTools.usage).mockReset().mockResolvedValue({ sessions: 3, cost: 1.5 });
vi.mocked(api.aiTools.config)
.mockReset()
.mockResolvedValue({
files: [
{ index: 0, path: "/h/.gemini/settings.json", format: "json", exists: true, size: 2 },
{ index: 1, path: "/h/.gemini/GEMINI.md", format: "text", exists: false, size: null },
],
});
vi.mocked(api.aiTools.readConfig)
.mockReset()
.mockResolvedValue({ path: "/h/.gemini/settings.json", format: "json", text: "{}", sha: "s1" });
vi.mocked(api.aiTools.saveConfig).mockReset();
vi.mocked(api.lanes.list)
.mockReset()
.mockResolvedValue({
lanes: [
{ id: 7, tool: "gemini", title: "web", cwd: "/w" },
{ id: 8, tool: "claude", title: "other", cwd: "/o" },
],
} as never);
});
const renderTab = () =>
render(
<MemoryRouter>
<AiToolConfig toolId="gemini" />
</MemoryRouter>
);
describe("AiToolConfig", () => {
it("shows the tool, its usage, its lanes and its vendor's pricing", async () => {
renderTab();
expect(await screen.findByText("/usr/bin/gemini")).toBeInTheDocument();
expect(await screen.findByText(/3 sessions/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "/workspace?lane=7");
expect(screen.queryByRole("link", { name: "other" })).toBeNull();
expect(screen.getByRole("link", { name: /update/i })).toHaveAttribute(
"href",
"/settings?tool=gemini#ai-tools"
);
expect(screen.getByText("pricing:google")).toBeInTheDocument();
});
it("saves the default model", async () => {
renderTab();
fireEvent.click(await screen.findByText("Inherit from settings"));
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
await waitFor(() =>
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { defaultModel: "gemini-3.1-pro" })
);
});
it("edits a config file over the sha it opened, and shows a refusal", async () => {
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(new Error("not valid JSON"));
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
expect(screen.getByRole("button", { name: "/h/.gemini/GEMINI.md" })).toBeDisabled();
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{nope" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
});
});