feat(hashline): port hashline edit tool from oh-my-pi

This PR ports the hashline edit tool from oh-my-pi to oh-my-opencode as an experimental feature.

## Features
- New experimental.hashline_edit config flag
- hashline_edit tool with 4 operations: set_line, replace_lines, insert_after, replace
- Hash-based line anchors for safe concurrent editing
- Edit tool disabler for non-OpenAI providers
- Read output enhancer with LINE:HASH prefixes
- Provider state tracking module

## Technical Details
- xxHash32-based 2-char hex hashes
- Bottom-up edit application to prevent index shifting
- OpenAI provider exemption (uses native apply_patch)
- 90 tests covering all operations and edge cases
- All files under 200 LOC limit

## Files Added/Modified
- src/tools/hashline-edit/ (7 files, ~400 LOC)
- src/hooks/hashline-edit-disabler/ (4 files, ~200 LOC)
- src/hooks/hashline-read-enhancer/ (3 files, ~400 LOC)
- src/features/hashline-provider-state.ts (13 LOC)
- src/config/schema/experimental.ts (hashline_edit flag)
- src/config/schema/hooks.ts (2 new hook names)
- src/plugin/tool-registry.ts (conditional registration)
- src/plugin/chat-params.ts (provider state tracking)
- src/tools/index.ts (export)
- src/hooks/index.ts (exports)
This commit is contained in:
YeonGyu-Kim
2026-02-16 16:32:33 +09:00
parent 149de9da66
commit 51dde4d43f
26 changed files with 1509 additions and 27 deletions
+123
View File
@@ -0,0 +1,123 @@
import { parseLineRef, validateLineRef } from "./validation"
import type { HashlineEdit } from "./types"
function unescapeNewlines(text: string): string {
return text.replace(/\\n/g, "\n")
}
export function applySetLine(lines: string[], anchor: string, newText: string): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
result[line - 1] = unescapeNewlines(newText)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string
): string[] {
validateLineRef(lines, startAnchor)
validateLineRef(lines, endAnchor)
const { line: startLine } = parseLineRef(startAnchor)
const { line: endLine } = parseLineRef(endAnchor)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const result = [...lines]
const newLines = unescapeNewlines(newText).split("\n")
result.splice(startLine - 1, endLine - startLine + 1, ...newLines)
return result
}
export function applyInsertAfter(lines: string[], anchor: string, text: string): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = unescapeNewlines(text).split("\n")
result.splice(line, 0, ...newLines)
return result
}
export function applyReplace(content: string, oldText: string, newText: string): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
return content.replaceAll(oldText, unescapeNewlines(newText))
}
function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "replace":
return Number.POSITIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
if (edits.length === 0) {
return content
}
const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
let result = content
let lines = result.split("\n")
for (const edit of sortedEdits) {
switch (edit.type) {
case "set_line": {
validateLineRef(lines, edit.line)
const { line } = parseLineRef(edit.line)
lines[line - 1] = unescapeNewlines(edit.text)
break
}
case "replace_lines": {
validateLineRef(lines, edit.start_line)
validateLineRef(lines, edit.end_line)
const { line: startLine } = parseLineRef(edit.start_line)
const { line: endLine } = parseLineRef(edit.end_line)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const newLines = unescapeNewlines(edit.text).split("\n")
lines.splice(startLine - 1, endLine - startLine + 1, ...newLines)
break
}
case "insert_after": {
validateLineRef(lines, edit.line)
const { line } = parseLineRef(edit.line)
const newLines = unescapeNewlines(edit.text).split("\n")
lines.splice(line, 0, ...newLines)
break
}
case "replace": {
result = lines.join("\n")
if (!result.includes(edit.old_text)) {
throw new Error(`Text not found: "${edit.old_text}"`)
}
result = result.replaceAll(edit.old_text, unescapeNewlines(edit.new_text))
lines = result.split("\n")
break
}
}
}
return lines.join("\n")
}