From 50de1a18f215bbd1737c069d75514e6764097ef7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 19 Feb 2026 14:56:54 +0900 Subject: [PATCH] feat(hooks): add hashline-edit-diff-enhancer for TUI inline diff display Capture file content before hashline edit execution and compute filediff metadata after, enabling opencode TUI to render inline diffs for the plugin's edit tool (which replaces the built-in EditTool). --- src/config/schema/hooks.ts | 1 + src/hooks/hashline-edit-diff-enhancer/hook.ts | 129 ++++++++++++ .../hashline-edit-diff-enhancer/index.test.ts | 185 ++++++++++++++++++ .../hashline-edit-diff-enhancer/index.ts | 1 + src/hooks/index.ts | 1 + src/plugin/hooks/create-tool-guard-hooks.ts | 7 + src/plugin/tool-execute-after.ts | 1 + src/plugin/tool-execute-before.ts | 1 + 8 files changed, 326 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/config/schema/hooks.ts b/src/config/schema/hooks.ts index cf3d5009..f769d5e1 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -48,6 +48,7 @@ export const HookNameSchema = z.enum([ "write-existing-file-guard", "anthropic-effort", "hashline-read-enhancer", + "hashline-edit-diff-enhancer", ]) export type HookName = z.infer 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..94c001b0 --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -0,0 +1,129 @@ +import { log } from "../../shared" + +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 isEditTool(toolName: string): boolean { + return toolName === "edit" +} + +function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + const oldSet = new Map() + for (const line of oldLines) { + oldSet.set(line, (oldSet.get(line) ?? 0) + 1) + } + + const newSet = new Map() + 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 { + 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 || !isEditTool(input.tool)) return + + const filePath = typeof output.args.path === "string" ? output.args.path : undefined + 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 || !isEditTool(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) + + output.metadata.filediff = { + file: filePath, + path: filePath, + before: oldContent, + after: newContent, + additions, + deletions, + } + + 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..3836b033 --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/index.test.ts @@ -0,0 +1,185 @@ +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 }, + } +} + +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 edit tool", async () => { + const filePath = import.meta.dir + "/index.test.ts" + const input = makeInput("edit") + 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-edit 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 edit 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 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) + 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-edit 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("edit", "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("edit", "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("edit", "call-create") + await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + + // when - file created during edit + 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 + 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("edit", "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(() => {}) + }) + }) +}) 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" diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 950cc974..37f99df8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -46,4 +46,5 @@ export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; +export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer"; diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 6982376f..8f1e7f81 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -12,6 +12,7 @@ import { createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, createHashlineReadEnhancerHook, + createHashlineEditDiffEnhancerHook, } from "../../hooks" import { getOpenCodeVersion, @@ -31,6 +32,7 @@ export type ToolGuardHooks = { tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null hashlineReadEnhancer: ReturnType | null + hashlineEditDiffEnhancer: ReturnType | null } export function createToolGuardHooks(args: { @@ -99,6 +101,10 @@ export function createToolGuardHooks(args: { ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? true } })) : null + const hashlineEditDiffEnhancer = isHookEnabled("hashline-edit-diff-enhancer") + ? safeHook("hashline-edit-diff-enhancer", () => createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? true } })) + : null + return { commentChecker, toolOutputTruncator, @@ -109,5 +115,6 @@ export function createToolGuardHooks(args: { tasksTodowriteDisabler, writeExistingFileGuard, hashlineReadEnhancer, + hashlineEditDiffEnhancer, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 0ecfcb99..a6b6fae2 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -45,5 +45,6 @@ export function createToolExecuteAfterHandler(args: { await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output) + await hooks.hashlineEditDiffEnhancer?.["tool.execute.after"]?.(input, output) } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 09ae1681..d9a06c70 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -29,6 +29,7 @@ export function createToolExecuteBeforeHandler(args: { await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + await hooks.hashlineEditDiffEnhancer?.["tool.execute.before"]?.(input, output) if (input.tool === "task") { const argsObject = output.args const category = typeof argsObject.category === "string" ? argsObject.category : undefined