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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-02-19 15:54:11 +09:00
parent 5f9b6cf176
commit 09f62b1d40
2 changed files with 214 additions and 10 deletions

View File

@@ -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, unknown>): 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
},

View File

@@ -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(() => {})
})
})
})