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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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(() => {})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user