From 09f62b1d40801b9f5e20c1c2be6b3a2b04b223d5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 19 Feb 2026 15:54:11 +0900 Subject: [PATCH] feat(hashline-edit-diff-enhancer): add unified diff output and write tool support - Generate unified diff for TUI display via metadata.diff - Support write tool in addition to edit tool - Hashline-format before/after content in filediff metadata Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/hooks/hashline-edit-diff-enhancer/hook.ts | 100 +++++++++++++- .../hashline-edit-diff-enhancer/index.test.ts | 124 +++++++++++++++++- 2 files changed, 214 insertions(+), 10 deletions(-) diff --git a/src/hooks/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts index 94c001b00..f2363878f 100644 --- a/src/hooks/hashline-edit-diff-enhancer/hook.ts +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -1,4 +1,5 @@ import { log } from "../../shared" +import { computeLineHash } from "../../tools/hashline-edit/hash-computation" interface HashlineEditDiffEnhancerConfig { hashline_edit?: { enabled: boolean } @@ -26,8 +27,88 @@ function cleanupStaleEntries(): void { } } -function isEditTool(toolName: string): boolean { - return toolName === "edit" +function isEditOrWriteTool(toolName: string): boolean { + const lower = toolName.toLowerCase() + return lower === "edit" || lower === "write" +} + +function extractFilePath(args: Record): string | undefined { + const path = args.path ?? args.filePath ?? args.file_path + return typeof path === "string" ? path : undefined +} + +function toHashlineContent(content: string): string { + if (!content) return content + const lines = content.split("\n") + const lastLine = lines[lines.length - 1] + const hasTrailingNewline = lastLine === "" + const contentLines = hasTrailingNewline ? lines.slice(0, -1) : lines + const hashlined = contentLines.map((line, i) => { + const lineNum = i + 1 + const hash = computeLineHash(lineNum, line) + return `${lineNum}:${hash}|${line}` + }) + return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n") +} + +function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + const maxLines = Math.max(oldLines.length, newLines.length) + + let diff = `--- ${filePath}\n+++ ${filePath}\n` + let inHunk = false + let oldStart = 1 + let newStart = 1 + let oldCount = 0 + let newCount = 0 + let hunkLines: string[] = [] + + for (let i = 0; i < maxLines; i++) { + const oldLine = oldLines[i] ?? "" + const newLine = newLines[i] ?? "" + + if (oldLine !== newLine) { + if (!inHunk) { + // Start new hunk + oldStart = i + 1 + newStart = i + 1 + oldCount = 0 + newCount = 0 + hunkLines = [] + inHunk = true + } + + if (oldLines[i] !== undefined) { + hunkLines.push(`-${oldLine}`) + oldCount++ + } + if (newLines[i] !== undefined) { + hunkLines.push(`+${newLine}`) + newCount++ + } + } else if (inHunk) { + // Context line within hunk + hunkLines.push(` ${oldLine}`) + oldCount++ + newCount++ + + // End hunk if we've seen enough context + if (hunkLines.length > 6) { + diff += `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` + diff += hunkLines.join("\n") + "\n" + inHunk = false + } + } + } + + // Close remaining hunk + if (inHunk && hunkLines.length > 0) { + diff += `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` + diff += hunkLines.join("\n") + "\n" + } + + return diff || `--- ${filePath}\n+++ ${filePath}\n` } function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } { @@ -80,9 +161,9 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan return { "tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => { - if (!enabled || !isEditTool(input.tool)) return + if (!enabled || !isEditOrWriteTool(input.tool)) return - const filePath = typeof output.args.path === "string" ? output.args.path : undefined + const filePath = extractFilePath(output.args) if (!filePath) return cleanupStaleEntries() @@ -95,7 +176,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan }, "tool.execute.after": async (input: AfterInput, output: AfterOutput) => { - if (!enabled || !isEditTool(input.tool)) return + if (!enabled || !isEditOrWriteTool(input.tool)) return const key = makeKey(input.sessionID, input.callID) const captured = pendingCaptures.get(key) @@ -114,14 +195,19 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan const { additions, deletions } = countLineDiffs(oldContent, newContent) + const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath) + output.metadata.filediff = { file: filePath, path: filePath, - before: oldContent, - after: newContent, + before: toHashlineContent(oldContent), + after: toHashlineContent(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 index 3836b033f..1910b3815 100644 --- a/src/hooks/hashline-edit-diff-enhancer/index.test.ts +++ b/src/hooks/hashline-edit-diff-enhancer/index.test.ts @@ -79,8 +79,8 @@ describe("hashline-edit-diff-enhancer", () => { 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.before).toMatch(/^\d+:[a-f0-9]{2}\|/) + expect(filediff.after).toMatch(/^\d+:[a-f0-9]{2}\|/) expect(filediff.additions).toBeGreaterThan(0) expect(filediff.deletions).toBeGreaterThan(0) @@ -154,7 +154,7 @@ describe("hashline-edit-diff-enhancer", () => { const filediff = afterOutput.metadata.filediff as any expect(filediff).toBeDefined() expect(filediff.before).toBe("") - expect(filediff.after).toBe("new content\n") + expect(filediff.after).toMatch(/^1:[a-f0-9]{2}\|new content/) expect(filediff.additions).toBeGreaterThan(0) expect(filediff.deletions).toBe(0) @@ -182,4 +182,122 @@ describe("hashline-edit-diff-enhancer", () => { 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 + expect((afterOutput.metadata.filediff as any)).toBeDefined() + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) + + describe("hashline format in filediff", () => { + test("filediff.before and filediff.after are in hashline format", 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("edit", "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 in LINE:HASH|content format + const filediff = afterOutput.metadata.filediff as { before: string; after: string } + const beforeLines = filediff.before.split("\n").filter(Boolean) + const afterLines = filediff.after.split("\n").filter(Boolean) + + for (const line of beforeLines) { + expect(line).toMatch(/^\d+:[a-f0-9]{2}\|/) + } + for (const line of afterLines) { + expect(line).toMatch(/^\d+:[a-f0-9]{2}\|/) + } + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) + + describe("TUI diff support (metadata.diff)", () => { + test("injects unified diff string in metadata.diff for 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("edit", "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(() => {}) + }) + }) })