Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/opencode/src/tool/hashline_edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import z from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import DESCRIPTION from "./hashline_edit.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { applyHashlineEdits, type HashlineEdit, parseAnchor } from "./hashline"

export const HashlineEditTool = Tool.define("hashline_edit", {
description: DESCRIPTION,
parameters: z.object({
file: z.string().describe("Absolute path to the file to edit"),
edits: z
.array(
z.discriminatedUnion("op", [
z.object({
op: z.literal("set_line"),
anchor: z.string().describe('Line anchor e.g. "14丐"'),
new_text: z.string().describe("Replacement text, or empty string to delete the line"),
}),
z.object({
op: z.literal("replace_lines"),
start_anchor: z.string().describe('Start anchor e.g. "10乙"'),
end_anchor: z.string().describe('End anchor e.g. "14丐"'),
new_text: z.string().describe("Replacement lines, or empty string to delete the range"),
}),
z.object({
op: z.literal("insert_after"),
anchor: z.string().describe('Line anchor e.g. "14丐"'),
text: z.string().describe("Text to insert after the anchor line"),
}),
])
)
.min(1)
.describe("List of edits to apply atomically"),
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.file) ? params.file : path.join(Instance.directory, params.file)

await assertExternalDirectory(ctx, filepath)

await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
},
})

const parsedEdits: HashlineEdit[] = params.edits.map((edit) => {
if (edit.op === "set_line") {
return { op: edit.op, anchor: parseAnchor(edit.anchor), new_text: edit.new_text }
}
if (edit.op === "replace_lines") {
return {
op: edit.op,
start_anchor: parseAnchor(edit.start_anchor),
end_anchor: parseAnchor(edit.end_anchor),
new_text: edit.new_text,
}
}
return { op: edit.op, anchor: parseAnchor(edit.anchor), text: edit.text }
})

await FileTime.withLock(filepath, async () => {
const file = Bun.file(filepath)
const stats = await file.stat().catch(() => {})
if (!stats) throw new Error(`File not found: ${filepath}`)
if (stats.isDirectory()) throw new Error(`Path is a directory: ${filepath}`)
await FileTime.assert(ctx.sessionID, filepath)

const contentOld = await file.text()
const contentNew = applyHashlineEdits(contentOld, parsedEdits)
await Bun.write(filepath, contentNew)
FileTime.read(ctx.sessionID, filepath)
})

return {
title: path.relative(Instance.worktree, filepath),
output: `Edit applied successfully to ${params.file}`,
metadata: {},
}
},
})
12 changes: 12 additions & 0 deletions packages/opencode/src/tool/hashline_edit.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Apply precise line edits to a file using content-addressable anchors from hashline_read.

Each edit references a line by its anchor (line number + CJK hash character). The tool validates
all anchors before applying any edits — if any anchor doesn't match, no changes are made.

Supports three operations:
- set_line: Replace or delete a single line
- replace_lines: Replace or delete a contiguous range of lines
- insert_after: Insert new lines after an anchor line

If lines have moved since last read, anchors are automatically relocated by hash.
Always use hashline_read to get current anchors before editing.
28 changes: 15 additions & 13 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { CheckTaskTool } from "./check_task"
import { ListTasksTool } from "./list_tasks"
import { CancelTaskTool } from "./cancel_task"
import { HashlineReadTool } from "./hashline_read"
import { HashlineEditTool } from "./hashline_edit"

export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
Expand Down Expand Up @@ -131,7 +132,7 @@ export namespace ToolRegistry {
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool, HashlineEditTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
...custom,
Expand All @@ -152,20 +153,21 @@ export namespace ToolRegistry {
const tools = await all()
const result = await Promise.all(
tools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}

// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit") return !usePatch && !Flag.OPENCODE_EXPERIMENTAL_HASHLINE
if (t.id === "write") return !usePatch

return true
})
return true
})
.map(async (t) => {
using _ = log.time(t.id)
const tool = await t.init({ agent })
Expand Down
Loading