From 1cb362773b02e0ed9858ca67de26ef6637cd9735 Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 05:47:05 +0900 Subject: [PATCH] fix(hashline-read-enhancer): handle inline tag from updated OpenCode read tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode updated its read tool output format — the tag now shares a line with the first content line (1: content) with no newline. The hook's exact indexOf('') detection returned -1, causing all read output to pass through unmodified (no hash anchors). This silently disabled the entire hashline-edit workflow. Fixes: - Sub-bug 1: Use findIndex + startsWith instead of exact indexOf match - Sub-bug 2: Extract inline content after prefix as first line - Sub-bug 3: Normalize open-tag line to bare tag in output (no duplicate) Also adds backward compat for legacy + 00001| pipe format. --- src/hooks/hashline-read-enhancer/hook.ts | 67 +++++++++++--- .../hashline-read-enhancer/index.test.ts | 87 +++++++++++++++++++ 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 9a3b58635..3852d64fd 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -6,9 +6,12 @@ interface HashlineReadEnhancerConfig { hashline_edit?: { enabled: boolean } } -const READ_LINE_PATTERN = /^(\d+): ?(.*)$/ +const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/ +const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/ const CONTENT_OPEN_TAG = "" const CONTENT_CLOSE_TAG = "" +const FILE_OPEN_TAG = "" +const FILE_CLOSE_TAG = "" function isReadTool(toolName: string): boolean { return toolName.toLowerCase() === "read" @@ -24,18 +27,36 @@ function shouldProcess(config: HashlineReadEnhancerConfig): boolean { function isTextFile(output: string): boolean { const firstLine = output.split("\n")[0] ?? "" - return READ_LINE_PATTERN.test(firstLine) + return COLON_READ_LINE_PATTERN.test(firstLine) || PIPE_READ_LINE_PATTERN.test(firstLine) +} + +function parseReadLine(line: string): { lineNumber: number; content: string } | null { + const colonMatch = COLON_READ_LINE_PATTERN.exec(line) + if (colonMatch) { + return { + lineNumber: Number.parseInt(colonMatch[1], 10), + content: colonMatch[2], + } + } + + const pipeMatch = PIPE_READ_LINE_PATTERN.exec(line) + if (pipeMatch) { + return { + lineNumber: Number.parseInt(pipeMatch[1], 10), + content: pipeMatch[2], + } + } + + return null } function transformLine(line: string): string { - const match = READ_LINE_PATTERN.exec(line) - if (!match) { + const parsed = parseReadLine(line) + if (!parsed) { return line } - const lineNumber = parseInt(match[1], 10) - const content = match[2] - const hash = computeLineHash(lineNumber, content) - return `${lineNumber}#${hash}:${content}` + const hash = computeLineHash(parsed.lineNumber, parsed.content) + return `${parsed.lineNumber}#${hash}:${parsed.content}` } function transformOutput(output: string): string { @@ -44,25 +65,43 @@ function transformOutput(output: string): string { } const lines = output.split("\n") - const contentStart = lines.indexOf(CONTENT_OPEN_TAG) + const contentStart = lines.findIndex( + (line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG) + ) const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG) + const fileStart = lines.findIndex((line) => line === FILE_OPEN_TAG || line.startsWith(FILE_OPEN_TAG)) + const fileEnd = lines.indexOf(FILE_CLOSE_TAG) - if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart + 1) { - const fileLines = lines.slice(contentStart + 1, contentEnd) + const blockStart = contentStart !== -1 ? contentStart : fileStart + const blockEnd = contentStart !== -1 ? contentEnd : fileEnd + const openTag = contentStart !== -1 ? CONTENT_OPEN_TAG : FILE_OPEN_TAG + + if (blockStart !== -1 && blockEnd !== -1 && blockEnd > blockStart) { + const openLine = lines[blockStart] ?? "" + const inlineFirst = openLine.startsWith(openTag) && openLine !== openTag + ? openLine.slice(openTag.length) + : null + const fileLines = inlineFirst !== null + ? [inlineFirst, ...lines.slice(blockStart + 1, blockEnd)] + : lines.slice(blockStart + 1, blockEnd) if (!isTextFile(fileLines[0] ?? "")) { return output } const result: string[] = [] for (const line of fileLines) { - if (!READ_LINE_PATTERN.test(line)) { + if (!parseReadLine(line)) { result.push(...fileLines.slice(result.length)) break } result.push(transformLine(line)) } - return [...lines.slice(0, contentStart + 1), ...result, ...lines.slice(contentEnd)].join("\n") + const prefixLines = inlineFirst !== null + ? [...lines.slice(0, blockStart), openTag] + : lines.slice(0, blockStart + 1) + + return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n") } if (!isTextFile(lines[0] ?? "")) { @@ -71,7 +110,7 @@ function transformOutput(output: string): string { const result: string[] = [] for (const line of lines) { - if (!READ_LINE_PATTERN.test(line)) { + if (!parseReadLine(line)) { result.push(...lines.slice(result.length)) break } diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 6081a6f55..14f2f23b8 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, it, expect } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { createHashlineReadEnhancerHook } from "./hook" @@ -50,6 +52,38 @@ describe("hashline-read-enhancer", () => { expect(lines[10]).toBe("1: keep this unchanged") }) + it("hashifies inline format from updated OpenCode read tool", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const input = { tool: "read", sessionID: "s", callID: "c" } + const output = { + title: "demo.ts", + output: [ + "/tmp/demo.ts", + "file", + "1: const x = 1", + "2: const y = 2", + "", + "(End of file - total 2 lines)", + "", + ].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[0]).toBe("/tmp/demo.ts") + expect(lines[1]).toBe("file") + expect(lines[2]).toBe("") + expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) + expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[6]).toBe("(End of file - total 2 lines)") + expect(lines[7]).toBe("") + }) + it("hashifies plain read output without content tags", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) @@ -77,6 +111,59 @@ describe("hashline-read-enhancer", () => { expect(lines[4]).toBe("(End of file - total 3 lines)") }) + it("hashifies read output with and zero-padded pipe format", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const input = { tool: "read", sessionID: "s", callID: "c" } + const output = { + title: "demo.ts", + output: [ + "", + "00001| const x = 1", + "00002| const y = 2", + "", + "(End of file - total 2 lines)", + "", + ].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) + expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[5]).toBe("") + }) + + it("hashifies pipe format even with leading spaces", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const input = { tool: "read", sessionID: "s", callID: "c" } + const output = { + title: "demo.ts", + output: [ + "", + " 00001| const x = 1", + " 00002| const y = 2", + "", + "(End of file - total 2 lines)", + "", + ].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) + expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + }) + it("appends LINE#ID output for write tool using metadata filepath", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })