feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's groups drawn as a folder tree (compacted chains, file counts, fold, stage/unstage a whole folder) - AI-suggested commit message for what is staged (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku) - AI Router: optional "Behaves as" Claude model id, written as modelPicker options into the router's --settings file (issue #56) - Sidebar: session rows show last activity, live rows their start time; a closed Terminal row can be removed (DELETE /api/run/history/:id) - Every pane's CLI starts only once a client is attached; claude panes no longer forced inline; tmux scrollbar one column wide - Update notifier: "Check for updates" reopens a closed notice
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @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
|
||||
* 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
|
||||
* to the lane's cwd and never carries a secret file (`isSecretOnDisk`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const worktree = require("./worktree");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { isSecretOnDisk } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
|
||||
|
||||
/** Diff characters sent to the model; the rest is cut and marked. */
|
||||
const MAX_DIFF_CHARS = 60_000;
|
||||
/** A `claude -p` that has not answered by then is killed. */
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
const PROMPT =
|
||||
"Write a git commit message for the staged 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.";
|
||||
|
||||
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);
|
||||
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");
|
||||
|
||||
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 (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
|
||||
|
||||
const log = await git(dir, ["log", "-10", "--format=%s"])
|
||||
.then((r) => r.stdout.trim())
|
||||
.catch(() => ""); // no commit yet
|
||||
|
||||
return [
|
||||
log ? `Recent commit subjects:\n${log}` : "",
|
||||
`Staged files:\n${paths.join("\n")}`,
|
||||
`Staged diff:\n${diff}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/** Run `bin` with `args`, `input` on stdin; resolve its stdout. */
|
||||
function run(bin, args, input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let errOut = "";
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (errOut += d));
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(bad("EAI", err.message));
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) return resolve(out);
|
||||
const why = signal ? "timed out" : (errOut || out).trim().slice(-500) || `exit ${code}`;
|
||||
reject(bad("EAI", `claude failed: ${why}`));
|
||||
});
|
||||
child.stdin.on("error", () => {}); // a CLI that exits early closes stdin
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested message for what `dir` has staged.
|
||||
* @param {string} dir the lane's (or session worktree's) cwd
|
||||
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function suggestCommitMessage(dir, { bin } = {}) {
|
||||
const input = await buildInput(dir);
|
||||
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(
|
||||
claude,
|
||||
["-p", "--safe-mode", "--no-session-persistence", "--tools", "", "--model", "haiku", PROMPT],
|
||||
input
|
||||
);
|
||||
const message = out
|
||||
.trim()
|
||||
.replace(/^```[a-z]*\n?|\n?```$/g, "")
|
||||
.trim();
|
||||
if (!message) throw bad("EAI", "claude returned an empty message");
|
||||
return message;
|
||||
}
|
||||
|
||||
module.exports = { suggestCommitMessage, buildInput };
|
||||
Reference in New Issue
Block a user