fix(hashline-edit): stabilize TUI diff metadata and output flow

Align edit/write hashline handling with TUI expectations by preserving metadata through tool execution, keeping unified diff raw to avoid duplicated line numbers, and tightening read/write/edit outputs plus tests for reliable agent operation.
This commit is contained in:
YeonGyu-Kim
2026-02-19 17:09:46 +09:00
parent 52029a0d42
commit 6869a2c0b5
7 changed files with 443 additions and 167 deletions
+9 -115
View File
@@ -1,5 +1,5 @@
import { log } from "../../shared"
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
interface HashlineEditDiffEnhancerConfig {
hashline_edit?: { enabled: boolean }
@@ -27,9 +27,8 @@ function cleanupStaleEntries(): void {
}
}
function isEditOrWriteTool(toolName: string): boolean {
const lower = toolName.toLowerCase()
return lower === "edit" || lower === "write"
function isWriteTool(toolName: string): boolean {
return toolName.toLowerCase() === "write"
}
function extractFilePath(args: Record<string, unknown>): string | undefined {
@@ -37,113 +36,6 @@ function extractFilePath(args: Record<string, unknown>): string | undefined {
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 } {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
const oldSet = new Map<string, number>()
for (const line of oldLines) {
oldSet.set(line, (oldSet.get(line) ?? 0) + 1)
}
const newSet = new Map<string, number>()
for (const line of newLines) {
newSet.set(line, (newSet.get(line) ?? 0) + 1)
}
let deletions = 0
for (const [line, count] of oldSet) {
const newCount = newSet.get(line) ?? 0
if (count > newCount) {
deletions += count - newCount
}
}
let additions = 0
for (const [line, count] of newSet) {
const oldCount = oldSet.get(line) ?? 0
if (count > oldCount) {
additions += count - oldCount
}
}
return { additions, deletions }
}
async function captureOldContent(filePath: string): Promise<string> {
try {
const file = Bun.file(filePath)
@@ -161,7 +53,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
return {
"tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => {
if (!enabled || !isEditOrWriteTool(input.tool)) return
if (!enabled || !isWriteTool(input.tool)) return
const filePath = extractFilePath(output.args)
if (!filePath) return
@@ -176,7 +68,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
},
"tool.execute.after": async (input: AfterInput, output: AfterOutput) => {
if (!enabled || !isEditOrWriteTool(input.tool)) return
if (!enabled || !isWriteTool(input.tool)) return
const key = makeKey(input.sessionID, input.callID)
const captured = pendingCaptures.get(key)
@@ -194,14 +86,16 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
}
const { additions, deletions } = countLineDiffs(oldContent, newContent)
const oldHashlined = toHashlineContent(oldContent)
const newHashlined = toHashlineContent(newContent)
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath)
output.metadata.filediff = {
file: filePath,
path: filePath,
before: toHashlineContent(oldContent),
after: toHashlineContent(newContent),
before: oldHashlined,
after: newHashlined,
additions,
deletions,
}
@@ -17,6 +17,15 @@ function makeAfterOutput(overrides?: Partial<{ title: string; output: string; me
}
}
type FileDiffMetadata = {
file: string
path: string
before: string
after: string
additions: number
deletions: number
}
describe("hashline-edit-diff-enhancer", () => {
let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook>
@@ -25,9 +34,9 @@ describe("hashline-edit-diff-enhancer", () => {
})
describe("tool.execute.before", () => {
test("captures old file content for edit tool", async () => {
test("captures old file content for write tool", async () => {
const filePath = import.meta.dir + "/index.test.ts"
const input = makeInput("edit")
const input = makeInput("write")
const output = makeBeforeOutput({ path: filePath, edits: [] })
await hook["tool.execute.before"](input, output)
@@ -36,7 +45,7 @@ describe("hashline-edit-diff-enhancer", () => {
// we verify in the after hook test that it produces filediff
})
test("ignores non-edit tools", async () => {
test("ignores non-write tools", async () => {
const input = makeInput("read")
const output = makeBeforeOutput({ path: "/some/file.ts" })
@@ -46,20 +55,20 @@ describe("hashline-edit-diff-enhancer", () => {
})
describe("tool.execute.after", () => {
test("injects filediff metadata after edit tool execution", async () => {
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("edit", "call-diff-1")
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 hashline edit execution)
// 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)
@@ -91,7 +100,7 @@ describe("hashline-edit-diff-enhancer", () => {
await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile)
})
test("does nothing for non-edit tools", async () => {
test("does nothing for non-write tools", async () => {
const input = makeInput("read", "call-other")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
@@ -104,7 +113,7 @@ describe("hashline-edit-diff-enhancer", () => {
test("does nothing when no before capture exists", async () => {
// given - no before hook was called for this callID
const input = makeInput("edit", "call-no-before")
const input = makeInput("write", "call-no-before")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
@@ -119,7 +128,7 @@ describe("hashline-edit-diff-enhancer", () => {
const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts`
await Bun.write(tmpFile, "original")
const input = makeInput("edit", "call-cleanup")
const input = makeInput("write", "call-cleanup")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
@@ -141,17 +150,17 @@ describe("hashline-edit-diff-enhancer", () => {
const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts`
// given - file doesn't exist during before hook
const input = makeInput("edit", "call-create")
const input = makeInput("write", "call-create")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
// when - file created during edit
// 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 any
const filediff = afterOutput.metadata.filediff as FileDiffMetadata
expect(filediff).toBeDefined()
expect(filediff.before).toBe("")
expect(filediff.after).toMatch(/^1:[a-f0-9]{2}\|new content/)
@@ -169,7 +178,7 @@ describe("hashline-edit-diff-enhancer", () => {
const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts`
await Bun.write(tmpFile, "content")
const input = makeInput("edit", "call-disabled")
const input = makeInput("write", "call-disabled")
await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
@@ -230,7 +239,8 @@ describe("hashline-edit-diff-enhancer", () => {
await hook["tool.execute.after"](input, afterOutput)
//#then
expect((afterOutput.metadata.filediff as any)).toBeDefined()
const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined
expect(filediff).toBeDefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
@@ -244,7 +254,7 @@ describe("hashline-edit-diff-enhancer", () => {
const oldContent = "const x = 1\nconst y = 2\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("edit", "call-hashline-format")
const input = makeInput("write", "call-hashline-format")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified and after hook runs
@@ -271,14 +281,14 @@ describe("hashline-edit-diff-enhancer", () => {
})
describe("TUI diff support (metadata.diff)", () => {
test("injects unified diff string in metadata.diff for TUI", async () => {
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("edit", "call-tui-diff")
const input = makeInput("write", "call-tui-diff")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified
+9 -1
View File
@@ -52,6 +52,14 @@ function transformOutput(output: string): string {
return result.join("\n")
}
function transformWriteOutput(output: string): string {
if (!output) {
return output
}
const lines = output.split("\n")
return lines.map((line) => (READ_LINE_PATTERN.test(line) ? transformLine(line) : line)).join("\n")
}
export function createHashlineReadEnhancerHook(
_ctx: PluginInput,
config: HashlineReadEnhancerConfig
@@ -70,7 +78,7 @@ export function createHashlineReadEnhancerHook(
if (!shouldProcess(config)) {
return
}
output.output = transformOutput(output.output)
output.output = input.tool.toLowerCase() === "write" ? transformWriteOutput(output.output) : transformOutput(output.output)
},
}
}
@@ -113,6 +113,26 @@ describe("createHashlineReadEnhancerHook", () => {
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\|const y = 2$/)
})
it("should transform numbered write lines even when header lines come first", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "write", sessionID, callID: "call-1" }
const output = {
title: "Write",
output: ["# Wrote /tmp/demo-edit.txt", "1: This is line one", "2: This is line two"].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[0]).toBe("# Wrote /tmp/demo-edit.txt")
expect(lines[1]).toMatch(/^1:[a-f0-9]{2}\|This is line one$/)
expect(lines[2]).toMatch(/^2:[a-f0-9]{2}\|This is line two$/)
})
it("should skip non-read tools", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))