From 865ced72e407c79b89285427687ae2dd4a68c09a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 17:07:34 +0900 Subject: [PATCH] feat(hooks): add hashline-edit-diff-enhancer for Write tool diff metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures file content before/after Write tool execution and injects unified diff into tool output metadata. TUI reads metadata.diff for rendering. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/hashline-edit-diff-enhancer/hook.ts | 106 ++++++ .../hashline-edit-diff-enhancer/index.test.ts | 306 ++++++++++++++++++ .../hashline-edit-diff-enhancer/index.ts | 1 + 3 files changed, 413 insertions(+) create mode 100644 src/hooks/hashline-edit-diff-enhancer/hook.ts create mode 100644 src/hooks/hashline-edit-diff-enhancer/index.test.ts create mode 100644 src/hooks/hashline-edit-diff-enhancer/index.ts diff --git a/src/hooks/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts new file mode 100644 index 00000000..300a6988 --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -0,0 +1,106 @@ +import { log } from "../../shared" +import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils" + +interface HashlineEditDiffEnhancerConfig { + hashline_edit?: { enabled: boolean } +} + +type BeforeInput = { tool: string; sessionID: string; callID: string } +type BeforeOutput = { args: Record } +type AfterInput = { tool: string; sessionID: string; callID: string } +type AfterOutput = { title: string; output: string; metadata: Record } + +const STALE_TIMEOUT_MS = 5 * 60 * 1000 + +const pendingCaptures = new Map() + +function makeKey(sessionID: string, callID: string): string { + return `${sessionID}:${callID}` +} + +function cleanupStaleEntries(): void { + const now = Date.now() + for (const [key, entry] of pendingCaptures) { + if (now - entry.storedAt > STALE_TIMEOUT_MS) { + pendingCaptures.delete(key) + } + } +} + +function isWriteTool(toolName: string): boolean { + return toolName.toLowerCase() === "write" +} + +function extractFilePath(args: Record): string | undefined { + const path = args.path ?? args.filePath ?? args.file_path + return typeof path === "string" ? path : undefined +} + +async function captureOldContent(filePath: string): Promise { + try { + const file = Bun.file(filePath) + if (await file.exists()) { + return await file.text() + } + } catch { + log("[hashline-edit-diff-enhancer] failed to read old content", { filePath }) + } + return "" +} + +export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhancerConfig) { + const enabled = config.hashline_edit?.enabled ?? false + + return { + "tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => { + if (!enabled || !isWriteTool(input.tool)) return + + const filePath = extractFilePath(output.args) + if (!filePath) return + + cleanupStaleEntries() + const oldContent = await captureOldContent(filePath) + pendingCaptures.set(makeKey(input.sessionID, input.callID), { + content: oldContent, + filePath, + storedAt: Date.now(), + }) + }, + + "tool.execute.after": async (input: AfterInput, output: AfterOutput) => { + if (!enabled || !isWriteTool(input.tool)) return + + const key = makeKey(input.sessionID, input.callID) + const captured = pendingCaptures.get(key) + if (!captured) return + pendingCaptures.delete(key) + + const { content: oldContent, filePath } = captured + + let newContent: string + try { + newContent = await Bun.file(filePath).text() + } catch { + log("[hashline-edit-diff-enhancer] failed to read new content", { filePath }) + return + } + + const { additions, deletions } = countLineDiffs(oldContent, newContent) + const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath) + + output.metadata.filediff = { + file: filePath, + path: filePath, + before: oldContent, + after: newContent, + additions, + deletions, + } + + // TUI reads metadata.diff (unified diff string), not filediff object + output.metadata.diff = unifiedDiff + + output.title = filePath + }, + } +} diff --git a/src/hooks/hashline-edit-diff-enhancer/index.test.ts b/src/hooks/hashline-edit-diff-enhancer/index.test.ts new file mode 100644 index 00000000..ee39442e --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/index.test.ts @@ -0,0 +1,306 @@ +import { describe, test, expect, beforeEach } from "bun:test" +import { createHashlineEditDiffEnhancerHook } from "./hook" + +function makeInput(tool: string, callID = "call-1", sessionID = "ses-1") { + return { tool, sessionID, callID } +} + +function makeBeforeOutput(args: Record) { + return { args } +} + +function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record }>) { + return { + title: overrides?.title ?? "", + output: overrides?.output ?? "Successfully applied 1 edit(s)", + metadata: overrides?.metadata ?? { truncated: false }, + } +} + +type FileDiffMetadata = { + file: string + path: string + before: string + after: string + additions: number + deletions: number +} + +describe("hashline-edit-diff-enhancer", () => { + let hook: ReturnType + + beforeEach(() => { + hook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: true } }) + }) + + describe("tool.execute.before", () => { + test("captures old file content for write tool", async () => { + const filePath = import.meta.dir + "/index.test.ts" + const input = makeInput("write") + const output = makeBeforeOutput({ path: filePath, edits: [] }) + + await hook["tool.execute.before"](input, output) + + // given the hook ran without error, the old content should be stored internally + // we verify in the after hook test that it produces filediff + }) + + test("ignores non-write tools", async () => { + const input = makeInput("read") + const output = makeBeforeOutput({ path: "/some/file.ts" }) + + // when - should not throw + await hook["tool.execute.before"](input, output) + }) + }) + + describe("tool.execute.after", () => { + test("injects filediff metadata after write tool execution", async () => { + // given - a temp file that we can modify between before/after + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts` + const oldContent = "line 1\nline 2\nline 3\n" + await Bun.write(tmpFile, oldContent) + + const input = makeInput("write", "call-diff-1") + const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] }) + + // when - before hook captures old content + await hook["tool.execute.before"](input, beforeOutput) + + // when - file is modified (simulating write execution) + const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n" + await Bun.write(tmpFile, newContent) + + // when - after hook computes filediff + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + // then - metadata should contain filediff + const filediff = afterOutput.metadata.filediff as { + file: string + path: string + before: string + after: string + additions: number + deletions: number + } + expect(filediff).toBeDefined() + expect(filediff.file).toBe(tmpFile) + expect(filediff.path).toBe(tmpFile) + expect(filediff.before).toBe(oldContent) + expect(filediff.after).toBe(newContent) + expect(filediff.additions).toBeGreaterThan(0) + expect(filediff.deletions).toBeGreaterThan(0) + + // then - title should be set to the file path + expect(afterOutput.title).toBe(tmpFile) + + // cleanup + await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile) + }) + + test("does nothing for non-write tools", async () => { + const input = makeInput("read", "call-other") + const afterOutput = makeAfterOutput() + const originalMetadata = { ...afterOutput.metadata } + + await hook["tool.execute.after"](input, afterOutput) + + // then - metadata unchanged + expect(afterOutput.metadata).toEqual(originalMetadata) + }) + + test("does nothing when no before capture exists", async () => { + // given - no before hook was called for this callID + const input = makeInput("write", "call-no-before") + const afterOutput = makeAfterOutput() + const originalMetadata = { ...afterOutput.metadata } + + await hook["tool.execute.after"](input, afterOutput) + + // then - metadata unchanged (no filediff injected) + expect(afterOutput.metadata.filediff).toBeUndefined() + }) + + test("cleans up stored content after consumption", async () => { + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts` + await Bun.write(tmpFile, "original") + + const input = makeInput("write", "call-cleanup") + await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + await Bun.write(tmpFile, "modified") + + // when - first after call consumes + const afterOutput1 = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput1) + expect(afterOutput1.metadata.filediff).toBeDefined() + + // when - second after call finds nothing + const afterOutput2 = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput2) + expect(afterOutput2.metadata.filediff).toBeUndefined() + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + + test("handles file creation (empty old content)", async () => { + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts` + + // given - file doesn't exist during before hook + const input = makeInput("write", "call-create") + await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + + // when - file created during write + await Bun.write(tmpFile, "new content\n") + + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + // then - filediff shows creation (before is empty) + const filediff = afterOutput.metadata.filediff as FileDiffMetadata + expect(filediff).toBeDefined() + expect(filediff.before).toBe("") + expect(filediff.after).toBe("new content\n") + expect(filediff.additions).toBeGreaterThan(0) + expect(filediff.deletions).toBe(0) + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) + + describe("disabled config", () => { + test("does nothing when hashline_edit is disabled", async () => { + const disabledHook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: false } }) + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts` + await Bun.write(tmpFile, "content") + + const input = makeInput("write", "call-disabled") + await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + await Bun.write(tmpFile, "modified") + + const afterOutput = makeAfterOutput() + await disabledHook["tool.execute.after"](input, afterOutput) + + // then - no filediff injected + expect(afterOutput.metadata.filediff).toBeUndefined() + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) + + describe("write tool support", () => { + test("captures filediff for write tool (path arg)", async () => { + //#given - a temp file + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-write-${Date.now()}.ts` + const oldContent = "line 1\nline 2\n" + await Bun.write(tmpFile, oldContent) + + const input = makeInput("write", "call-write-1") + const beforeOutput = makeBeforeOutput({ path: tmpFile }) + + //#when - before hook captures old content + await hook["tool.execute.before"](input, beforeOutput) + + //#when - file is written + const newContent = "line 1\nmodified line 2\nnew line 3\n" + await Bun.write(tmpFile, newContent) + + //#when - after hook computes filediff + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + //#then - metadata should contain filediff + const filediff = afterOutput.metadata.filediff as { file: string; before: string; after: string; additions: number; deletions: number } + expect(filediff).toBeDefined() + expect(filediff.file).toBe(tmpFile) + expect(filediff.additions).toBeGreaterThan(0) + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + + test("captures filediff for write tool (filePath arg)", async () => { + //#given + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-write-fp-${Date.now()}.ts` + await Bun.write(tmpFile, "original content\n") + + const input = makeInput("write", "call-write-fp") + + //#when - before hook uses filePath arg + await hook["tool.execute.before"](input, makeBeforeOutput({ filePath: tmpFile })) + await Bun.write(tmpFile, "new content\n") + + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + //#then + const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined + expect(filediff).toBeDefined() + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) + + describe("raw content in filediff", () => { + test("filediff.before and filediff.after are raw file content", async () => { + //#given - a temp file + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-format-${Date.now()}.ts` + const oldContent = "const x = 1\nconst y = 2\n" + await Bun.write(tmpFile, oldContent) + + const input = makeInput("write", "call-hashline-format") + await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + + //#when - file is modified and after hook runs + const newContent = "const x = 1\nconst y = 42\n" + await Bun.write(tmpFile, newContent) + + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + //#then - before and after should be raw file content + const filediff = afterOutput.metadata.filediff as { before: string; after: string } + expect(filediff.before).toBe(oldContent) + expect(filediff.after).toBe(newContent) + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) + + describe("TUI diff support (metadata.diff)", () => { + test("injects unified diff string in metadata.diff for write tool TUI", async () => { + //#given - a temp file + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts` + const oldContent = "line 1\nline 2\nline 3\n" + await Bun.write(tmpFile, oldContent) + + const input = makeInput("write", "call-tui-diff") + await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + + //#when - file is modified + const newContent = "line 1\nmodified line 2\nline 3\n" + await Bun.write(tmpFile, newContent) + + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + //#then - metadata.diff should be a unified diff string + expect(afterOutput.metadata.diff).toBeDefined() + expect(typeof afterOutput.metadata.diff).toBe("string") + expect(afterOutput.metadata.diff).toContain("---") + expect(afterOutput.metadata.diff).toContain("+++") + expect(afterOutput.metadata.diff).toContain("@@") + expect(afterOutput.metadata.diff).toContain("-line 2") + expect(afterOutput.metadata.diff).toContain("+modified line 2") + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) +}) diff --git a/src/hooks/hashline-edit-diff-enhancer/index.ts b/src/hooks/hashline-edit-diff-enhancer/index.ts new file mode 100644 index 00000000..883bccfe --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/index.ts @@ -0,0 +1 @@ +export { createHashlineEditDiffEnhancerHook } from "./hook"