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:
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file commit-message.js
|
||||
* @description Suggests a commit message for a lane's STAGED changes with the
|
||||
* user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with
|
||||
* @description Suggests a commit message for a lane's STAGED changes — or,
|
||||
* given `paths` (the Changes panel's ticked files), for exactly those files'
|
||||
* working-tree changes, untracked ones included — with the
|
||||
* user's own `claude` CLI: `claude -p` on that diff, sent on stdin, with
|
||||
* no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on
|
||||
* disk (`--no-session-persistence`), so the call neither acts on the repo nor
|
||||
* shows up as a session in the dashboard. Read-only on git; the diff is scoped
|
||||
@@ -14,7 +16,7 @@ const { spawn } = require("node:child_process");
|
||||
|
||||
const worktree = require("./worktree");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { isSecretOnDisk } = require("./git-diff");
|
||||
const { isSecretOnDisk, laneStatus } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
|
||||
|
||||
@@ -24,7 +26,7 @@ const MAX_DIFF_CHARS = 60_000;
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
const PROMPT =
|
||||
"Write a git commit message for the staged diff given on stdin. " +
|
||||
"Write a git commit message for the diff given on stdin. " +
|
||||
"Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " +
|
||||
"matching the style of the recent subjects listed there. Add a short body after a blank line only " +
|
||||
"when the change needs explaining. Output ONLY the commit message — no code fences, no commentary.";
|
||||
@@ -33,28 +35,52 @@ function bad(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/** The text the model reads: recent subjects, the staged file list, the diff. */
|
||||
async function buildInput(dir) {
|
||||
const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
/** A diff's text. An oversized one keeps git's partial output — still the
|
||||
* useful head — and so does `--no-index`, which exits 1 when files differ. */
|
||||
async function readDiff(dir, args) {
|
||||
try {
|
||||
return (await git(dir, args, { maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 })).stdout;
|
||||
} catch (err) {
|
||||
if (!err.git?.stdout) throw err;
|
||||
return String(err.git.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The text the model reads: recent subjects, the file list, the diff — of
|
||||
* what is staged, or of `picked`: paths `git status` reports changed right
|
||||
* now (EBADPATH otherwise), diffed against HEAD, an untracked one as new.
|
||||
*/
|
||||
async function buildInput(dir, picked) {
|
||||
const status = Array.isArray(picked)
|
||||
? new Map((await laneStatus(dir)).map((e) => [e.path, e.code]))
|
||||
: null;
|
||||
if (status) {
|
||||
const unknown = picked.find((p) => typeof p !== "string" || !status.has(p));
|
||||
if (unknown !== undefined) throw bad("EBADPATH", `not a changed path: ${unknown}`);
|
||||
}
|
||||
const names = status
|
||||
? picked
|
||||
: (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const paths = [];
|
||||
for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p);
|
||||
if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace");
|
||||
|
||||
const diffArgs = ["--no-color", "--no-ext-diff", "--relative"];
|
||||
let diff;
|
||||
try {
|
||||
diff = (
|
||||
await git(
|
||||
dir,
|
||||
["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths],
|
||||
{ maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 }
|
||||
)
|
||||
).stdout;
|
||||
} catch (err) {
|
||||
// An oversized diff: git's partial output is still the useful head.
|
||||
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
|
||||
diff = String(err.git.stdout);
|
||||
if (!status) {
|
||||
diff = await readDiff(dir, ["diff", "--cached", ...diffArgs, "--", ...paths]);
|
||||
} else {
|
||||
const tracked = paths.filter((p) => status.get(p) !== "??");
|
||||
diff = tracked.length
|
||||
? await readDiff(dir, ["diff", "HEAD", ...diffArgs, "--", ...tracked]).catch(() => "")
|
||||
: "";
|
||||
for (const p of paths.filter((p) => status.get(p) === "??")) {
|
||||
if (diff.length > MAX_DIFF_CHARS) break;
|
||||
diff += await readDiff(dir, ["diff", "--no-index", "--no-color", "--", "/dev/null", p]);
|
||||
}
|
||||
}
|
||||
if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
|
||||
|
||||
@@ -64,8 +90,8 @@ async function buildInput(dir) {
|
||||
|
||||
return [
|
||||
log ? `Recent commit subjects:\n${log}` : "",
|
||||
`Staged files:\n${paths.join("\n")}`,
|
||||
`Staged diff:\n${diff}`,
|
||||
`${status ? "Files" : "Staged files"}:\n${paths.join("\n")}`,
|
||||
`${status ? "Diff" : "Staged diff"}:\n${diff}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@@ -96,13 +122,14 @@ function run(bin, args, input) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested message for what `dir` has staged.
|
||||
* A suggested message for what `dir` has staged, or for `paths`.
|
||||
* @param {string} dir the lane's (or session worktree's) cwd
|
||||
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
|
||||
* @param {{bin?: string, paths?: string[]}} [opts] `bin` overrides the
|
||||
* detected `claude` (tests); `paths` = the ticked files
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function suggestCommitMessage(dir, { bin } = {}) {
|
||||
const input = await buildInput(dir);
|
||||
async function suggestCommitMessage(dir, { bin, paths } = {}) {
|
||||
const input = await buildInput(dir, paths);
|
||||
const claude = bin || aiTools.detect("claude", { fresh: true }).path;
|
||||
if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH");
|
||||
const out = await run(
|
||||
|
||||
Reference in New Issue
Block a user