feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
@@ -1,6 +1,6 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -73,8 +73,8 @@ describe("AppearanceSettings", () => {
it("the System mode is checked after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("radio", { name: /System/ }));
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
await user.click(screen.getByRole("radio", { name: "System" }));
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
@@ -84,4 +84,21 @@ describe("AppearanceSettings", () => {
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
});
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
"aria-checked",
"true"
);
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
await user.click(within(group).getByRole("radio", { name: "System font" }));
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
/**
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
* first, says when start-at-login will bring the dashboard back, and only a
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
* switch, which goes straight to PUT /api/settings/tray.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AutostartSettings } from "../AutostartSettings";
import { api } from "../../../lib/api";
import * as confirm from "../../../lib/confirm";
vi.mock("../../../lib/api", () => ({
api: {
settings: {
autostart: { get: vi.fn(), set: vi.fn() },
tray: { get: vi.fn(), set: vi.fn() },
quit: vi.fn(),
},
},
}));
const status = { available: true, kind: "systemd", enabled: true, foreign: false };
const trayOff = { available: true, enabled: false, running: false };
describe("Quit AgentHub", () => {
beforeEach(() => {
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
vi.mocked(api.settings.quit).mockReset().mockResolvedValue({ ok: true, killed: 0 });
});
it("does nothing when the ask is cancelled", async () => {
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("quit-agenthub"));
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
expect(api.settings.quit).not.toHaveBeenCalled();
});
it("quits once confirmed, warning that start-at-login brings it back", async () => {
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
render(<AutostartSettings />);
await waitFor(() => expect(api.settings.autostart.get).toHaveBeenCalled());
await screen.findByRole("switch", { checked: true });
fireEvent.click(screen.getByTestId("quit-agenthub"));
await waitFor(() => expect(api.settings.quit).toHaveBeenCalledTimes(1));
expect(ask.mock.calls[0]![0].message).toMatch(/next boot\/login/);
expect(screen.getByTestId("quit-agenthub")).toBeDisabled();
});
it("turns the status-area icon on from its switch", async () => {
vi.mocked(api.settings.tray.set).mockResolvedValue({
...trayOff,
enabled: true,
running: true,
});
render(<AutostartSettings />);
await waitFor(() => expect(api.settings.tray.get).toHaveBeenCalled());
const icon = screen.getAllByRole("switch")[1]!;
await waitFor(() => expect(icon).toBeEnabled());
fireEvent.click(icon);
await waitFor(() => expect(api.settings.tray.set).toHaveBeenCalledWith(true));
await waitFor(() => expect(icon).toBeChecked());
});
});
@@ -1,20 +1,24 @@
/**
* @file ClaudeAgentHubSettings.test.tsx
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
* Settings → Dashboard (#40 part B) — still load and act as they did there.
* @description Settings → Claude Code → AgentHub: the hook status with
* Reinstall (named as the plugin's when the plugin provides the hooks),
* CLAUDE_HOME, and a link to the Claude table on the Pricing page — the
* table itself is shown only there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor, within } from "@testing-library/react";
import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
const render = (ui: ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
function confirmYes() {
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
@@ -36,24 +40,9 @@ vi.mock("../../../lib/api", async (importOriginal) => {
};
});
const RULE = {
model_pattern: "claude-opus-5-5%",
display_name: "Claude Opus 5.5",
input_per_mtok: 4,
output_per_mtok: 20,
cache_read_per_mtok: 0.2,
cache_write_per_mtok: 5,
cache_write_1h_per_mtok: 8,
fast_input_per_mtok: 8,
fast_output_per_mtok: 40,
updated_at: "2026-09-28 10:00:00",
} as ModelPricing;
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(api.pricing.list)
.mockReset()
.mockResolvedValue({ pricing: [RULE] });
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
vi.mocked(api.settings.info)
.mockReset()
.mockResolvedValue({
@@ -76,10 +65,15 @@ beforeEach(() => {
});
describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
it("renders hook status and CLAUDE_HOME, and links to the Claude prices", async () => {
const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
"href",
"/settings/pricing#pricing-claude"
);
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
expect(api.pricing.list).not.toHaveBeenCalled();
for (const id of ["hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
const hooks = container.querySelector("section#hooks") as HTMLElement;
@@ -121,14 +115,19 @@ describe("ClaudeAgentHubSettings", () => {
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
it("resets pricing only on the second click", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
vi.mocked(api.settings.info).mockResolvedValue({
hooks: {
installed: true,
source: "plugin",
path: "/home/u/.claude/settings.json",
hooks: { UserPromptSubmit: true },
},
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
render(<ClaudeAgentHubSettings />);
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
const note = await screen.findByText(/provided by the agenthub plugin/i);
const hooks = note.closest("section#hooks") as HTMLElement;
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
});
});