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
@@ -0,0 +1,301 @@
/**
* @file The Changes panel's JetBrains-style commit against a REAL repo:
* `commit`/`amend` with `paths` take exactly the ticked files' working-tree
* content (an untracked one included) and leave every other change — staged
* or not — where it was; a failed commit puts a ticked untracked file back
* out of the index. And `ignore` (Add to .gitignore): an anchored, escaped
* line in the workspace's own .gitignore, only for an untracked path, never
* twice, never through a symlink. Then the hunk-level commit (`hunks`: only
* the ticked change blocks, through a temporary index, the real index synced
* afterwards and the working tree untouched), `blameFile` (Annotate) and a
* file's history with its parents rewritten so the graph joins up.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const { blameFile, listCommits } = require("../lib/git-diff");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-"));
const REPO = path.join(ROOT, "repo");
const g = (...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
};
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
const status = () => g("status", "--porcelain=v1", "--untracked-files=all");
const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean);
beforeEach(() => {
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
write("a.txt", "a\n");
write("b.txt", "b\n");
write("c.txt", "c\n");
g("add", "-A");
g("commit", "-qm", "init");
});
describe("commit with paths", () => {
it("commits only the ticked files, untracked included, and leaves the rest", async () => {
write("a.txt", "a2\n"); // ticked, unstaged
write("b.txt", "b2\n");
g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted
write("c.txt", "c2\n"); // not ticked
write("new.txt", "n\n"); // ticked, untracked
await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] });
assert.equal(g("log", "-1", "--format=%s").trim(), "pick");
assert.deepEqual(
g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(),
["a.txt", "new.txt"]
);
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
assert.match(status(), /^M {2}b\.txt$/m);
assert.match(status(), /^ M c\.txt$/m);
});
it("commits a partly staged file's working-tree content, a deletion too", async () => {
write("a.txt", "staged\n");
g("add", "a.txt");
write("a.txt", "staged\nand more\n");
fs.rmSync(path.join(REPO, "b.txt"));
await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] });
assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n");
assert.ok(!inHead().includes("b.txt"));
assert.equal(status(), "");
});
it("refuses an empty list and a path git does not report changed", async () => {
write("a.txt", "a2\n");
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), {
code: "EBADPATH",
});
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), {
code: "EBADPATH",
});
});
it("puts a ticked untracked file back out of the index when the commit fails", async () => {
write("new.txt", "n\n");
const hook = path.join(REPO, ".git", "hooks", "pre-commit");
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] }));
assert.match(status(), /^\?\? new\.txt$/m);
});
});
describe("amend with paths", () => {
it("an empty list rewords only, leaving the staged change out", async () => {
write("a.txt", "a2\n");
g("add", "a.txt");
const head = g("rev-parse", "HEAD").trim();
await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] });
assert.equal(g("log", "-1", "--format=%s").trim(), "reworded");
assert.equal(g("show", "HEAD:a.txt"), "a\n");
assert.match(status(), /^M {2}a\.txt$/m);
});
it("folds the ticked files in, and refuses an empty list with no message", async () => {
write("new.txt", "n\n");
const head = g("rev-parse", "HEAD").trim();
await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), {
code: "ENOCHANGES",
});
await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] });
assert.equal(g("log", "-1", "--format=%s").trim(), "init");
assert.ok(inHead().includes("new.txt"));
assert.equal(g("rev-list", "--count", "HEAD").trim(), "1");
});
});
describe("ignore", () => {
it("appends an anchored, escaped line once, keeping the file's last line", async () => {
write(".gitignore", "node_modules");
write("debug*.log", "x\n");
write("note.md", "x\n");
await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] });
assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n");
assert.doesNotMatch(status(), /debug|note/);
});
it("creates the file when there is none, in a subdirectory too", async () => {
fs.mkdirSync(path.join(REPO, "dir"));
write("dir/x y.tmp", "x\n");
await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" });
assert.equal(read(".gitignore"), "/dir/x y.tmp\n");
assert.doesNotMatch(status(), /x y\.tmp/);
});
it("refuses a tracked file and a .gitignore that is a symlink", async () => {
write("a.txt", "a2\n");
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" });
write("new.txt", "n\n");
const outside = path.join(ROOT, "outside-ignore");
fs.writeFileSync(outside, "");
fs.symlinkSync(outside, path.join(REPO, ".gitignore"));
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), {
code: "EBADPATH",
});
assert.equal(fs.readFileSync(outside, "utf8"), "");
});
});
/** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */
const headers = (file) =>
g("diff", "-U3", "HEAD", "--", file)
.split("\n")
.filter((l) => l.startsWith("@@ "));
describe("commit with hunks", () => {
beforeEach(() => {
write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`);
g("add", "long.txt");
g("commit", "-qm", "long");
});
const edit = (pairs) => {
let text = read("long.txt");
for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to);
write("long.txt", text);
};
it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => {
edit([
["3", "three"],
["25", "twentyfive"],
]);
const [first] = headers("long.txt");
await runGitAction(REPO, "commit", {
message: "part",
context: 3,
hunks: { "long.txt": [{ hunk: first, blocks: [0] }] },
});
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^three$/m);
assert.match(committed, /^25$/m);
assert.match(read("long.txt"), /^twentyfive$/m);
// The index followed the commit: the rest is an unstaged change only.
assert.match(status(), /^ M long\.txt$/m);
});
it("takes one block of a hunk that holds two, with whole files beside it", async () => {
edit([
["3", "three"],
["5", "five"],
]);
write("a.txt", "a2\n");
const [only] = headers("long.txt");
await runGitAction(REPO, "commit", {
message: "block",
context: 3,
paths: ["a.txt"],
hunks: { "long.txt": [{ hunk: only, blocks: [1] }] },
});
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^3$/m);
assert.match(committed, /^five$/m);
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
assert.match(status(), /^ M long\.txt$/m);
assert.doesNotMatch(status(), /a\.txt/);
});
it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => {
edit([["3", "three"]]);
write("new.txt", "n\n");
g("add", "b.txt");
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] },
}),
{ code: "EHUNKGONE" }
);
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] },
}),
{ code: "EUNTRACKED" }
);
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] },
}),
{ code: "EBADPATH" }
);
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
assert.match(status(), /^ M long\.txt$/m);
});
it("amends with only the ticked hunk", async () => {
edit([
["3", "three"],
["25", "twentyfive"],
]);
const head = g("rev-parse", "HEAD").trim();
const [, second] = headers("long.txt");
await runGitAction(REPO, "amend", {
sha: head,
context: 3,
paths: [],
hunks: { "long.txt": [{ hunk: second, blocks: [0] }] },
});
assert.equal(g("log", "-1", "--format=%s").trim(), "long");
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^twentyfive$/m);
assert.match(committed, /^3$/m);
});
});
describe("blameFile", () => {
it("names each line's commit, and none for a line not committed yet", async () => {
write("a.txt", "a\nnew line\n");
const blame = await blameFile(REPO, "a.txt");
assert.equal(blame.lines.length, 2);
const first = blame.commits[blame.lines[0]];
assert.equal(first.summary, "init");
assert.equal(first.author, "t");
assert.equal(blame.lines[1], null);
});
it("refuses a path outside the lane and a secret file", async () => {
await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" });
fs.mkdirSync(path.join(REPO, ".claude"));
write(".claude/settings.local.json", "{}\n");
g("add", "-f", ".claude/settings.local.json");
g("commit", "-qm", "secret");
await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" });
});
});
describe("a file's history", () => {
it("rewrites parents to the file's own commits, so the graph joins up", async () => {
write("a.txt", "a2\n");
g("commit", "-qam", "a2");
write("b.txt", "b2\n");
g("commit", "-qam", "b only");
write("a.txt", "a3\n");
g("commit", "-qam", "a3");
const commits = await listCommits(REPO, { file: "a.txt" });
assert.deepEqual(
commits.map((c) => c.subject),
["a3", "a2", "init"]
);
assert.deepEqual(commits[0].parents, [commits[1].sha]);
});
});