1b5ef0737f
* chore: details * fix(tui): add visual feedback for diff wrap and conceal toggles (#10655) * fix: await SessionRevert.cleanup for shell (#10669) * fix: Make diff wrapping toggle always available in command_list and correct a type error (resolves #10682) (#10683) * feat(app): default servers on web * fix(app): handle non-tool call permissions * fix(app): line selection waits on ready * wip(app): model settings * feat(app): model settings * chore: generate * wip(app): sidebar hover full * wip(app): full-height sidebar * wip(app): full-height sidebar * feat(app): better sidebar hover when collapsed * fix(app): query selector with non-latin chars * chore: stale-while-revalidate headers for changelog * chore: generate * feat(opencode): add agent description (#10680) * feat: dynamically resolve AGENTS.md files from subdirectories as agent explores them (#10678) * chore: gen changelog page off changelog json * upgrade opentui to v0.1.75 * chore: update nix node_modules hashes * fix: move changelog footer outside content div to fix padding (#10712) * fix(app): Order themes alphabetically (#10698) * fix(app): restore external link opening in system browser (#10697) * fix(ui): use focus-visible instead of focus to prevent sticky hover effect on click (#10651) * fix: add 44px top padding to sticky version header on changelog (#10715) * feat(app): file tree * wip(app): file tree mode * wip(app): file tree mode * chore: cleanup * chore: cleanup * chore(app): createStore over signals * fix(enterprise): add message navigation to share page desktop view (#10071) Co-authored-by: Claude <noreply@anthropic.com> * chore: fix changelog page * Revert "fix(app): restore external link opening in system browser (#10697)" This reverts commit984518b1c0. * ignore: tweak ai deps * feat: use anthropic compat messages api for anthropic models through copilot * chore: debug changelog * fix: ensure openai 404 errors are retried (#10590) * chore: generate * test(app): e2e test for sidebar nav * chore: refactor changelog * fix(app): session load cap * chore(app): missing i18n strings * chore: cleanup * fix(app): session sync issue * chore: generate * fix(ui): update button styles and disconnect button size * fix(app): set provider row height to 56px * fix(app): add hover text for env-connected providers * fix(app): use default cursor for env provider text * feat(ui): add providers icon and use in settings * feat(ui): add models icon and use in settings * fix(app): style view all button with interactive color and margin * feat(ui): add optional transition animations to dialog * fix(ui): prevent double-close and fix dialog replacement * feat(app): add transition to select provider dialog * feat(app): add transition to command palette * fix(ui): reduce dialog transition in time to 150ms * feat(app): add provider descriptions to settings * feat(app): add Vercel AI Gateway provider description * fix(ui): scope filetree pill tabs styling * fix(app): adjust filetree panel padding * fix(app): tighten filetree row spacing * fix(app): use chevron icons for filetree folders * fix(app): use medium font for filetree items * fix(app): refine filetree row spacing and indent * fix(app): reduce filetree folder indent * fix(app): dim non-deep filetree guide lines * fix(app): fade filetree guide lines on hover * fix(app): move file tree toggle to right * fix(app): disable tooltips in filetree tabs * test(app): fix e2e test * ci: add stale pr job * ci: add dry-run option to stale PR closer workflow Allows testing stale PR closure without actually closing PRs * tweak: retry logic to catch certain provider problems * fix(app): disconnect zen provider * fix(app): zen disconnect not working * chore: generate * chore: cleanup * fix: search clear icon * wip: new release modal - highlight key updates or new features - needs some transition love - all copy including text and video placeholder * chore: cleanup * wip: highlights * wip: highlights * wip: highlights * tweak: when using messages api for copilot, attach anthropic beta headers * chore: don't flip github draft release automatically * fix(app): file tree performance * fix(app): reactive file tree * chore: generate * chore: cleanup * fix(app): enable file watcher * chore: cleanup * chore: cleanup * test(app): file tree spec * feat(release): add highlights template to draft releases (#10745) * docs: add Italian README (#10732) * chore: generate * fix(app): add connect provier in model selector (#10706) * fix(desktop): disable magnification gestures on macOS (#10605) * feat: add /learn command to extract session learnings to scoped AGENTS.md files (#10717) * fix: use Instance.directory instead of process.cwd() in read tool * fix(core): expose Instance.directory to custom tools * fix(app): only show files in select dialog when clicking + tab * desktop: deduplicate tauri configs * ignore: update download stats 2026-01-27 * fix(app): auto-scroll button sometimes sticks * chore(app): missing tooltips * feat(app): forward and back buttons * chore: cleanup * feat(core): add worktree to plugin tool context * fix(app): agent fallback colors * fix(app): non-fatal error handling * docs(core): plugin tool context updates * fix(ui): set filetree tablist height to 48px with centered content * fix(app): center filetree empty state with 32px top margin * fix(app): update review empty states to 14px and align select file empty state * fix(app): only show left border on plus button when sticky * fix(app): use smaller close icon on tabs to match comment cards * fix(ui): add max-width 280px to tabs with text truncation * fix(app): don't show session skeleton after workspace reset * perf(app): don't keep parts in memory * perf(app): cleanup connect provider timers * perf(app): shared terminal ghostty-web instance * fix(app): select model anchor * Revert "fix(app): select model anchor" This reverts commit 36babf5fc390fd9a892ad3742690740296d94a23. * fix(app): open markdown links in external browser * fix(app): navigate to tabs when opening file * chore(app): cleanup tailwind vs pure css * fix(app): tooltips causing getComputedStyle errors in model select * fix(app): swallow file search errors * feat(opencode): Handle Venice cache creation tokens (#10735) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> * chore: generate * chore(docs): Better explanation on how to allow tools in external directories (#10862) * tweak: adjust retry check to be more defensive * ci: make tests passing a requirement pre-release * zen: kimi k2.5 and minimax m2.1 * feat(app): update settings in general settings * chore(app): translations * test(app): new e2e smoke tests * feat(app): show loaded agents.md files * fix(app): session diffs not always loading * feat(app): improved layout * chore: generate * tweak: add recommended topP/temp for kimi k2.5 * Set temperature for kimi k2.5 * Revert "ci: make tests passing a requirement pre-release" This reverts commit8c00818108. * Revert "Set temperature for kimi k2.5" This reverts commitbb63d16fa8. * feat(app): color filetree change dots by diff kind * feat(app): add filetree tooltips with diff labels * fix(app): shorten nav tooltips * fix(app): adjust titlebar left spacing * fix(app): delay nav tooltips * fix(app): align filetree change styling * fix(app): set filetree padding to 6px * fix(app): highlight selected change Track clicked file in the Changes tree and apply selection styling to the matching review diff. * chore(app): show 5 highlights * fix(app): auto-scroll * fix(app): don't connect to localhost through vpn * perf(app): better memory management * chore: cleanup * feat(desktop): i18n for tauri side * feat(desktop): tauri locales * fix(app): not auto-navigating to last project * chore: update nix node_modules hashes * fix(tui): handle 4-5 codes too in c to copy logic * fix(app): spinner color * fix(app): file tree not always loading * test(app): fix outdated e2e test * test(app): fix outdated e2e test * fix(app): file tree not always loading * fix(app): terminal corruption * feat(app): add 'connect provider' button to the manage models dialog (#10887) * fix(app): auto-scroll * feat: support headless authentication for chatgpt/codex (#10890) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> * fix(app): reintroduce review tab * release: v1.1.37 * feat(app): use opentui markdown component behind experimental flag (#10900) * chore: generate * docs: add Daytona OpenCode plugin to ecosystem (#10917) * chore: generate * fix(cli): restore brand integrity of CLI wordmark (#10912) * zen: kimi k2.5 * fix(opencode): ensure unsub(PartUpdated) is always called in TaskTool (#9992) * chore: generate * ignore: adjust flag * bump plugin version * ci * fix: ensure parallel tool calls dont double load AGENTS.md * release: v1.1.38 * fix: ensure copilot plugin properly sets headers for new messages api * chore: generate * release: v1.1.39 * bump: plugins * ignore: update download stats 2026-01-28 * chore(app): global config changes * feat(app): custom provider * chore: cleanup * wip(app): global config * chore: cleanup * chore: cleanup * chore: generate * feat(i18n): add th locale support (#10809) * chore: generate * fix(desktop): enable ctrl+n and ctrl+p for popover navigation (#10777) * fix: add SubtaskPart with metadata reference (#10990) Co-authored-by: yangtianzhe <yangtianzhe@corp.netease.com> * chore: generate * fix(app): close review pane * fix(markdown): Add streaming prop to markdown element (#11025) * release: v1.1.40 * fix(script): remove highlights template from release notes (#11052) * chore: generate * fix(ui): allow KaTeX inline math to be followed by punctuation (#11033) * fix(app): alignment and padding in dialogs (#10866) * fix(app): fill missing zh keys to avoid English fallback (#10841) * chore: generate * fix(opencode): normalize zh punctuation for Chinese UI (#10842) * fix(ui): improve zh duration display formatting (#10844) * fix(opencode): add `AbortSignal` support to `Ripgrep.files()` and `GlobTool` (#10833) * fix(typescript errors): remove duplicate keys causing typescript failures (#11071) * fix(app): file tabs - auto scroll on open & scroll via mouse wheel (#11070) * tweak: add ctx.abort to grep tool * fix(app): types * docs: update experimental environment variables in CLI docs (#11030) * fix(app): make settings more responsive for mobile and small web/desktop (#10775) * chore: generate * fix(opencode): allow media-src data: URL for small audio files (#11082) * fix(provider): include providerID in SDK cache key (#11020) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * fix: maxOutputTokens was accidentally hardcoded to undefined (#10995) * fix: ensure variants for copilot models work w/ maxTokens being set * release: v1.1.41 * test: fix test * chore: generate * feat(cli): include cache tokens in stats (#10582) * ci: adjust team * guard destroyed input field in timeout * feat: Sequential numbering for forked session titles (Issue #10105) (#10321) * more timeout race guards * fix: ensure that kimi doesnt have fake variants available * fix: frontmatter was adding newlines in some cases causing invalid model ids (#11095) Co-authored-by: aptdnfapt <aptdnfapt@users.noreply.github.com> * fix: undo change that used anthropic messages endpoint for anthropic models on copilot due to ratelimiting issues, go back to completions endpoint instead * release: v1.1.42 * fix: fix types * fix: fix kilocode/sdk type * chore: update nix node_modules hashes --------- Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com> Co-authored-by: Idris Gadi <85882535+IdrisGit@users.noreply.github.com> Co-authored-by: Noam Bressler <noamzbr@gmail.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Steffen Deusch <steffen@deusch.me> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com> Co-authored-by: Ryan Vogel <ryan@mandarin3d.com> Co-authored-by: Sam Huckaby <samhuckaby@gmail.com> Co-authored-by: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: zerone0x <hi@trine.dev> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Tim <hello@timsmart.co> Co-authored-by: David Hill <iamdavidhill@gmail.com> Co-authored-by: Fabio Martino <fabiomartino@users.noreply.github.com> Co-authored-by: Chris Yang <18487241+ysm-dev@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: dpuyosa <dpuyosa@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Rohan Godha <git@rohangodha.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Tommy D. Rossi <beats.by.morse@gmail.com> Co-authored-by: James Murdza <james@jamesmurdza.com> Co-authored-by: Tito <github@mynameistito.com> Co-authored-by: Goni Zahavy <goni1993@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Nattawee Phantawong <crozzio@gmail.com> Co-authored-by: Sairaj <sairaj2119@gmail.com> Co-authored-by: RenegadeE <37529469+YangTianz@users.noreply.github.com> Co-authored-by: yangtianzhe <yangtianzhe@corp.netease.com> Co-authored-by: Max Kong <kongzheyuan@outlook.com> Co-authored-by: Julian Coy <julian@juliancoy.com> Co-authored-by: Babou <42550761+bbabou@users.noreply.github.com> Co-authored-by: Devin Griffin <31415269+DNGriffin@users.noreply.github.com> Co-authored-by: tan <zerotanapok@gmail.com> Co-authored-by: Saba Tchikhinashvili <38084512+saba-ch@users.noreply.github.com> Co-authored-by: ideallove <20013535+ideallove@users.noreply.github.com> Co-authored-by: Benjamin Oldenburg <benjamin.oldenburg@ordis.co.th> Co-authored-by: aptdnfapt <aptdnfapt@users.noreply.github.com> Co-authored-by: marius-kilocode <marius@kilocode.ai>
656 lines
20 KiB
TypeScript
656 lines
20 KiB
TypeScript
// 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 { 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"
|
|
import { Bus } from "../bus"
|
|
import { FileTime } from "../file/time"
|
|
import { Filesystem } from "../util/filesystem"
|
|
import { Instance } from "../project/instance"
|
|
import { Snapshot } from "@/snapshot"
|
|
import { assertExternalDirectory } from "./external-directory"
|
|
|
|
const MAX_DIAGNOSTICS_PER_FILE = 20
|
|
|
|
function normalizeLineEndings(text: string): string {
|
|
return text.replaceAll("\r\n", "\n")
|
|
}
|
|
|
|
export const EditTool = Tool.define("edit", {
|
|
description: DESCRIPTION,
|
|
parameters: 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)"),
|
|
}),
|
|
async execute(params, ctx) {
|
|
if (!params.filePath) {
|
|
throw new Error("filePath is required")
|
|
}
|
|
|
|
if (params.oldString === params.newString) {
|
|
throw new Error("oldString and newString must be different")
|
|
}
|
|
|
|
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 Bun.file(filePath).exists()
|
|
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 Bun.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 file = Bun.file(filePath)
|
|
const stats = await file.stat().catch(() => {})
|
|
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 file.text()
|
|
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 file.write(contentNew)
|
|
await Bus.publish(File.Event.Edited, {
|
|
file: filePath,
|
|
})
|
|
await Bus.publish(FileWatcher.Event.Updated, {
|
|
file: filePath,
|
|
event: "change",
|
|
})
|
|
contentNew = await file.text()
|
|
diff = trimDiff(
|
|
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
|
)
|
|
FileTime.read(ctx.sessionID, filePath)
|
|
})
|
|
|
|
const filediff: Snapshot.FileDiff = {
|
|
file: filePath,
|
|
before: contentOld,
|
|
after: contentNew,
|
|
additions: 0,
|
|
deletions: 0,
|
|
}
|
|
for (const change of diffLines(contentOld, contentNew)) {
|
|
if (change.added) filediff.additions += change.count || 0
|
|
if (change.removed) filediff.deletions += change.count || 0
|
|
}
|
|
|
|
ctx.metadata({
|
|
metadata: {
|
|
diff,
|
|
filediff,
|
|
diagnostics: {},
|
|
},
|
|
})
|
|
|
|
let output = "Edit applied successfully."
|
|
await LSP.touchFile(filePath, true)
|
|
const diagnostics = await LSP.diagnostics()
|
|
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
|
const issues = diagnostics[normalizedFilePath] ?? []
|
|
const errors = issues.filter((item) => item.severity === 1)
|
|
if (errors.length > 0) {
|
|
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
|
const suffix =
|
|
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
|
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
|
}
|
|
|
|
return {
|
|
metadata: {
|
|
diagnostics,
|
|
diff,
|
|
filediff,
|
|
},
|
|
title: `${path.relative(Instance.worktree, filePath)}`,
|
|
output,
|
|
}
|
|
},
|
|
})
|
|
|
|
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(
|
|
(line) =>
|
|
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
|
!line.startsWith("---") &&
|
|
!line.startsWith("+++"),
|
|
)
|
|
|
|
if (contentLines.length === 0) return diff
|
|
|
|
let min = Infinity
|
|
for (const line of contentLines) {
|
|
const content = line.slice(1)
|
|
if (content.trim().length > 0) {
|
|
const match = content.match(/^(\s*)/)
|
|
if (match) min = Math.min(min, match[1].length)
|
|
}
|
|
}
|
|
if (min === Infinity || min === 0) return diff
|
|
const trimmedLines = lines.map((line) => {
|
|
if (
|
|
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
|
!line.startsWith("---") &&
|
|
!line.startsWith("+++")
|
|
) {
|
|
const prefix = line[0]
|
|
const content = line.slice(1)
|
|
return prefix + content.slice(min)
|
|
}
|
|
return line
|
|
})
|
|
|
|
return trimmedLines.join("\n")
|
|
}
|
|
|
|
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
|
|
if (oldString === newString) {
|
|
throw new Error("oldString and newString must be different")
|
|
}
|
|
|
|
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("oldString not found in content")
|
|
}
|
|
throw new Error(
|
|
"Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.",
|
|
)
|
|
}
|