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:
+89
-13
@@ -657,7 +657,9 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
|
||||
}
|
||||
// Through the same lexical guard as every other path: in a subdirectory
|
||||
// lane `../x` would list commit metadata for a path outside the lane.
|
||||
if (file) args.push("--", lanePath(file));
|
||||
// `--parents` makes git REWRITE each parent to the file's own previous
|
||||
// commit, so a file's history draws one joined-up line, not loose ends.
|
||||
if (file) args.push("--parents", "--", lanePath(file));
|
||||
const result = await git(dir, args);
|
||||
const commits = [];
|
||||
for (const record of result.stdout.split("\x1e")) {
|
||||
@@ -682,6 +684,50 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate (JetBrains' blame gutter): who last changed each line of the
|
||||
* working-tree file. `git blame --porcelain` on the file on disk, so a line
|
||||
* nobody committed yet is `null`. Same guards as a whole-file read: the
|
||||
* lexical lane guard, a secret refused (ESECRET), and the file's size capped.
|
||||
* Answers `{commits: {sha: {short, author, date, summary}}, lines: [sha|null]}`.
|
||||
*/
|
||||
async function blameFile(dir, requested) {
|
||||
const file = lanePath(requested);
|
||||
if (await isSecretOnDisk(dir, file)) {
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
}
|
||||
const st = await fs.promises.stat(path.join(dir, file)).catch(() => null);
|
||||
if (!st?.isFile()) throw badRequest("ENOFILE", "no such file");
|
||||
if (st.size > MAX_FILE_VIEW_BYTES) throw badRequest("ETOOBIG", "file too large to annotate");
|
||||
const out = (
|
||||
await git(dir, ["blame", "--porcelain", "--", file], { maxBuffer: 64 * 1024 * 1024 })
|
||||
).stdout;
|
||||
const commits = {};
|
||||
const lines = [];
|
||||
let sha = null;
|
||||
for (const line of out.split("\n")) {
|
||||
const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line);
|
||||
if (head) {
|
||||
sha = head[1];
|
||||
if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) };
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("\t")) {
|
||||
lines.push(/^0+$/.test(sha) ? null : sha);
|
||||
continue;
|
||||
}
|
||||
const c = commits[sha];
|
||||
if (!c) continue;
|
||||
const sp = line.indexOf(" ");
|
||||
const key = line.slice(0, sp);
|
||||
const val = line.slice(sp + 1);
|
||||
if (key === "author") c.author = val;
|
||||
else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString();
|
||||
else if (key === "summary") c.summary = val;
|
||||
}
|
||||
return { path: file, commits, lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's full content at a revision, for the "read the whole file" view
|
||||
* that a diff's three lines of context cannot answer.
|
||||
@@ -981,7 +1027,18 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
* `reverse` says which side the patch will be applied to (see `onlyBlock`).
|
||||
*/
|
||||
async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) {
|
||||
if (!header || (await isSecretOnDisk(dir, file))) return null;
|
||||
if (!header) return null;
|
||||
const diff = await diffHunks(dir, file, context);
|
||||
const hunk = diff?.hunks.find((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (!hunk) return null;
|
||||
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
|
||||
return body ? diff.head + (body.endsWith("\n") ? body : `${body}\n`) : null;
|
||||
}
|
||||
|
||||
/** A tracked file's fresh `git diff HEAD` at `context`: its header lines and
|
||||
* its hunks' texts, in order. Null for a secret, binary or too-large file. */
|
||||
async function diffHunks(dir, file, context) {
|
||||
if (await isSecretOnDisk(dir, file)) return null;
|
||||
const u = `-U${normalizeContext(context)}`;
|
||||
const raw = await readCapped(
|
||||
dir,
|
||||
@@ -992,16 +1049,31 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
|
||||
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
|
||||
const at = raw.indexOf("\n@@ ");
|
||||
if (at < 0) return null;
|
||||
const head = raw.slice(0, at + 1);
|
||||
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
|
||||
// line that does is a hunk boundary.
|
||||
const hunk = raw
|
||||
.slice(at + 1)
|
||||
.split(/^(?=@@ )/m)
|
||||
.find((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (!hunk) return null;
|
||||
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
|
||||
return body ? head + (body.endsWith("\n") ? body : `${body}\n`) : null;
|
||||
return { head: raw.slice(0, at + 1), hunks: raw.slice(at + 1).split(/^(?=@@ )/m) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The hunk-level commit's patch for one file (JetBrains' partial commit):
|
||||
* the file's header plus each picked hunk cut down to its picked `blocks`,
|
||||
* in file order, to be applied FORWARD onto an index holding HEAD. Every
|
||||
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
|
||||
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
|
||||
* git wrote it — `git apply` locates a hunk by its old side.
|
||||
*/
|
||||
async function selectionPatch(dir, file, context, picks) {
|
||||
const diff = await diffHunks(dir, file, context);
|
||||
if (!diff || !picks.length) return null;
|
||||
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (picks.some((p) => at(p.hunk) < 0)) return null;
|
||||
const bodies = [];
|
||||
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
|
||||
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
|
||||
if (!body) return null;
|
||||
bodies.push(body);
|
||||
}
|
||||
return diff.head + bodies.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1020,7 +1092,9 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
|
||||
* checked side is unchanged. Null when the hunk has no such block.
|
||||
*/
|
||||
function onlyBlock(hunk, block, reverse) {
|
||||
if (!Number.isInteger(block) || block < 0) return null;
|
||||
// One block, or several (the hunk-level commit keeps every ticked one).
|
||||
const keep = Array.isArray(block) ? block : [block];
|
||||
if (!keep.length || keep.some((b) => !Number.isInteger(b) || b < 0)) return null;
|
||||
const [header, ...lines] = hunk.replace(/\n$/, "").split("\n");
|
||||
const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||
if (!m) return null;
|
||||
@@ -1040,7 +1114,7 @@ function onlyBlock(hunk, block, reverse) {
|
||||
if (change && !inBlock) index += 1;
|
||||
inBlock = change;
|
||||
let kept = line;
|
||||
if (change && index !== block) {
|
||||
if (change && !keep.includes(index)) {
|
||||
const fold = reverse ? "+" : "-";
|
||||
kept = mark === fold ? ` ${line.slice(1)}` : null;
|
||||
}
|
||||
@@ -1050,7 +1124,7 @@ function onlyBlock(hunk, block, reverse) {
|
||||
if (kept[0] !== "+") oldCount += 1;
|
||||
if (kept[0] !== "-") newCount += 1;
|
||||
}
|
||||
if (block > index) return null;
|
||||
if (Math.max(...keep) > index) return null;
|
||||
return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n";
|
||||
}
|
||||
|
||||
@@ -1103,6 +1177,8 @@ module.exports = {
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
hunkPatch,
|
||||
selectionPatch,
|
||||
blameFile,
|
||||
laneStatus,
|
||||
listCommits,
|
||||
listPaths,
|
||||
|
||||
Reference in New Issue
Block a user