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:
2026-10-06 12:36:06 +07:00
parent 6c605849d8
commit f0500dc592
41 changed files with 1201 additions and 249 deletions
+21 -1
View File
@@ -189,6 +189,21 @@ function normalizeModels(model, models) {
return out;
}
/**
* A known Claude model id that Claude Code should treat the router's own model
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
* The user picks it, never AgentHub: it also sets the context window Claude
* Code compacts at, and a router may route a name to a non-Claude model.
*/
function normalizeBehavesAs(value) {
const clean = typeof value === "string" ? value.trim() : null;
if (clean === "") return "";
if (!clean || !/^claude-[a-z0-9][a-z0-9.-]{0,99}$/.test(clean)) {
throw err("EBADMODEL", "behavesAs must be a Claude model id, e.g. claude-opus-5-5");
}
return clean;
}
function assertNotBuiltin(name) {
if (name === BUILTIN_NAME) {
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
@@ -250,7 +265,7 @@ function getRouter(name) {
/**
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
* models?:Record<string,string>}} input
* models?:Record<string,string>, behavesAs?:string}} input
* @returns the browser-safe view of what was saved
*/
function createRouter(input = {}) {
@@ -265,6 +280,7 @@ function createRouter(input = {}) {
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
model,
models: normalizeModels(model, input.models),
behavesAs: input.behavesAs === undefined ? "" : normalizeBehavesAs(input.behavesAs),
savedAt: new Date().toISOString(),
};
@@ -297,6 +313,10 @@ function updateRouter(name, changes = {}) {
changes.model === undefined && changes.models === undefined
? stored.models
: normalizeModels(model, changes.models ?? stored.models),
behavesAs:
changes.behavesAs === undefined
? (stored.behavesAs ?? "")
: normalizeBehavesAs(changes.behavesAs),
savedAt: new Date().toISOString(),
};
+121
View File
@@ -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 };
+9
View File
@@ -165,6 +165,14 @@ function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
}
}
const forgetShellStmt = db.prepare(`DELETE FROM dashboard_runs WHERE id = ? AND tool = 'shell'`);
/** Drop a closed Terminal tab's row — a shell has no session, so the row is
* only the sidebar's "recently closed" entry. Never touches another tool's. */
function forgetShellRun(id) {
return forgetShellStmt.run(id).changes > 0;
}
function getRun(id) {
try {
return getStmt.get({ id }) || null;
@@ -281,6 +289,7 @@ module.exports = {
patchRun,
listRuns,
getRun,
forgetShellRun,
reconcileOrphans,
routerForSession,
laneForSession,
+37 -19
View File
@@ -60,6 +60,23 @@ const ALLOWED_PERMISSION_MODES = new Set([
]);
// Pane commands that mean "idle shell prompt, safe to type a command into".
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
/**
* `sh` that waits until a client is attached to the pane — 2 s at most
* ({@link CLIENT_WAIT_MS}) — before what follows it runs. A session is born
* 80×24 and takes the browser's size only on attach (and `enableMouse`'s
* scrollbar narrows it before that). Anything drawn before then is wrong:
* a shell's prompt (issue #55), and an inline `claude`'s banner — tmux
* re-wraps the 80-column rules into two lines each on the resize, pushing the
* banner's first lines into history, and claude's redraw clears only the
* visible screen, so they stay above it as a duplicate. Not "the first
* SIGWINCH": the scrollbar fires one before any client is there. A tmux that
* cannot answer ends the wait at once, i.e. the old behaviour.
*/
const WAIT_FOR_CLIENT =
'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' +
" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;";
const CLIENT_WAIT_MS = 2000;
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const CODEX_APPROVALS = new Set(["on-request", "never"]);
// Codex has no `--effort` flag; reasoning effort is a config override.
@@ -452,11 +469,10 @@ function spawnRun(args) {
// journals and the router layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
// Claude Code inline even under `"tui": "fullscreen"`: on the alternate
// screen tmux keeps no history and draws no pane scrollbar, so the
// dashboard terminal had nothing to drag. The user's own terminals keep
// their setting — this reaches only panes AgentHub starts.
...(tool === "claude" ? { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" } : {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
...routerSpawn.env,
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
@@ -472,12 +488,18 @@ function spawnRun(args) {
} catch {
/* pane gone mid-paste — nothing to deliver to */
}
}, entry.prompt.delayMs);
}, entry.prompt.delayMs + CLIENT_WAIT_MS); // the CLI may start up to that late
if (timer.unref) timer.unref();
};
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv, env });
// argv rides as sh's positional parameters, never inside the script text.
tmux.newSession({
name: id,
cwd,
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv],
env,
});
record();
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
@@ -530,18 +552,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
};
// The session is born 80×24 and only takes the browser's size when the pane
// attaches. A shell started at once printed its prompt at 80 columns (a long
// cwd wrapped), and readline's redraw after the resize left the cursor on top
// of the prompt (issue #55). So wait until a client is attached — 2 s at
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
// resizes the pane before any client is there. A tmux that cannot answer
// ends the wait at once, i.e. the old behaviour.
const waitForSize =
"i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" +
' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l';
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
tmux.newSession({
name: id,
cwd,
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`],
env,
});
dashboardRuns?.recordRun({
id,
sessionId: null,
+20 -2
View File
@@ -8,7 +8,8 @@
* theirs, not a choice made here:
*
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
* profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
* profile's eight `env` keys (plus `modelPicker` rows when the profile
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
* (0600). The command-line layer outranks the project-local and user
* settings (verified on 2.1.286), so two sessions of one workspace can run
* on two routers, and the key stays out of argv — only the path is there.
@@ -73,12 +74,29 @@ function writeSecret(file, body) {
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
}
/**
* `modelPicker` rows telling Claude Code which known model each of the router's
* own names behaves as — without one, a name like 9Router's `agent` draws an
* "isn't described by this version's model catalog" warning and a 200k
* compact window (issue #56). Only when the profile sets `behavesAs`: this
* layer's `modelPicker` replaces the user's own for the session. A bare
* `claude-*` id is skipped, since Claude Code already knows it.
*/
function modelPicker(profile) {
if (!profile.behavesAs) return {};
const names = new Set([profile.model, ...Object.values(profile.models || {})]);
const options = [...names]
.filter((m) => m && !m.startsWith("claude-"))
.map((model) => ({ model, behavesAs: profile.behavesAs }));
return options.length ? { modelPicker: { options } } : {};
}
function writeClaudeSettings(profile) {
const file = claudeSettingsPath(profile.name);
// Empty values are left out: a blank key in the command-line layer would
// blank the user's own setting, not leave it alone.
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
return file;
}
+4 -2
View File
@@ -99,13 +99,15 @@ function enableMouse(name) {
execImpl(["set-option", "-t", pane(name), "pane-scrollbars", "on"]);
// tmux's default (`fg=white,bg=black,width=1`) is a one-column track in
// ANSI black — the terminal theme's border colour, almost the background —
// so it reads as no scrollbar at all. Two columns, bright slider.
// so it reads as no scrollbar at all. Bright slider. ONE column: tmux 3.7c
// reserves `width` columns but paints only the first, so a second one is
// never cleared and shows stale cells — a ghost second scrollbar.
execImpl([
"set-option",
"-t",
pane(name),
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
"fg=brightwhite,bg=black,width=1,pad=0",
]);
} catch {
/* tmux < 3.7 */