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
@@ -2,12 +2,14 @@
* @file SettingsHub.test.tsx
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* unknown section falls back to Appearance, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* highlighted, the nav has three groups (General, Agents, Data) with no
* one-page group, Git and Snapshots & reports are one page each (their old
* paths redirect there), and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -31,7 +33,18 @@ vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({
GitCredentialsConfig: () => <div data-testid="git-credentials" />,
}));
vi.mock("../../components/settings/WorktreeVisibilitySettings", () => ({
WorktreeVisibilitySettings: () => <div data-testid="worktree-visibility" />,
}));
vi.mock("../../components/settings/SnapshotSettings", () => ({
SnapshotSettings: () => <div data-testid="snapshot-settings" />,
}));
vi.mock("../../components/settings/ReportSettings", () => ({
ReportSettings: () => <div data-testid="report-settings" />,
}));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
}));
@@ -124,6 +137,10 @@ describe("SettingsHub", () => {
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
["/settings#ai-tools", "/settings/agents"],
["/settings/about", "/settings/system"],
["/settings/snapshots", "/settings/reports"],
["/settings/worktrees", "/settings/git"],
["/settings/git-credentials", "/settings/git"],
])("redirects %s to %s", async (from, to) => {
renderAt(from);
await screen.findByRole("heading", { level: 1 });
@@ -170,7 +187,7 @@ describe("SettingsHub", () => {
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
expect(links).toEqual(["Pricing"]);
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
"data-first-match",
"true"
@@ -234,18 +251,38 @@ describe("SettingsHub", () => {
);
});
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
it("groups the nav into General, Agents and Data, with no one-page group", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const groups = within(nav())
.getAllByRole("group")
.map((g) => [g.getAttribute("aria-label"), within(g).getAllByRole("link").length]);
expect(groups.map(([name]) => name)).toEqual(["General", "Agents", "Data"]);
for (const [, count] of groups) expect(count).toBeGreaterThan(1);
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
expect(links).not.toContain("General");
expect(links.slice(0, 3)).toEqual(["Appearance", "Notifications", "System"]);
expect(links).toEqual(expect.arrayContaining(["AI Proxy", "Pricing", "Git"]));
expect(links).not.toContain("About");
expect(links).not.toContain("Worktrees");
expect(links).not.toContain("Pet");
});
it("shows git logins and worktree suggestions on one Git page", async () => {
renderAt("/settings/git");
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
});
it("shows snapshots and scheduled reports on one page", async () => {
renderAt("/settings/reports");
await screen.findByRole("heading", { level: 1, name: "Snapshots & reports" });
expect(screen.getByTestId("snapshot-settings")).toBeInTheDocument();
expect(screen.getByTestId("report-settings")).toBeInTheDocument();
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
@@ -272,7 +309,9 @@ describe("SettingsHub", () => {
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
const msg = seen.find((x) => x.message.includes("nope"))?.message ?? "";
expect(msg).toContain("Appearance");
expect(msg).not.toContain("General");
off();
});
});