From 69959592fa0a0c5bcb4e35f4bcc3e9e315a770ea Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 18 Oct 2026 17:45:17 +0200 Subject: [PATCH 1/5] feat(agents, hooks): the full-diff mod draws at the transcript's width, folds each file on its own behind its file list, and mods watch names the mods folder so every new session loads it --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 5 +- plugins/genesis-tools-mods/hooks/register.tsx | 111 ++++++++++++----- plugins/genesis-tools-mods/hooks/sections.ts | 13 +- .../genesis-tools-mods/tests/fold.test.tsx | 114 ++++++++++++++++++ plugins/genesis-tools-mods/types/index.d.ts | 8 ++ scripts/test-args.ts | 4 + src/agents/README.md | 6 +- src/agents/commands/hooks.ts | 58 ++++++++- src/agents/lib/hooks/diff/diff.test.ts | 32 +++++ src/agents/lib/hooks/diff/run.ts | 46 ++++++- src/agents/lib/hooks/hooks.test.ts | 38 +++++- .../agents/lib/hooks/mods-plugin.test.ts | 17 ++- src/agents/lib/hooks/mods.ts | 77 ++++++++++++ 14 files changed, 487 insertions(+), 44 deletions(-) create mode 100644 plugins/genesis-tools-mods/tests/fold.test.tsx create mode 100644 plugins/genesis-tools-mods/types/index.d.ts rename plugins/genesis-tools-mods/hooks/mods.test.ts => src/agents/lib/hooks/mods-plugin.test.ts (88%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 66dc6180ec..5a920b5e1c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -27,7 +27,7 @@ { "name": "genesis-tools-mods", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.1.0", + "version": "1.3.0", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 9b02302176..b14f5b4324 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,8 +1,9 @@ { "name": "genesis-tools-mods", - "version": "1.1.0", + "version": "1.3.0", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Configured in the hooks file: tools agents hooks config set mods.fullDiff|mods.idleCompact true|false.", "author": { "name": "GenesisTools" - } + }, + "types": "./types/index.d.ts" } diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 17590fdc53..11485bfcee 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -1,4 +1,6 @@ -import type { EngineInterface, Register, Timer } from "claude-code"; +import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from "claude-code"; +import { atom, read, update } from "claude-code"; +import type { ClosedFiles } from "../types"; import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; import { type CallSection, fullDiffEnabled, sectionsOfCalls } from "./sections"; @@ -88,6 +90,85 @@ async function compactIfIdle($: EngineInterface): Promise { } } +/** + * The files whose diff is folded, each as `fileKey`: its row (a ToolGroup's `requestId`, or a call's + * `tool_use_id` in an expanded group), its call and its path. Session state, so a fold survives a redraw and + * a reload of the mod. The newest 500 are kept. + */ +const closed = atom({ plugin: "genesis-tools-mods", key: "closed" } as const, [] as ClosedFiles); + +function fileKey(row: string, section: CallSection): string { + return `${row}|${section.call}|${section.path}`; +} + +/** + * The full diffs under a row. Each file has its own `[-]`/`[+]` Button that folds its diff to its name and + * back; the row's Button folds every file, or opens them all once every file is folded. A click elsewhere on the + * drawing reaches the engine's row, which opens the group's calls (the person's "expansion in expansion"). + * + * The inner Box's `width="100%"` stretches the diffs to the transcript's width: without it they were as wide as + * the engine's row line, and the Code element wrapped them there. The width cannot sit on the outer Box: the + * engine refuses a tree that puts its own row under a Box with a `width` (seen live 2026-10-18). + */ +async function drawDiffs( + $: EngineInterface, + ui: ElementTable, + row: string, + sections: readonly CallSection[], + drawn: RenderElement +): Promise { + const { Box, Button, Code, Text } = ui; + const folded = new Set(await read($, closed)); + const keys = sections.map((section) => fileKey(row, section)); + const isAllFolded = keys.every((key) => folded.has(key)); + const added = sections.reduce((sum, section) => sum + section.added, 0); + const removed = sections.reduce((sum, section) => sum + section.removed, 0); + const files = new Set(sections.map((section) => section.path)).size; + const setFolded = (targets: readonly string[], fold: boolean) => { + void update($, closed, (list) => { + const rest = list.filter((key) => !targets.includes(key)); + + return (fold ? [...rest, ...targets] : rest).slice(-500); + }); + }; + + return ( + + {drawn} + + + + + {files} {files === 1 ? "file" : "files"} + + +{added} + -{removed} + + {sections.map((section) => { + const key = fileKey(row, section); + const isFolded = folded.has(key); + + return ( + + + + {section.path} + +{section.added} + -{section.removed} + + {isFolded ? null : } + + ); + })} + + + ); +} + export const register: Register = (on) => { on("turn.start", (_$, e, next) => { stopIdleTimer(); @@ -140,19 +221,7 @@ export const register: Register = (on) => { return drawn; } - const { Box, Code, Text } = $.ui.resolve(e); - - return ( - - {drawn} - {sections.map((section) => ( - - {section.path} - - - ))} - - ); + return drawDiffs($, $.ui.resolve(e), e.requestId, sections, drawn); }); on("ui.render", { component: "ToolUse" }, async ($, e, next) => { @@ -168,18 +237,6 @@ export const register: Register = (on) => { return drawn; } - const { Box, Code, Text } = $.ui.resolve(e); - - return ( - - {drawn} - {sections.map((section) => ( - - {section.path} - - - ))} - - ); + return drawDiffs($, $.ui.resolve(e), e.props.tool_use_id, sections, drawn); }); }; diff --git a/plugins/genesis-tools-mods/hooks/sections.ts b/plugins/genesis-tools-mods/hooks/sections.ts index ed1f8ca70d..9f25c72baa 100644 --- a/plugins/genesis-tools-mods/hooks/sections.ts +++ b/plugins/genesis-tools-mods/hooks/sections.ts @@ -1,6 +1,8 @@ -/** One file of a diff-hook full-diff note: its path and its unified-diff hunks. */ +/** One file of a diff-hook full-diff note: its path, its line counts and its unified-diff hunks. */ export interface Section { path: string; + added: number; + removed: number; patch: string; } @@ -12,7 +14,7 @@ export interface CallSection extends Section { * The files of `~/.genesis-tools/agents/hook-diffs/.md`, which `tools agents hooks` writes when * a message cuts lines: `## (+a -b)`, a blank line, then the hunks in a ```diff fence. */ -const SECTION = /^## (.+?) \(\+\d+ -\d+\)\n\n```diff\n([\s\S]*?)\n```$/gm; +const SECTION = /^## (.+?) \(\+(\d+) -(\d+)\)\n\n```diff\n([\s\S]*?)\n```$/gm; /** * The hunks only. The note's fence starts with git's `diff --git` and `index` lines, which the Code @@ -27,7 +29,12 @@ function hunksOf(patch: string): string { export function sectionsOf(text: string): Section[] { return [...text.matchAll(SECTION)] - .map((match) => ({ path: match[1] ?? "", patch: hunksOf(match[2] ?? "") })) + .map((match) => ({ + path: match[1] ?? "", + added: Number(match[2]), + removed: Number(match[3]), + patch: hunksOf(match[4] ?? ""), + })) .filter((section) => section.patch.length > 0); } diff --git a/plugins/genesis-tools-mods/tests/fold.test.tsx b/plugins/genesis-tools-mods/tests/fold.test.tsx new file mode 100644 index 0000000000..ba9878aa6f --- /dev/null +++ b/plugins/genesis-tools-mods/tests/fold.test.tsx @@ -0,0 +1,114 @@ +import type { On } from "claude-code"; +import { expect, mock, test } from "claude-code/testing"; + +// Run with `claude plugin test plugins/genesis-tools-mods`; the repo's bun runner skips this folder. + +const NOTE = [ + "# Diff of toolu_a", + "", + "## /repo/src/a.ts (+2 -1)", + "", + "```diff", + "@@ -1,2 +1,3 @@", + "-old", + "+new", + "+more", + " same", + "```", + "", + "## /repo/src/b.ts (+1 -0)", + "", + "```diff", + "@@ -1,1 +1,2 @@", + " keep", + "+added", + "```", +].join("\n"); + +const GROUP = { + calls: [ + { + tool_use_id: "toolu_a", + tool: "Bash", + input: { command: "perl -pi -e s/old/new/ src/a.ts" }, + isRunning: false, + isErrored: false, + isInterrupted: false, + }, + ], + isActive: false, + isExpanded: false, +}; + +/** The engine's own group line, beneath the plugin, and the files the plugin reads. */ +function engine(on: On, files: Record): void { + mock.env(on, { HOME: "/home/test" }); + on("fs.read", async (_$, e) => { + const text = files[e.path]; + + if (text === undefined) { + throw new Error(`no file ${e.path}`); + } + + return { value: text }; + }); + on("ui.render", { component: "ToolGroup" }, async ($, e) => { + const { Text } = $.ui.resolve(e); + + return ran 1 shell command; + }); +} + +const NOTE_PATH = "/home/test/.genesis-tools/agents/hook-diffs/toolu_a.md"; + +test("each file folds on its own, and the row's Button folds or opens them all", async ($, on) => { + engine(on, { [NOTE_PATH]: NOTE }); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: GROUP, + requestId: "group-1", + viewport: { columns: 160, rows: 40 }, + }); + const A = "group-1|toolu_a|/repo/src/a.ts:fold"; + const B = "group-1|toolu_a|/repo/src/b.ts:fold"; + + expect(await ui.find({ text: "ran 1 shell command" })).toBeDefined(); + expect((await ui.find({ key: "group-1:fold" }))?.text).toBe("files only"); + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + expect((await ui.find({ key: A }))?.text).toBe("[-]"); + + await ui.press({ key: A }); + + expect((await ui.find({ key: A }))?.text).toBe("[+]"); + expect((await ui.find({ key: B }))?.text).toBe("[-]"); + expect(await ui.findAll({ type: "Code" })).toHaveLength(1); + expect((await ui.find({ key: "group-1:fold" }))?.text).toBe("files only"); + + await ui.press({ key: "group-1:fold" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + expect((await ui.find({ key: "group-1:fold" }))?.text).toBe("show diff"); + expect(await ui.find({ text: "/repo/src/b.ts" })).toBeDefined(); + + await ui.press({ key: "group-1:fold" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + await ui.unmount(); +}); + +test("an expanded group draws no diffs of its own", async ($, on) => { + engine(on, { [NOTE_PATH]: NOTE }); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: { ...GROUP, isExpanded: true }, + }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + await ui.unmount(); +}); diff --git a/plugins/genesis-tools-mods/types/index.d.ts b/plugins/genesis-tools-mods/types/index.d.ts new file mode 100644 index 0000000000..db95867b70 --- /dev/null +++ b/plugins/genesis-tools-mods/types/index.d.ts @@ -0,0 +1,8 @@ +/** The files whose full diff is folded to its name: `||`, the newest 500. */ +export type ClosedFiles = string[]; + +declare module "claude-code" { + interface PluginState { + "genesis-tools-mods": { closed: ClosedFiles }; + } +} diff --git a/scripts/test-args.ts b/scripts/test-args.ts index eb7820cb7e..8c636485c4 100644 --- a/scripts/test-args.ts +++ b/scripts/test-args.ts @@ -32,6 +32,10 @@ export const ALWAYS_EXCLUDES = [ // is excluded: `cloud/shared` holds real bun tests, which `bun test ../shared` // runs from that package. "**/DevDashboard/cloud/web/**", + // Claude Code mod tests: they import `claude-code/testing`, which only `claude plugin test + // plugins/genesis-tools-mods` provides. The mod's pure helpers are tested by bun in + // src/agents/lib/hooks/mods-plugin.test.ts. + "**/plugins/genesis-tools-mods/tests/**", ]; /** diff --git a/src/agents/README.md b/src/agents/README.md index d4f717c50a..3078c3ebf8 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -213,7 +213,11 @@ message cut lines, it draws the call's whole diff (the `hook-diffs/.md` no diff element under the call's row, so the 10,000-character message limit no longer hides the rest. A running session needs `/reload-plugins`. `tools agents hooks mods` shows whether it is installed; `tools agents hooks config set mods.fullDiff false --write` turns it off without an uninstall (the mod reads -the hooks file). It draws nothing while `diff.fullDiff` is `off`. The mod API is early access in Claude Code +the hooks file). It draws nothing while `diff.fullDiff` is `off`. While the plugin is enabled in the user settings +and `mods.fullDiff` is on, the diff hook prints no diff for Claude: it writes every diff to its note, and the mod +draws it, so it is not drawn twice (Codex and Grok keep the hook's message). Each file has a `[-]` / `[+]` button +that folds its diff to its name and back, and `[ files only ]` above them folds every file; a click on the diff +itself still opens the group's calls. Its tests run with `claude plugin test plugins/genesis-tools-mods`. The mod API is early access in Claude Code (2.1.287 and later), so a release can change it; the mod then draws nothing and the hook message is unchanged. **The idle-compaction mod.** The same plugin compacts an idle Claude session 56 minutes after its last turn diff --git a/src/agents/commands/hooks.ts b/src/agents/commands/hooks.ts index 726f14b7ff..18829378a9 100644 --- a/src/agents/commands/hooks.ts +++ b/src/agents/commands/hooks.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join } from "node:path"; import { suggestCommand, suggestEnumFlag } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { ui } from "@genesiscz/utils/cli/ui"; @@ -37,13 +38,26 @@ import { hooksDistPath, type InstallResult, installAndPoint, + readSettings, repoRoot, uninstallHooks, wiringStatus, } from "../lib/hooks/install"; -import { claudePlugin, MODS_PLUGIN, MODS_SCOPES, type ModsScope, modsPluginState } from "../lib/hooks/mods"; +import { + claudePlugin, + claudeUserSettingsPath, + MODS_PLUGIN, + MODS_SCOPES, + type ModsScope, + modsPluginState, + modsWatched, + PLUGIN_DIRS_VAR, + pluginDirsOf, + withModsWatch, +} from "../lib/hooks/mods"; import { nativeDiffForced, nativeDiffState, setNativeDiff } from "../lib/hooks/native-diff"; import { SETTABLE_KEYS, type SetResult, setHooksConfig } from "../lib/hooks/set-config"; +import { writeJsonFile } from "../lib/hooks/write-json"; import { registerRuleCommands, registerRulePicker, ruleState, shortPath } from "./hooks-rules"; const HARNESSES: readonly HarnessName[] = ["claude", "codex", "grok"]; @@ -761,11 +775,51 @@ function registerMods(hooks: Command): void { ui.raw(pc.yellow(`diff.fullDiff is off, so no call has a full-diff note for the mod to draw.`)); } - if (!state.installed) { + const watched = modsWatched(readSettings(claudeUserSettingsPath())); + + ui.raw( + `hot reload: ${watched ? pc.green(`on (${PLUGIN_DIRS_VAR} in the settings env)`) : pc.dim(`off: ${toolCommand("agents hooks mods watch", "--write")}`)}` + ); + + if (!state.installed && !watched) { ui.raw(`Install: ${toolCommand("agents hooks mods install", "--write")}`); } }); + mods.command("watch") + .description( + `Load the mods from this checkout in every new session and reload them on each save (${PLUGIN_DIRS_VAR} in the Claude settings env)` + ) + .option("--off", "Stop: take the folder out of the settings env") + .option("--write", "Actually change the settings; without it this is a dry run") + .action((options: { off?: boolean; write?: boolean }) => { + const path = claudeUserSettingsPath(); + const folder = join(repoRoot(), "plugins", "genesis-tools-mods"); + const before = readSettings(path); + const after = withModsWatch(before, folder, !options.off); + const changed = SafeJSON.stringify(after) !== SafeJSON.stringify(before); + + ui.raw(`${PLUGIN_DIRS_VAR}: ${pluginDirsOf(after).join(", ") || "(unset)"}`); + + if (!changed) { + ui.raw(pc.dim(`${path} already says so.`)); + return; + } + + if (!options.write) { + ui.raw(pc.yellow(`dry run: ${path} was not changed. Pass --write to apply.`)); + return; + } + + writeJsonFile(path, after); + ui.raw(pc.green(`wrote ${path}`)); + ui.raw( + pc.dim( + "A session started from now on loads the folder and reloads the mod on each save. A running session keeps what it loaded until it restarts." + ) + ); + }); + mods.command("install") .description("Install the mods plugin from this repository's marketplace") .option("--scope ", `Where Claude Code records it: ${MODS_SCOPES.join(", ")}`, "user") diff --git a/src/agents/lib/hooks/diff/diff.test.ts b/src/agents/lib/hooks/diff/diff.test.ts index 72d247dd4b..a81d6073ff 100644 --- a/src/agents/lib/hooks/diff/diff.test.ts +++ b/src/agents/lib/hooks/diff/diff.test.ts @@ -63,6 +63,7 @@ import { composePostDiff, type DiffDecision, flushDeferred, + modDrawsDiffs, noteFileToolInput, renamesIn, runDiffPost, @@ -3771,6 +3772,37 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () } }); + it("leaves a small diff to the full-diff mod: the note holds it and the message is empty", () => { + const current = begin({ command: "perl -pi -e 1 small.ts", toolUseId: `call-mod-${calls}` }, quiet); + writeFileSync(join(repo, "small.ts"), "one\ntwo\n"); + const decision = runDiffPost({ + payload: current, + config: { ...DEFAULT_HOOKS_CONFIG, diff: { ...quiet, fullDiff: "path" } }, + drawnByMod: true, + }); + const path = decision.stats?.fullDiff ?? ""; + + try { + expect(decision.decision).toBe("silent"); + expect(decision.message).toBeUndefined(); + expect(decision.files).toEqual([join(repo, "small.ts")]); + expect(readFileSync(path, "utf8")).toContain("+two"); + } finally { + rmSync(join(repo, "small.ts"), { force: true }); + rmSync(path, { force: true }); + } + }); + + it("lets the mod draw only for Claude, with mods.fullDiff on, a note to read and the plugin enabled", () => { + const on = { ...DEFAULT_HOOKS_CONFIG, mods: { ...DEFAULT_HOOKS_CONFIG.mods, fullDiff: true } }; + + expect(modDrawsDiffs(on, "claude", () => true)).toBe(true); + expect(modDrawsDiffs(on, "claude", () => false)).toBe(false); + expect(modDrawsDiffs(on, "codex", () => true)).toBe(false); + expect(modDrawsDiffs({ ...on, mods: { ...on.mods, fullDiff: false } }, "claude", () => true)).toBe(false); + expect(modDrawsDiffs({ ...on, diff: { ...on.diff, fullDiff: "off" } }, "claude", () => true)).toBe(false); + }); + it("renders a file once when the command names it through another path to the same folder", () => { // `/tmp` is a symlink to `/private/tmp` on macOS: git names the root by its real path and // the command by the typed one, and both used to render, 84 times in one day. diff --git a/src/agents/lib/hooks/diff/run.ts b/src/agents/lib/hooks/diff/run.ts index fd80528a2a..18f0d1e733 100644 --- a/src/agents/lib/hooks/diff/run.ts +++ b/src/agents/lib/hooks/diff/run.ts @@ -30,6 +30,7 @@ import { gitObjectSink } from "../../changes/objects"; import { type DiffConfig, diffFor, type HooksConfig } from "../config"; import { committedPaths, gitOut, lastHistoryMove, objectId, statusOf, untrackedFilesIn } from "../git"; import { hookDiag } from "../log"; +import { modsPluginEnabled } from "../mods"; import { callDir, safeSegment } from "../paths"; import { type HookPayload, normalizeEvent } from "../payload"; import { @@ -605,6 +606,19 @@ export function deferredMessageParts(flushed: DiffDecision[]): string[] { ); } +/** The real Claude settings, never read by a test (they would make the message depend on this machine). */ +function installedMod(): boolean { + return !isTestProcess() && modsPluginEnabled(); +} + +/** + * Claude Code with the full-diff mod (`genesis-tools-mods`) installed and `mods.fullDiff` on: the mod draws + * each call's diff from its note, so the hook's own message would draw it twice. Codex and Grok have no mods. + */ +export function modDrawsDiffs(config: HooksConfig, harness: string, isEnabled = installedMod): boolean { + return harness === "claude" && config.mods.fullDiff && diffFor(config, harness).fullDiff !== "off" && isEnabled(); +} + /** * One post phase: background tasks that have ended share half of the message, and this call gets * what remains. The sum, headings included, stays inside `maxMessageChars`. Claude Code replaces @@ -627,7 +641,8 @@ export function composePostDiff({ const flushedChars = flushedParts.reduce((total, part) => total + part.length + 2, 0); const heading = commandLines(payload.command, diffFor(config, payload.harness).commandLines); const room = Math.max(0, maxChars - flushedChars - (heading ? heading.length + 1 : 0)); - const decision = fitDecision(runDiffPost({ payload, config, record, maxChars: room }), room); + const drawnByMod = modDrawsDiffs(config, payload.harness); + const decision = fitDecision(runDiffPost({ payload, config, record, maxChars: room, drawnByMod }), room); const own = decision.message ? [heading ? `${heading}\n${decision.message}` : decision.message] : []; const message = [...own, ...flushedParts].join("\n\n"); @@ -1129,12 +1144,18 @@ export function runDiffPost({ config, record = recordBashEdits, maxChars, + drawnByMod = false, }: { payload: HookPayload; config: HooksConfig; record?: EditRecorder; /** The message budget when it must share the hook's one message (`flushDeferred`); else `diff.maxMessageChars`. */ maxChars?: number; + /** + * The full-diff mod draws this call's diff (`modDrawsDiffs`): every diff goes to its note, and the message + * keeps only the notes under it, so the diff is not drawn twice. + */ + drawnByMod?: boolean; }): DiffDecision { const diff = diffFor(config, payload.harness); @@ -1584,6 +1605,29 @@ export function runDiffPost({ return { decision: "noted", reason, message: notes.join("\n"), files: [], others }; } + if (drawnByMod) { + const path = writeFullDiff({ + toolUseId: payload.toolUseId, + command: payload.command, + parts: [...blocks.flatMap((block) => (block.source ? [block.source] : [])), ...overflow], + notShown: notShown.filter((path) => !overflow.some((part) => part.label === path)), + }); + + if (path) { + const message = notes.join("\n"); + + return { + decision: notes.length > 0 ? "noted" : "silent", + reason: "the full-diff mod draws the diff", + ...(message ? { message } : {}), + files, + attributed, + others, + stats: { chars: message.length, blocks: blocks.length, elided: 0, dropped: 0, fullDiff: path }, + }; + } + } + const noteChars = notes.reduce((total, line) => total + line.length + 2, 0); const budget = Math.max(0, (maxChars ?? diff.maxMessageChars) - noteChars); let fitted = fitMessage(blocks, { ...diff, maxMessageChars: budget }); diff --git a/src/agents/lib/hooks/hooks.test.ts b/src/agents/lib/hooks/hooks.test.ts index 6eb9d2a25a..345a12e208 100644 --- a/src/agents/lib/hooks/hooks.test.ts +++ b/src/agents/lib/hooks/hooks.test.ts @@ -54,7 +54,7 @@ import { wiringStatus, } from "./install"; import { logDecision, releaseRotationLock, setDiagLogPath, setMaxLogBytes, takeRotationLock } from "./log"; -import { MODS_PLUGIN, modsPluginState } from "./mods"; +import { MODS_PLUGIN, modsPluginEnabled, modsPluginState, modsWatched, pluginDirsOf, withModsWatch } from "./mods"; import { nativeDiffState } from "./native-diff"; import { matchesGlob, resolveOutcome } from "./outcome"; import { callDir, hookDataRoot, safeSegment, sessionDir } from "./paths"; @@ -684,6 +684,42 @@ describe("applySetting", () => { expect(modsPluginState("not json")).toEqual({ installed: false, enabled: false }); }); + it("reads whether the user settings enable the mods plugin", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-mods-settings-")); + const settings = join(dir, "settings.json"); + + writeFileSync(settings, SafeJSON.stringify({ enabledPlugins: { [MODS_PLUGIN]: true } })); + expect(modsPluginEnabled(settings)).toBe(true); + writeFileSync(settings, SafeJSON.stringify({ enabledPlugins: { [MODS_PLUGIN]: false } })); + expect(modsPluginEnabled(settings)).toBe(false); + writeFileSync(settings, "not json"); + expect(modsPluginEnabled(settings)).toBe(false); + expect(modsPluginEnabled(join(dir, "missing.json"))).toBe(false); + }); + + it("mods watch adds the mods folder to CLAUDE_CODE_PLUGIN_DIRS and takes it out again, keeping the rest", () => { + const folder = "/repo/plugins/genesis-tools-mods"; + const settings = { model: "opus", env: { OTHER: "1", CLAUDE_CODE_PLUGIN_DIRS: "/a/one" } }; + const on = withModsWatch(settings, folder, true); + + expect(pluginDirsOf(on)).toEqual(["/a/one", folder]); + expect(modsWatched(on)).toBe(true); + expect(withModsWatch(on, folder, true)).toEqual(on); + expect(on.model).toBe("opus"); + + const off = withModsWatch(on, folder, false); + + expect(off).toEqual(settings); + expect(withModsWatch({ env: { CLAUDE_CODE_PLUGIN_DIRS: `${folder}/` } }, folder, false)).toEqual({}); + expect(modsWatched({})).toBe(false); + + const dir = mkdtempSync(join(tmpdir(), "gt-mods-watch-")); + const path = join(dir, "settings.json"); + + writeFileSync(path, SafeJSON.stringify(on)); + expect(modsPluginEnabled(path)).toBe(true); + }); + it("agentsTalk.hint is a boolean switch that ships on and survives a load", () => { expect(DEFAULT_HOOKS_CONFIG.agentsTalk.hint).toBe(true); expect(applySetting(DEFAULT_HOOKS_CONFIG, "agentsTalk.hint", "false").agentsTalk.hint).toBe(false); diff --git a/plugins/genesis-tools-mods/hooks/mods.test.ts b/src/agents/lib/hooks/mods-plugin.test.ts similarity index 88% rename from plugins/genesis-tools-mods/hooks/mods.test.ts rename to src/agents/lib/hooks/mods-plugin.test.ts index f092db2d93..736b1c77b7 100644 --- a/plugins/genesis-tools-mods/hooks/mods.test.ts +++ b/src/agents/lib/hooks/mods-plugin.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { SafeJSON } from "@genesiscz/utils/json"; import { compactionEndsIdleWait, IDLE_COMPACT_DEFAULTS, @@ -6,8 +7,8 @@ import { idleCompactSettings, idleCompactSkip, idleNotice, -} from "./idle"; -import { fullDiffEnabled, sectionsOf, sectionsOfCalls } from "./sections"; +} from "../../../../plugins/genesis-tools-mods/hooks/idle"; +import { fullDiffEnabled, sectionsOf, sectionsOfCalls } from "../../../../plugins/genesis-tools-mods/hooks/sections"; const NOTE = [ "# Diff of toolu_x", @@ -47,6 +48,10 @@ describe("the full-diff mod's note reader", () => { expect(sections.map((section) => section.path)).toEqual(["/repo/src/a.ts", "/repo/src/b.ts"]); expect(sections[0]?.patch).toBe("@@ -1,2 +1,2 @@\n-a\n+b\n same"); expect(sections[1]?.patch).toBe("@@ -1,1 +0,0 @@\n-gone"); + expect(sections.map((section) => [section.added, section.removed])).toEqual([ + [1, 1], + [0, 1], + ]); expect(sectionsOf("# Diff of toolu_y\n\n```sh\nls\n```\n")).toEqual([]); }); @@ -91,13 +96,13 @@ describe("idle compaction", () => { expect(idleCompactSettings("not json")).toEqual(IDLE_COMPACT_DEFAULTS); expect( idleCompactSettings( - JSON.stringify({ mods: { idleCompact: false, idleCompactMinutes: 50, idleCompactMinTokens: 0 } }) + SafeJSON.stringify({ mods: { idleCompact: false, idleCompactMinutes: 50, idleCompactMinTokens: 0 } }) ) ).toEqual({ enabled: false, minutes: 50, minTokens: 0 }); // 60 minutes or more would fire after the cache expired. - expect(idleCompactSettings(JSON.stringify({ mods: { idleCompactMinutes: 60, idleCompact: "no" } }))).toEqual( - IDLE_COMPACT_DEFAULTS - ); + expect( + idleCompactSettings(SafeJSON.stringify({ mods: { idleCompactMinutes: 60, idleCompact: "no" } })) + ).toEqual(IDLE_COMPACT_DEFAULTS); }); it("compacts only when on, drawn and over the floor", () => { diff --git a/src/agents/lib/hooks/mods.ts b/src/agents/lib/hooks/mods.ts index 243dd9117b..7a098c7431 100644 --- a/src/agents/lib/hooks/mods.ts +++ b/src/agents/lib/hooks/mods.ts @@ -1,3 +1,7 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, delimiter, join } from "node:path"; +import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -58,6 +62,79 @@ export function modsPluginState(listJson: string): ModsPluginState { }; } +/** `/settings.json`: the user settings, where `CLAUDE_CONFIG_DIR` moves the claude dir. */ +export function claudeUserSettingsPath(): string { + return join(env.paths.getClaudeConfigDir() ?? join(homedir(), ".claude"), "settings.json"); +} + +/** The folder plugins name a session's own plugin folders by, in the process env or settings `env`. */ +export const PLUGIN_DIRS_VAR = "CLAUDE_CODE_PLUGIN_DIRS"; + +const MODS_FOLDER_NAME = "genesis-tools-mods"; + +function settingsEnv(settings: unknown): Record { + const block = isRecord(settings) ? settings.env : undefined; + + return isRecord(block) ? block : {}; +} + +/** The folders `CLAUDE_CODE_PLUGIN_DIRS` names in a settings object's `env` block. */ +export function pluginDirsOf(settings: unknown): string[] { + const value = settingsEnv(settings)[PLUGIN_DIRS_VAR]; + + return typeof value === "string" ? value.split(delimiter).filter((dir) => dir.length > 0) : []; +} + +/** Whether the settings load a mods plugin folder (`mods watch`), hot-reloaded on every save. */ +export function modsWatched(settings: unknown): boolean { + return pluginDirsOf(settings).some((dir) => basename(dir.replace(/\/+$/, "")) === MODS_FOLDER_NAME); +} + +/** + * The settings with the mods folder added to (or, `on: false`, every mods folder taken out of) + * `env.CLAUDE_CODE_PLUGIN_DIRS`. Every interactive session loads such a folder as a `--plugin-dir` and + * watches it, so a save reloads the mod; an installed plugin is never watched, only re-read by + * `/reload-plugins`. Other folders in the list and other env keys are kept; an emptied list is removed. + */ +export function withModsWatch(settings: unknown, folder: string, on: boolean): Record { + const base = isRecord(settings) ? { ...settings } : {}; + const env = { ...settingsEnv(settings) }; + const others = pluginDirsOf(settings).filter((dir) => basename(dir.replace(/\/+$/, "")) !== MODS_FOLDER_NAME); + const dirs = on ? [...others, folder] : others; + + if (dirs.length > 0) { + env[PLUGIN_DIRS_VAR] = dirs.join(delimiter); + } else { + delete env[PLUGIN_DIRS_VAR]; + } + + if (Object.keys(env).length > 0) { + base.env = env; + } else { + delete base.env; + } + + return base; +} + +/** + * Whether the user settings load the mods plugin: enabled in `enabledPlugins` (`hooks mods install`) or + * named in `env.CLAUDE_CODE_PLUGIN_DIRS` (`hooks mods watch`), in `/settings.json`. A file read, not `claude plugin list`: the diff hook asks on every Bash call. + * A project or local install is not seen, and a session that has not run `/reload-plugins` since the install + * does not draw yet. + */ +export function modsPluginEnabled(settingsPath = claudeUserSettingsPath()): boolean { + try { + const settings: unknown = SafeJSON.parse(readFileSync(settingsPath, "utf8"), { strict: true }); + const enabled = isRecord(settings) ? settings.enabledPlugins : undefined; + + return (isRecord(enabled) && enabled[MODS_PLUGIN] === true) || modsWatched(settings); + } catch (err) { + logger.debug({ err, settingsPath }, "Claude settings unreadable: the mods plugin counts as off"); + return false; + } +} + export interface ClaudePluginRun { ok: boolean; stdout: string; From cc4da5230bafeb4d379a3890c72254335b5076f7 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 19 Oct 2026 11:16:46 +0200 Subject: [PATCH 2/5] fix(agents, hooks): a dirty file moved by git mv reads as a move, deleted and long files fold by default, and the full-diff note keeps a hunk's blank context line --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- plugins/genesis-tools-mods/.gitignore | 2 + plugins/genesis-tools-mods/hooks/register.tsx | 40 +++++++++-------- plugins/genesis-tools-mods/hooks/sections.ts | 43 ++++++++++++++++++- .../genesis-tools-mods/tests/fold.test.tsx | 29 +++++++++++++ plugins/genesis-tools-mods/types/index.d.ts | 6 +-- src/agents/lib/hooks/diff/diff.test.ts | 36 ++++++++++++++++ src/agents/lib/hooks/diff/full.ts | 11 ++++- src/agents/lib/hooks/diff/run.ts | 37 ++++++++++++++-- src/agents/lib/hooks/mods-plugin.test.ts | 38 +++++++++++++++- 11 files changed, 216 insertions(+), 30 deletions(-) create mode 100644 plugins/genesis-tools-mods/.gitignore diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5a920b5e1c..1be42cdbc7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -27,7 +27,7 @@ { "name": "genesis-tools-mods", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.3.0", + "version": "1.4.0", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index b14f5b4324..f4afb6d2db 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools-mods", - "version": "1.3.0", + "version": "1.4.0", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Configured in the hooks file: tools agents hooks config set mods.fullDiff|mods.idleCompact true|false.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools-mods/.gitignore b/plugins/genesis-tools-mods/.gitignore new file mode 100644 index 0000000000..2dc60fe8bb --- /dev/null +++ b/plugins/genesis-tools-mods/.gitignore @@ -0,0 +1,2 @@ +# Claude Code writes this beside .claude-plugin/types/ (ignored there) while it watches the folder. +/tsconfig.json diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 11485bfcee..a4e63472e6 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -1,8 +1,8 @@ import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from "claude-code"; import { atom, read, update } from "claude-code"; -import type { ClosedFiles } from "../types"; +import type { FlippedFiles } from "../types"; import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; -import { type CallSection, fullDiffEnabled, sectionsOfCalls } from "./sections"; +import { type CallSection, fullDiffEnabled, isDeletion, openByDefault, sectionsOfCalls } from "./sections"; // The full-diff mod. When the diff hook (`tools agents hooks`) cut a Bash call's message, it wrote the // whole diff to ~/.genesis-tools/agents/hook-diffs/.md. This draws those hunks with Claude @@ -91,11 +91,11 @@ async function compactIfIdle($: EngineInterface): Promise { } /** - * The files whose diff is folded, each as `fileKey`: its row (a ToolGroup's `requestId`, or a call's - * `tool_use_id` in an expanded group), its call and its path. Session state, so a fold survives a redraw and - * a reload of the mod. The newest 500 are kept. + * The files the person folded or opened against their default (`openByDefault`), each as `fileKey`: its row + * (a ToolGroup's `requestId`, or a call's `tool_use_id` in an expanded group), its call and its path. Session + * state, so a fold survives a redraw and a reload of the mod. The newest 500 are kept. */ -const closed = atom({ plugin: "genesis-tools-mods", key: "closed" } as const, [] as ClosedFiles); +const flipped = atom({ plugin: "genesis-tools-mods", key: "flipped" } as const, [] as FlippedFiles); function fileKey(row: string, section: CallSection): string { return `${row}|${section.call}|${section.path}`; @@ -118,26 +118,29 @@ async function drawDiffs( drawn: RenderElement ): Promise { const { Box, Button, Code, Text } = ui; - const folded = new Set(await read($, closed)); + const flips = new Set(await read($, flipped)); const keys = sections.map((section) => fileKey(row, section)); - const isAllFolded = keys.every((key) => folded.has(key)); + const defaults = openByDefault(sections); + const isOpen = (index: number) => (defaults[index] ?? true) !== flips.has(keys[index] ?? ""); + const isAllFolded = keys.every((_, index) => !isOpen(index)); const added = sections.reduce((sum, section) => sum + section.added, 0); const removed = sections.reduce((sum, section) => sum + section.removed, 0); const files = new Set(sections.map((section) => section.path)).size; - const setFolded = (targets: readonly string[], fold: boolean) => { - void update($, closed, (list) => { - const rest = list.filter((key) => !targets.includes(key)); + /** Opens or folds the files at `indexes`: a file is flipped exactly when that differs from its default. */ + const setOpen = (indexes: readonly number[], open: boolean) => { + const targets = indexes.map((index) => keys[index] ?? ""); + const flip = indexes.filter((index) => open !== (defaults[index] ?? true)).map((index) => keys[index] ?? ""); - return (fold ? [...rest, ...targets] : rest).slice(-500); - }); + void update($, flipped, (list) => [...list.filter((key) => !targets.includes(key)), ...flip].slice(-500)); }; + const all = keys.map((_, index) => index); return ( {drawn} - @@ -146,19 +149,20 @@ async function drawDiffs( +{added} -{removed} - {sections.map((section) => { - const key = fileKey(row, section); - const isFolded = folded.has(key); + {sections.map((section, index) => { + const key = keys[index] ?? fileKey(row, section); + const isFolded = !isOpen(index); return ( - {section.path} +{section.added} -{section.removed} + {isDeletion(section) ? deleted : null} {isFolded ? null : } diff --git a/plugins/genesis-tools-mods/hooks/sections.ts b/plugins/genesis-tools-mods/hooks/sections.ts index 9f25c72baa..be6378aa84 100644 --- a/plugins/genesis-tools-mods/hooks/sections.ts +++ b/plugins/genesis-tools-mods/hooks/sections.ts @@ -28,6 +28,8 @@ function hunksOf(patch: string): string { } export function sectionsOf(text: string): Section[] { + const seen = new Set(); + return [...text.matchAll(SECTION)] .map((match) => ({ path: match[1] ?? "", @@ -35,7 +37,46 @@ export function sectionsOf(text: string): Section[] { removed: Number(match[3]), patch: hunksOf(match[4] ?? ""), })) - .filter((section) => section.patch.length > 0); + .filter((section) => { + // A file the note holds twice (its block and its overflow part) is drawn once. + const key = `${section.path}\n${section.patch}`; + + if (section.patch.length === 0 || seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +/** A file the call deleted whole: no line added, and its one hunk ends at line 0. */ +export function isDeletion(section: Section): boolean { + return section.added === 0 && /^@@ -\d+(?:,\d+)? \+0,0 @@/.test(section.patch); +} + +/** A file over this many changed lines starts folded; so does every file once a row has opened this many. */ +export const OPEN_FILE_LINES = 120; +export const OPEN_ROW_LINES = 300; + +/** + * Which files start open: not a whole deletion (its text is gone, the name says enough), not a file over + * `OPEN_FILE_LINES`, and only while the row's open files stay within `OPEN_ROW_LINES`. A click on `[+]` opens + * any of them (2026-10-18: a `git mv` call drew 1,300 deleted lines). + */ +export function openByDefault(sections: readonly Section[]): boolean[] { + let open = 0; + + return sections.map((section) => { + const lines = section.added + section.removed; + + if (isDeletion(section) || lines > OPEN_FILE_LINES || open + lines > OPEN_ROW_LINES) { + return false; + } + + open += lines; + return true; + }); } /** diff --git a/plugins/genesis-tools-mods/tests/fold.test.tsx b/plugins/genesis-tools-mods/tests/fold.test.tsx index ba9878aa6f..d60ff88bc9 100644 --- a/plugins/genesis-tools-mods/tests/fold.test.tsx +++ b/plugins/genesis-tools-mods/tests/fold.test.tsx @@ -99,6 +99,35 @@ test("each file folds on its own, and the row's Button folds or opens them all", await ui.unmount(); }); +test("a deleted file starts folded, and its [+] opens it", async ($, on) => { + const gone = ["## /repo/src/gone.ts (+0 -2)", "", "```diff", "@@ -1,2 +0,0 @@", "-one", "-two", "```"].join("\n"); + + engine(on, { [NOTE_PATH]: `${NOTE}\n\n${gone}` }); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: GROUP, + requestId: "group-2", + }); + const G = "group-2|toolu_a|/repo/src/gone.ts:fold"; + + expect((await ui.find({ key: G }))?.text).toBe("[+]"); + expect(await ui.find({ text: "deleted" })).toBeDefined(); + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + + await ui.press({ key: G }); + + expect((await ui.find({ key: G }))?.text).toBe("[-]"); + expect(await ui.findAll({ type: "Code" })).toHaveLength(3); + + await ui.press({ key: "group-2:fold" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + await ui.unmount(); +}); + test("an expanded group draws no diffs of its own", async ($, on) => { engine(on, { [NOTE_PATH]: NOTE }); diff --git a/plugins/genesis-tools-mods/types/index.d.ts b/plugins/genesis-tools-mods/types/index.d.ts index db95867b70..d5872afb81 100644 --- a/plugins/genesis-tools-mods/types/index.d.ts +++ b/plugins/genesis-tools-mods/types/index.d.ts @@ -1,8 +1,8 @@ -/** The files whose full diff is folded to its name: `||`, the newest 500. */ -export type ClosedFiles = string[]; +/** The files folded or opened against their default: `||`, the newest 500. */ +export type FlippedFiles = string[]; declare module "claude-code" { interface PluginState { - "genesis-tools-mods": { closed: ClosedFiles }; + "genesis-tools-mods": { flipped: FlippedFiles }; } } diff --git a/src/agents/lib/hooks/diff/diff.test.ts b/src/agents/lib/hooks/diff/diff.test.ts index a81d6073ff..d89e39cc83 100644 --- a/src/agents/lib/hooks/diff/diff.test.ts +++ b/src/agents/lib/hooks/diff/diff.test.ts @@ -39,6 +39,7 @@ import { claimChange, claimFileName } from "./claim"; import { classifyChange, type DiffCategory } from "./classify"; import { changedFiles } from "./collect"; import { commandDirs, namedArguments } from "./command-paths"; +import { writeFullDiff } from "./full"; import { covers, mentionsFrom, mentionsOf, textPaths } from "./mentions"; import { assembleMessage, @@ -4225,6 +4226,41 @@ describe("the misfires of 2026-10-14", () => { git(["commit", "-qm", "mv done"]); }); + it("keeps a blank context line at the end of a hunk in the full-diff note", () => { + const path = writeFullDiff({ + toolUseId: `call-blank-${calls}`, + command: "perl -pi -e 1 a.ts", + parts: [{ label: "a.ts (+1 -0)", patch: "@@ -1,2 +1,3 @@\n one\n+two\n \n" }], + notShown: [], + }); + + try { + expect(readFileSync(path ?? "", "utf8")).toContain("@@ -1,2 +1,3 @@\n one\n+two\n \n```"); + } finally { + rmSync(path ?? "", { force: true }); + } + }); + + it("names a file with uncommitted edits, moved into a new folder, in one line and not as a deletion", () => { + writeFileSync(join(repo, "dirty-src.ts"), "committed\nbody\n"); + git(["add", "dirty-src.ts"]); + git(["commit", "-qm", "dirty mv"]); + writeFileSync(join(repo, "dirty-src.ts"), "committed\nbody\nUNCOMMITTED-EDIT\n"); + const past = new Date(Date.now() - 60_000); + utimesSync(join(repo, "dirty-src.ts"), past, past); + + const current = begin({ command: "mkdir -p moved/deeper && git mv dirty-src.ts moved/deeper/dirty-dst.ts" }); + + mkdirSync(join(repo, "moved", "deeper"), { recursive: true }); + git(["mv", "dirty-src.ts", "moved/deeper/dirty-dst.ts"]); + + const decision = runDiffPost({ payload: current, config: plain }); + + expect(decision.message ?? "").not.toContain("Deleted"); + expect(decision.message).toContain("dirty-src.ts → moved/deeper/dirty-dst.ts"); + git(["commit", "-qam", "dirty mv done"]); + }); + it("prints a block past maxBlockLines as its header and a count, and links the full diff", () => { const big = join(repo, "big.ts"); const diff = { ...plain.diff, maxBlockLines: 5, fullDiff: "path" as const }; diff --git a/src/agents/lib/hooks/diff/full.ts b/src/agents/lib/hooks/diff/full.ts index b14fadd968..7e0a381f28 100644 --- a/src/agents/lib/hooks/diff/full.ts +++ b/src/agents/lib/hooks/diff/full.ts @@ -51,7 +51,16 @@ export function writeFullDiff(options: { command.length > 4000 ? `${command.slice(0, 4000)}\n…` : command, fence, "", - ...options.parts.flatMap((part) => [`## ${part.label}`, "", `${fence}diff`, part.patch.trimEnd(), fence, ""]), + // Trailing newlines only: a blank context line is a lone space, and `trimEnd()` cut it, which left the hunk a + // line short of its header, so Claude Code drew the full-diff mod's copy as plain code (2026-10-19). + ...options.parts.flatMap((part) => [ + `## ${part.label}`, + "", + `${fence}diff`, + part.patch.replace(/\n+$/, ""), + fence, + "", + ]), ...(options.notShown.length > 0 ? ["## Changed, past the file cap", "", ...options.notShown.map((path) => `- ${path}`), ""] : []), diff --git a/src/agents/lib/hooks/diff/run.ts b/src/agents/lib/hooks/diff/run.ts index 18f0d1e733..3838f70366 100644 --- a/src/agents/lib/hooks/diff/run.ts +++ b/src/agents/lib/hooks/diff/run.ts @@ -248,11 +248,18 @@ export function renamesIn({ base, files, entries, + beforeOf, }: { root: string; base: string; files: ChangedFile[]; entries: StatusEntry[]; + /** + * The file's copy from before the call (`beforeCopy`), when it was dirty then. Its blob in `base` lacks the + * earlier uncommitted edits, so a dirty file moved by `git mv` matched nothing and printed as a whole + * deletion (2026-10-18: three Swift files of 782, 305 and 218 lines). + */ + beforeOf?: (absolute: string) => string | null; }): Array<{ from: string; to: string; @@ -327,8 +334,26 @@ export function renamesIn({ } const blobs = new Map(); + const copies = gone.flatMap((file) => { + const copy = beforeOf?.(file.path) ?? null; + + return copy ? [{ file, copy }] : []; + }); + const fromHead = gone.filter((file) => !copies.some((item) => item.file === file)); + + for (const chunk of inChunks(copies, RENAME_ARGS)) { + const out = gitOut(root, ["hash-object", "--", ...chunk.map((item) => item.copy)], { quiet: true }).split("\n"); + + chunk.forEach((item, index) => { + const hash = out[index]?.trim(); - for (const chunk of inChunks(gone, RENAME_ARGS)) { + if (hash) { + blobs.set(hash, [...(blobs.get(hash) ?? []), item.file.path]); + } + }); + } + + for (const chunk of inChunks(fromHead, RENAME_ARGS)) { const listing = gitOut(root, ["ls-tree", "-z", base, "--", ...chunk.map((file) => relative(root, file.path))], { quiet: true, }); @@ -1287,9 +1312,13 @@ export function runDiffPost({ } const changed = changedFiles({ root, since, config: diff, source: { entries: summary.entries, committed } }); - const renames = renamesIn({ root, base, files: changed, entries: summary.entries }).filter((pair) => - ownedRename(pair, { dir, root, context: attribution }) - ); + const renames = renamesIn({ + root, + base, + files: changed, + entries: summary.entries, + beforeOf: (absolute) => beforeCopy({ dir, root, absolute }), + }).filter((pair) => ownedRename(pair, { dir, root, context: attribution })); const renamed = new Set(renames.flatMap((pair) => [pair.from, pair.to])); const carried: string[] = []; const ownCommits = new Set(committed.map((name) => join(root, name))); diff --git a/src/agents/lib/hooks/mods-plugin.test.ts b/src/agents/lib/hooks/mods-plugin.test.ts index 736b1c77b7..0b54e41e0c 100644 --- a/src/agents/lib/hooks/mods-plugin.test.ts +++ b/src/agents/lib/hooks/mods-plugin.test.ts @@ -8,7 +8,14 @@ import { idleCompactSkip, idleNotice, } from "../../../../plugins/genesis-tools-mods/hooks/idle"; -import { fullDiffEnabled, sectionsOf, sectionsOfCalls } from "../../../../plugins/genesis-tools-mods/hooks/sections"; +import { + fullDiffEnabled, + isDeletion, + OPEN_FILE_LINES, + openByDefault, + sectionsOf, + sectionsOfCalls, +} from "../../../../plugins/genesis-tools-mods/hooks/sections"; const NOTE = [ "# Diff of toolu_x", @@ -81,6 +88,35 @@ describe("the full-diff mod's note reader", () => { expect(cut.map((section) => section.path)).toEqual(["/repo/src/a.ts"]); }); + it("draws a file the note holds twice once", () => { + const twice = [NOTE.split("## /repo/src/b.ts")[0], NOTE.split("## /repo/src/b.ts")[0]].join(""); + + expect(sectionsOf(twice).map((section) => section.path)).toEqual(["/repo/src/a.ts"]); + }); + + it("folds a whole deletion, a long file and what is past the row's budget by default", () => { + const file = (path: string, added: number, removed: number, patch = "@@ -1,2 +1,2 @@\n-a\n+b") => ({ + path, + added, + removed, + patch, + }); + const gone = file("gone.ts", 0, 782, "@@ -1,782 +0,0 @@\n-x"); + + expect(isDeletion(gone)).toBe(true); + expect(isDeletion(file("edit.ts", 0, 1, "@@ -3,2 +3,1 @@\n-x\n y"))).toBe(false); + expect( + openByDefault([ + file("small.ts", 1, 1), + gone, + file("long.ts", OPEN_FILE_LINES, 1), + file("mid1.ts", 100, 0), + file("mid2.ts", 100, 0), + file("mid3.ts", 100, 0), + ]) + ).toEqual([true, false, false, true, true, false]); + }); + it("is on unless the hooks file sets mods.fullDiff to false", () => { expect(fullDiffEnabled(undefined)).toBe(true); expect(fullDiffEnabled("not json")).toBe(true); From c52a11405714c1efec784f1fcf81413dc838ce81 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 04:31:59 +0200 Subject: [PATCH 3/5] feat(agents, hooks): the diff hook watches files a heredoc or -c/-e script names, a git pull, merge or rebase diff hides behind one show button, and a background task's heading ends a cut command --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- plugins/genesis-tools-mods/hooks/register.tsx | 142 ++++++++++++++---- plugins/genesis-tools-mods/hooks/sections.ts | 41 ++++- .../genesis-tools-mods/tests/fold.test.tsx | 67 +++++++-- plugins/genesis-tools-mods/types/index.d.ts | 5 +- src/agents/README.md | 5 +- src/agents/commands/hooks.ts | 1 + src/agents/lib/hooks/config.ts | 15 +- src/agents/lib/hooks/diff/command-paths.ts | 115 +++++++++++++- src/agents/lib/hooks/diff/diff.test.ts | 39 +++++ src/agents/lib/hooks/diff/run.ts | 9 +- src/agents/lib/hooks/hooks.test.ts | 1 + src/agents/lib/hooks/mods-plugin.test.ts | 14 ++ src/agents/lib/hooks/set-config.ts | 4 +- 15 files changed, 406 insertions(+), 56 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1be42cdbc7..0a2012e053 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -27,7 +27,7 @@ { "name": "genesis-tools-mods", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.4.0", + "version": "1.5.0", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index f4afb6d2db..21c7624c2c 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools-mods", - "version": "1.4.0", + "version": "1.5.0", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Configured in the hooks file: tools agents hooks config set mods.fullDiff|mods.idleCompact true|false.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index a4e63472e6..25ccbb98e2 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -1,8 +1,17 @@ import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from "claude-code"; import { atom, read, update } from "claude-code"; -import type { FlippedFiles } from "../types"; +import type { FlippedFiles, ShownCalls } from "../types"; import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; -import { type CallSection, fullDiffEnabled, isDeletion, openByDefault, sectionsOfCalls } from "./sections"; +import { + type CallSection, + collapseGitSyncEnabled, + commandOf, + fullDiffEnabled, + gitSyncOf, + isDeletion, + openByDefault, + sectionsOfCalls, +} from "./sections"; // The full-diff mod. When the diff hook (`tools agents hooks`) cut a Bash call's message, it wrote the // whole diff to ~/.genesis-tools/agents/hook-diffs/.md. This draws those hunks with Claude @@ -29,20 +38,45 @@ async function readHooksFile($: EngineInterface, dir: string | undefined): Promi return dir ? $.fs.read(`${dir}/hooks.json`).catch(() => undefined) : undefined; } -async function fullDiffs($: EngineInterface, ids: readonly string[]): Promise { - const dir = ids.length > 0 ? await agentsDir($) : undefined; +interface Diffs { + sections: CallSection[]; + /** The calls that run a git sync (`git pull`, …), each with that command, while `mods.collapseGitSync` is on. */ + syncs: Map; +} + +async function fullDiffs($: EngineInterface, calls: readonly { id: string; input: unknown }[]): Promise { + const none: Diffs = { sections: [], syncs: new Map() }; + const dir = calls.length > 0 ? await agentsDir($) : undefined; if (!dir) { - return []; + return none; } const hooksFile = await readHooksFile($, dir); if (!fullDiffEnabled(hooksFile)) { - return []; + return none; + } + + const ids = calls.map((call) => call.id); + const sections = await sectionsOfCalls({ + ids, + read: (id) => $.fs.read(`${dir}/hook-diffs/${id}.md`), + maxChars: MAX_CHARS, + }); + const syncs = new Map(); + + if (collapseGitSyncEnabled(hooksFile)) { + for (const call of calls) { + const sync = gitSyncOf(commandOf(call.input)); + + if (sync !== undefined) { + syncs.set(call.id, sync); + } + } } - return sectionsOfCalls({ ids, read: (id) => $.fs.read(`${dir}/hook-diffs/${id}.md`), maxChars: MAX_CHARS }); + return { sections, syncs }; } // The idle-compaction mod. Claude Code's own idle compaction fires once, 54 minutes after the last @@ -101,8 +135,12 @@ function fileKey(row: string, section: CallSection): string { return `${row}|${section.call}|${section.path}`; } +/** The git-sync calls whose hidden diff the person opened, as `|`; the newest 500. */ +const shownCalls = atom({ plugin: "genesis-tools-mods", key: "shown" } as const, [] as ShownCalls); + /** - * The full diffs under a row. Each file has its own `[-]`/`[+]` Button that folds its diff to its name and + * The full diffs under a row. The diff of a call that runs a git sync (`git pull`, …) starts hidden behind one + * `show diff from git pull` Button, and a `hide diff from git pull` Button folds it back. Each file has its own `[-]`/`[+]` Button that folds its diff to its name and * back; the row's Button folds every file, or opens them all once every file is folded. A click elsewhere on the * drawing reaches the engine's row, which opens the group's calls (the person's "expansion in expansion"). * @@ -114,18 +152,43 @@ async function drawDiffs( $: EngineInterface, ui: ElementTable, row: string, - sections: readonly CallSection[], + diffs: Diffs, drawn: RenderElement ): Promise { const { Box, Button, Code, Text } = ui; + const shown = new Set(await read($, shownCalls)); + const callKey = (call: string) => `${row}|${call}`; + const isHidden = (call: string) => diffs.syncs.has(call) && !shown.has(callKey(call)); + const sections = diffs.sections.filter((section) => !isHidden(section.call)); + const hidden = [...new Set(diffs.sections.map((section) => section.call))].filter(isHidden); + const setShown = (call: string, on: boolean) => { + const key = callKey(call); + + void update($, shownCalls, (list) => + [...list.filter((item) => item !== key), ...(on ? [key] : [])].slice(-500) + ); + }; const flips = new Set(await read($, flipped)); const keys = sections.map((section) => fileKey(row, section)); const defaults = openByDefault(sections); const isOpen = (index: number) => (defaults[index] ?? true) !== flips.has(keys[index] ?? ""); const isAllFolded = keys.every((_, index) => !isOpen(index)); - const added = sections.reduce((sum, section) => sum + section.added, 0); - const removed = sections.reduce((sum, section) => sum + section.removed, 0); - const files = new Set(sections.map((section) => section.path)).size; + const totals = (of: readonly CallSection[]) => ({ + files: new Set(of.map((section) => section.path)).size, + added: of.reduce((sum, section) => sum + section.added, 0), + removed: of.reduce((sum, section) => sum + section.removed, 0), + }); + const counts = ({ files, added, removed }: ReturnType) => [ + + {files} {files === 1 ? "file" : "files"} + , + + +{added} + , + + -{removed} + , + ]; /** Opens or folds the files at `indexes`: a file is flipped exactly when that differs from its default. */ const setOpen = (indexes: readonly number[], open: boolean) => { const targets = indexes.map((index) => keys[index] ?? ""); @@ -139,22 +202,39 @@ async function drawDiffs( {drawn} - - - - {files} {files === 1 ? "file" : "files"} - - +{added} - -{removed} - + {hidden.map((call) => ( + + + {counts(totals(diffs.sections.filter((section) => section.call === call)))} + + ))} + {sections.length === 0 ? null : ( + + + {counts(totals(sections))} + + )} {sections.map((section, index) => { const key = keys[index] ?? fileKey(row, section); const isFolded = !isOpen(index); + const sync = diffs.syncs.get(section.call); + const isFirstOfCall = sections.findIndex((other) => other.call === section.call) === index; return ( + {sync !== undefined && isFirstOfCall ? ( + + ) : null}