refactor: remove legacy edit mode

pull/14677/head
Shoubhit Dash 2026-03-06 11:10:00 +05:30
parent 9215af4465
commit 5a7f03a384
17 changed files with 575 additions and 1554 deletions

View File

@ -347,21 +347,13 @@ export namespace ACP {
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
const diff = editDiff(part.state.input, part.state.metadata)
if (diff) {
content.push({
type: "diff",
...diff,
})
}
}
if (part.tool === "todowrite") {
@ -862,21 +854,13 @@ export namespace ACP {
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
const diff = editDiff(part.state.input, part.state.metadata)
if (diff) {
content.push({
type: "diff",
...diff,
})
}
}
if (part.tool === "todowrite") {
@ -1630,6 +1614,40 @@ export namespace ACP {
}
}
function editDiff(input: Record<string, any>, metadata: unknown) {
const meta = typeof metadata === "object" && metadata ? (metadata as Record<string, any>) : undefined
const filediff =
meta && typeof meta["filediff"] === "object" && meta["filediff"]
? (meta["filediff"] as Record<string, any>)
: undefined
const path =
typeof filediff?.["file"] === "string"
? filediff["file"]
: typeof input["filePath"] === "string"
? input["filePath"]
: ""
const oldText =
typeof filediff?.["before"] === "string"
? filediff["before"]
: typeof input["oldString"] === "string"
? input["oldString"]
: ""
const newText =
typeof filediff?.["after"] === "string"
? filediff["after"]
: typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
if (!path && !oldText && !newText) return
return {
path,
oldText,
newText,
}
}
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
const result = applyPatch(fileOriginal, unifiedDiff)
if (result === false) {

View File

@ -2065,9 +2065,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit{" "}
{normalizePath(props.input.filePath!)}{" "}
{input({ replaceAll: "replaceAll" in props.input ? props.input.replaceAll : undefined })}
Edit {normalizePath(props.input.filePath!)} {input(props.input, ["edits", "filePath"])}
</InlineTool>
</Match>
</Switch>

View File

@ -1151,10 +1151,6 @@ export namespace Config {
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
hashline_edit: z
.boolean()
.optional()
.describe("Enable hashline-backed edit/read tool behavior (default true, set false to disable)"),
hashline_autocorrect: z
.boolean()
.optional()

View File

@ -1,14 +1,9 @@
// the approaches in this edit tool are sourced from
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { createTwoFilesPatch, diffLines } from "diff"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
@ -28,84 +23,43 @@ import {
import { Config } from "../config/config"
const MAX_DIAGNOSTICS_PER_FILE = 20
const LEGACY_EDIT_MODE = "legacy"
const HASHLINE_EDIT_MODE = "hashline"
const LegacyEditParams = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
})
const HashlineEditParams = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z.array(HashlineEdit).default([]),
delete: z.boolean().optional(),
rename: z.string().optional(),
})
const LEGACY_KEYS = ["oldString", "newString", "replaceAll"] as const
const EditParams = z
.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().optional().describe("The text to replace"),
newString: z.string().optional().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
edits: z.array(HashlineEdit).optional(),
delete: z.boolean().optional(),
rename: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
const legacy = value.oldString !== undefined || value.newString !== undefined || value.replaceAll !== undefined
const hashline = value.edits !== undefined || value.delete !== undefined || value.rename !== undefined
if (legacy && hashline) {
ctx.addIssue({
code: "custom",
message: "Do not mix legacy (oldString/newString) and hashline (edits/delete/rename) fields.",
})
return
}
if (!legacy && !hashline) {
ctx.addIssue({
code: "custom",
message: "Provide either legacy fields (oldString/newString) or hashline fields (edits/delete/rename).",
})
return
}
if (legacy) {
if (value.oldString === undefined || value.newString === undefined) {
ctx.addIssue({
code: "custom",
message: "Legacy payload requires both oldString and newString.",
})
}
return
}
if (value.edits === undefined) {
ctx.addIssue({
code: "custom",
message: "Hashline payload requires edits (use [] when only delete is intended).",
})
}
if (value.edits !== undefined) return
ctx.addIssue({
code: "custom",
message: "Hashline payload requires edits (use [] when only delete or rename is intended).",
})
})
type LegacyEditParams = z.infer<typeof LegacyEditParams>
type HashlineEditParams = z.infer<typeof HashlineEditParams>
type EditParams = z.infer<typeof EditParams>
function formatValidationError(error: z.ZodError) {
const legacy = error.issues.some((issue) => {
if (issue.code !== "unrecognized_keys") return false
if (!("keys" in issue) || !Array.isArray(issue.keys)) return false
return issue.keys.some((key) => LEGACY_KEYS.includes(key as (typeof LEGACY_KEYS)[number]))
})
if (legacy) {
return "Legacy edit payload has been removed. Use hashline fields: { filePath, edits, delete?, rename? }."
}
return `Invalid parameters for tool 'edit':\n${error.issues.map((issue) => `- ${issue.message}`).join("\n")}`
}
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
function isLegacyParams(params: EditParams): params is LegacyEditParams {
return params.oldString !== undefined || params.newString !== undefined || params.replaceAll !== undefined
}
async function withLocks(paths: string[], fn: () => Promise<void>) {
const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b))
const recurse = async (idx: number): Promise<void> => {
@ -154,105 +108,8 @@ async function diagnosticsOutput(filePath: string, output: string) {
}
}
async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) {
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
FileTime.read(ctx.sessionID, filePath)
return
}
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
FileTime.read(ctx.sessionID, filePath)
})
const filediff = createFileDiff(filePath, contentOld, contentNew)
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
edit_mode: LEGACY_EDIT_MODE,
},
})
const result = await diagnosticsOutput(filePath, "Edit applied successfully.")
return {
metadata: {
diagnostics: result.diagnostics,
diff,
filediff,
edit_mode: LEGACY_EDIT_MODE,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output: result.output,
}
}
async function executeHashline(
params: HashlineEditParams,
params: EditParams,
ctx: Tool.Context,
autocorrect: boolean,
aggressiveAutocorrect: boolean,
@ -263,13 +120,14 @@ async function executeHashline(
? params.rename
: path.join(Instance.directory, params.rename)
: sourcePath
const edits = params.edits ?? []
await assertExternalDirectory(ctx, sourcePath)
if (params.rename) {
await assertExternalDirectory(ctx, targetPath)
}
if (params.delete && params.edits.length > 0) {
if (params.delete && edits.length > 0) {
throw new Error("delete=true cannot be combined with edits")
}
if (params.delete && params.rename) {
@ -283,8 +141,7 @@ async function executeHashline(
let deleted = false
let changed = false
let diagnostics: Awaited<ReturnType<typeof LSP.diagnostics>> = {}
const paths = [sourcePath, targetPath]
await withLocks(paths, async () => {
await withLocks([sourcePath, targetPath], async () => {
const sourceStat = Filesystem.stat(sourcePath)
if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`)
const exists = Boolean(sourceStat)
@ -300,10 +157,7 @@ async function executeHashline(
}
await FileTime.assert(ctx.sessionID, sourcePath)
before = await Filesystem.readText(sourcePath)
after = ""
diff = trimDiff(
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
diff = trimDiff(createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), ""))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, sourcePath)],
@ -326,7 +180,7 @@ async function executeHashline(
return
}
if (!exists && !hashlineOnlyCreates(params.edits)) {
if (!exists && !hashlineOnlyCreates(edits)) {
throw new Error("Missing file can only be created with append/prepend hashline edits")
}
if (exists) {
@ -348,7 +202,7 @@ async function executeHashline(
const next = applyHashlineEdits({
lines: parsed.lines,
trailing: parsed.trailing,
edits: params.edits,
edits,
autocorrect,
aggressiveAutocorrect,
})
@ -360,8 +214,7 @@ async function executeHashline(
})
after = output.text
const noContentChange = before === after && sourcePath === targetPath
if (noContentChange) {
if (before === after && sourcePath === targetPath) {
noop = 1
diff = trimDiff(
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
@ -463,31 +316,15 @@ async function executeHashline(
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: EditParams,
formatValidationError,
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
if (isLegacyParams(params)) {
return executeLegacy(params, ctx)
}
const config = await Config.get()
if (config.experimental?.hashline_edit === false) {
throw new Error(
"Hashline edit payload is disabled. Set experimental.hashline_edit to true to use hashline operations.",
)
}
const hashlineParams: HashlineEditParams = {
filePath: params.filePath,
edits: params.edits ?? [],
delete: params.delete,
rename: params.rename,
}
return executeHashline(
hashlineParams,
params,
ctx,
config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
@ -495,431 +332,6 @@ export const EditTool = Tool.define("edit", {
},
})
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
// Similarity thresholds for block anchor fallback matching
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
/**
* Levenshtein distance algorithm implementation
*/
function levenshtein(a: string, b: string): number {
// Handle empty strings
if (a === "" || b === "") {
return Math.max(a.length, b.length)
}
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
)
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
}
}
return matrix[a.length][b.length]
}
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length
if (k < searchLines.length - 1) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines.length < 3) {
return
}
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
// Collect all candidate positions where both anchors match
const candidates: Array<{ startLine: number; endLine: number }> = []
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
candidates.push({ startLine: i, endLine: j })
break // Only match the first occurrence of the last line
}
}
}
// Return immediately if no candidates
if (candidates.length === 0) {
return
}
// Handle single candidate scenario (using relaxed threshold)
if (candidates.length === 1) {
const { startLine, endLine } = candidates[0]
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += (1 - distance / maxLen) / linesToCheck
// Exit early when threshold is reached
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
break
}
}
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
return
}
// Calculate similarity for multiple candidates
let bestMatch: { startLine: number; endLine: number } | null = null
let maxSimilarity = -1
for (const candidate of candidates) {
const { startLine, endLine } = candidate
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += 1 - distance / maxLen
}
similarity /= linesToCheck // Average similarity
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity > maxSimilarity) {
maxSimilarity = similarity
bestMatch = candidate
}
}
// Threshold judgment
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
const { startLine, endLine } = bestMatch
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
// Handle single line matches
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
yield line
} else {
// Only check for substring matches if the full line doesn't match
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
}
} catch (e) {
// Invalid regex pattern, skip
}
}
}
}
}
// Handle multi-line matches
const findLines = find.split("\n")
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
yield block.join("\n")
}
}
}
}
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
if (nonEmptyLines.length === 0) return text
const minIndent = Math.min(
...nonEmptyLines.map((line) => {
const match = line.match(/^(\s*)/)
return match ? match[1].length : 0
}),
)
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
if (removeIndentation(block) === normalizedFind) {
yield block
}
}
}
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "'":
return "'"
case '"':
return '"'
case "`":
return "`"
case "\\":
return "\\"
case "\n":
return "\n"
case "$":
return "$"
default:
return match
}
})
}
const unescapedFind = unescapeString(find)
// Try direct match with unescaped find string
if (content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = content.split("\n")
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
const unescapedBlock = unescapeString(block)
if (unescapedBlock === unescapedFind) {
yield block
}
}
}
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = content.indexOf(find, startIndex)
if (index === -1) break
yield find
startIndex = index + find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
if (trimmedFind === find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
if (block.trim() === trimmedFind) {
yield block
}
}
}
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
const lastLine = findLines[findLines.length - 1].trim()
// Find blocks that start and end with the context anchors
for (let i = 0; i < contentLines.length; i++) {
if (contentLines[i].trim() !== firstLine) continue
// Look for the matching last line
for (let j = i + 2; j < contentLines.length; j++) {
if (contentLines[j].trim() === lastLine) {
// Found a potential context block
const blockLines = contentLines.slice(i, j + 1)
const block = blockLines.join("\n")
// Check if the middle content has reasonable similarity
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
if (blockLines.length === findLines.length) {
let matchingLines = 0
let totalNonEmptyLines = 0
for (let k = 1; k < blockLines.length - 1; k++) {
const blockLine = blockLines[k].trim()
const findLine = findLines[k].trim()
if (blockLine.length > 0 || findLine.length > 0) {
totalNonEmptyLines++
if (blockLine === findLine) {
matchingLines++
}
}
}
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
yield block
break // Only match the first occurrence
}
}
break
}
}
}
}
export function trimDiff(diff: string): string {
const lines = diff.split("\n")
const contentLines = lines.filter(
@ -955,42 +367,3 @@ export function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
if (oldString === newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
let notFound = true
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false
if (replaceAll) {
return content.replaceAll(search, newString)
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
if (notFound) {
throw new Error(
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
)
}
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
}

View File

@ -1,22 +1,12 @@
Performs file edits with two supported payload schemas.
Performs file edits with hashline anchors.
Usage:
- You must use your `Read` tool at least once before editing an existing file. This tool rejects stale edits when file contents changed since read.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
Legacy schema (always supported):
- `{ filePath, oldString, newString, replaceAll? }`
- Exact replacement only.
- The edit fails if `oldString` is not found.
- The edit fails if `oldString` matches multiple locations and `replaceAll` is not true.
- Use `replaceAll: true` for global replacements.
Hashline schema (default behavior):
- `{ filePath, edits, delete?, rename? }`
- Do not mix legacy fields (`oldString/newString/replaceAll`) with hashline fields (`edits/delete/rename`) in one call.
- `edits` is required; use `[]` when only delete or rename is intended.
- Use strict anchor references from `Read` output: `LINE#ID`.
- Hashline mode can be turned off with `experimental.hashline_edit: false`.
- Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`.
- Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics.
- When `Read` returns `LINE#ID:<content>`, prefer hashline operations.
@ -29,5 +19,5 @@ Hashline schema (default behavior):
- `append { text }`
- `prepend { text }`
- `replace { old_text, new_text, all? }`
- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors.
- Fallback guidance: GPT-family models can use `apply_patch` as fallback; non-GPT models should fallback to legacy `oldString/newString` payloads.
- Provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors.
- GPT-family models can use `apply_patch` as fallback when needed.

View File

@ -9,7 +9,6 @@ import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
import { Config } from "../config/config"
import { hashlineRef } from "./hashline"
const MAX_LINE_LENGTH = 2000
@ -118,7 +117,6 @@ export const GrepTool = Tool.define("grep", {
}
const totalMatches = matches.length
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
@ -132,11 +130,7 @@ export const GrepTool = Tool.define("grep", {
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
if (useHashline) {
outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`)
} else {
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`)
}
if (truncated) {

View File

@ -3,7 +3,7 @@
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns file paths with matching lines sorted by modification time
- Output format follows edit mode: `Line N:` when hashline mode is disabled, `N#ID:<content>` when hashline mode is enabled
- Output format uses hashline anchors: `N#ID:<content>`
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead

View File

@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "./tool"
import { EditTool } from "./edit"
import { WriteTool } from "./write"
import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { Instance } from "../project/instance"
@ -22,17 +23,32 @@ export const MultiEditTool = Tool.define("multiedit", {
}),
async execute(params, ctx) {
const tool = await EditTool.init()
const write = await WriteTool.init()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
)
for (const edit of params.edits) {
const result =
edit.oldString === ""
? await write.execute(
{
filePath: params.filePath,
content: edit.newString,
},
ctx,
)
: await tool.execute(
{
filePath: params.filePath,
edits: [
{
type: "replace",
old_text: edit.oldString,
new_text: edit.newString,
all: edit.replaceAll,
},
],
},
ctx,
)
results.push(result)
}
return {

View File

@ -11,7 +11,6 @@ import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import { hashlineRef } from "./hashline"
const DEFAULT_READ_LIMIT = 2000
@ -194,11 +193,9 @@ export const ReadTool = Tool.define("read", {
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
}
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
const content = raw.map((line, index) => {
const lineNumber = index + offset
if (useHashline) return `${hashlineRef(lineNumber, full[index])}:${line}`
return `${lineNumber}: ${line}`
return `${hashlineRef(lineNumber, full[index])}:${line}`
})
const preview = raw.slice(0, 20).join("\n")

View File

@ -9,7 +9,6 @@ Usage:
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
- Contents are returned with a line prefix.
- Default format: `LINE#ID:<content>` (example: `1#AB:foo`). Use these anchors for hashline edits.
- Legacy format can be restored with `experimental.hashline_edit: false`: `<line>: <content>` (example: `1: foo`).
- For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
- Any line longer than 2000 characters is truncated.
- Call this tool in parallel when you know there are multiple files you want to read.

View File

@ -135,9 +135,7 @@ export namespace ToolRegistry {
},
agent?: Agent.Info,
) {
const config = await Config.get()
const tools = await all()
const hashline = config.experimental?.hashline_edit !== false
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
const result = await Promise.all(
@ -148,14 +146,7 @@ export namespace ToolRegistry {
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
if (hashline) {
if (t.id === "apply_patch") return usePatch
return true
}
// use apply tool in same format as codex
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
return true
})

View File

@ -102,7 +102,27 @@ test("loads JSONC config file", async () => {
})
})
test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => {
test("parses experimental.hashline_autocorrect", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
experimental: {
hashline_autocorrect: true,
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.experimental?.hashline_autocorrect).toBe(true)
},
})
})
test("ignores removed experimental.hashline_edit", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
@ -118,8 +138,8 @@ test("parses experimental.hashline_edit and experimental.hashline_autocorrect",
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.experimental?.hashline_edit).toBe(true)
expect(config.experimental?.hashline_autocorrect).toBe(true)
expect((config.experimental as Record<string, unknown>)?.hashline_edit).toBeUndefined()
},
})
})

File diff suppressed because it is too large Load Diff

View File

@ -37,41 +37,8 @@ describe("tool.grep", () => {
})
})
test("hashline disabled keeps Line N format", async () => {
test("emits hashline anchors by default", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const result = await grep.execute(
{
pattern: "alpha",
path: tmp.path,
},
ctx,
)
expect(result.output).toContain("Line 1: alpha")
},
})
})
test("hashline enabled emits N#ID anchor format", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
},
@ -117,10 +84,8 @@ describe("tool.grep", () => {
})
test("handles CRLF line endings in output", async () => {
// This test verifies the regex split handles both \n and \r\n
await using tmp = await tmpdir({
init: async (dir) => {
// Create a test file with content
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
},
})

View File

@ -463,29 +463,6 @@ describe("tool.read hashline output", () => {
},
})
})
test("keeps legacy line prefixes when hashline mode is disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "legacy.txt"), "foo\nbar")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "legacy.txt") }, ctx)
expect(result.output).toContain("1: foo")
expect(result.output).toContain("2: bar")
},
})
})
})
describe("tool.read loaded instructions", () => {

View File

@ -4,14 +4,8 @@ import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
describe("tool.registry hashline routing", () => {
test("hashline mode keeps edit and apply_patch for GPT models", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
test("keeps edit and apply_patch for GPT models", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@ -28,14 +22,8 @@ describe("tool.registry hashline routing", () => {
})
})
test("hashline mode keeps edit and removes apply_patch for non-GPT models", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
test("keeps edit and removes apply_patch for non-GPT models", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@ -51,50 +39,4 @@ describe("tool.registry hashline routing", () => {
},
})
})
test("keeps existing GPT apply_patch routing when hashline is explicitly disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "openai",
modelID: "gpt-5",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("apply_patch")
expect(ids).not.toContain("edit")
},
})
})
test("keeps existing non-GPT routing when hashline is explicitly disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "anthropic",
modelID: "claude-3-7-sonnet",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).not.toContain("apply_patch")
},
})
})
})

View File

@ -1751,11 +1751,11 @@ ToolRegistry.register({
mode="diff"
before={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString,
contents: props.metadata?.filediff?.before ?? props.input.oldString,
}}
after={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.after || props.input.newString,
contents: props.metadata?.filediff?.after ?? props.input.newString,
}}
/>
</div>