mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-19 00:01:19 +00:00
533 lines
21 KiB
TypeScript
533 lines
21 KiB
TypeScript
import fs from "fs/promises"
|
|
import path from "path"
|
|
|
|
import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
|
|
|
|
import { getReadablePath } from "../../utils/path"
|
|
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
|
|
import { Task } from "../task/Task"
|
|
import { formatResponse } from "../prompts/responses"
|
|
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
|
import { fileExistsAtPath } from "../../utils/fs"
|
|
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
|
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
|
|
import type { ToolUse } from "../../shared/tools"
|
|
|
|
import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai"
|
|
|
|
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
|
|
|
interface EditFileParams {
|
|
file_path: string
|
|
old_string: string
|
|
new_string: string
|
|
expected_replacements?: number
|
|
}
|
|
|
|
type LineEnding = "\r\n" | "\n"
|
|
|
|
/**
|
|
* Count occurrences of a substring in a string.
|
|
* @param str The string to search in
|
|
* @param substr The substring to count
|
|
* @returns Number of non-overlapping occurrences
|
|
*/
|
|
function countOccurrences(str: string, substr: string): number {
|
|
if (substr === "") return 0
|
|
let count = 0
|
|
let pos = str.indexOf(substr)
|
|
while (pos !== -1) {
|
|
count++
|
|
pos = str.indexOf(substr, pos + substr.length)
|
|
}
|
|
return count
|
|
}
|
|
|
|
/**
|
|
* Safely replace all occurrences of a literal string, handling $ escape sequences.
|
|
* Standard String.replaceAll treats $ specially in the replacement string.
|
|
* This function ensures literal replacement.
|
|
*
|
|
* @param str The original string
|
|
* @param oldString The string to replace
|
|
* @param newString The replacement string
|
|
* @returns The string with all occurrences replaced
|
|
*/
|
|
function safeLiteralReplace(str: string, oldString: string, newString: string): string {
|
|
if (oldString === "" || !str.includes(oldString)) {
|
|
return str
|
|
}
|
|
|
|
// If newString doesn't contain $, we can use replaceAll directly
|
|
if (!newString.includes("$")) {
|
|
return str.replaceAll(oldString, newString)
|
|
}
|
|
|
|
// Escape $ to prevent ECMAScript GetSubstitution issues
|
|
// $$ becomes a single $ in the output, so we double-escape
|
|
const escapedNewString = newString.replaceAll("$", "$$$$")
|
|
return str.replaceAll(oldString, escapedNewString)
|
|
}
|
|
|
|
function detectLineEnding(content: string): LineEnding {
|
|
return content.includes("\r\n") ? "\r\n" : "\n"
|
|
}
|
|
|
|
function normalizeToLF(content: string): string {
|
|
return content.replace(/\r\n/g, "\n")
|
|
}
|
|
|
|
function restoreLineEnding(contentLF: string, eol: LineEnding): string {
|
|
if (eol === "\n") return contentLF
|
|
return contentLF.replace(/\n/g, "\r\n")
|
|
}
|
|
|
|
function escapeRegExp(input: string): string {
|
|
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
}
|
|
|
|
function buildWhitespaceTolerantRegex(oldLF: string): RegExp {
|
|
if (oldLF === "") {
|
|
// Never match empty string
|
|
return new RegExp("(?!)", "g")
|
|
}
|
|
|
|
const parts = oldLF.match(/(\s+|\S+)/g) ?? []
|
|
const whitespacePatternForRun = (run: string): string => {
|
|
// If the whitespace run includes a newline, allow matching any whitespace (including newlines)
|
|
// to tolerate wrapping changes across lines.
|
|
if (run.includes("\n")) {
|
|
return "\\s+"
|
|
}
|
|
|
|
// Otherwise, limit matching to horizontal whitespace so we don't accidentally consume
|
|
// line breaks that precede indentation.
|
|
return "[\\t ]+"
|
|
}
|
|
|
|
const pattern = parts
|
|
.map((part) => {
|
|
if (/^\s+$/.test(part)) {
|
|
return whitespacePatternForRun(part)
|
|
}
|
|
return escapeRegExp(part)
|
|
})
|
|
.join("")
|
|
|
|
return new RegExp(pattern, "g")
|
|
}
|
|
|
|
function buildTokenRegex(oldLF: string): RegExp {
|
|
const tokens = oldLF.split(/\s+/).filter(Boolean)
|
|
if (tokens.length === 0) {
|
|
return new RegExp("(?!)", "g")
|
|
}
|
|
|
|
const pattern = tokens.map(escapeRegExp).join("\\s+")
|
|
return new RegExp(pattern, "g")
|
|
}
|
|
|
|
function countRegexMatches(content: string, regex: RegExp): number {
|
|
const stable = new RegExp(regex.source, regex.flags)
|
|
return Array.from(content.matchAll(stable)).length
|
|
}
|
|
|
|
export class EditFileTool extends BaseTool<"edit_file"> {
|
|
readonly name = "edit_file" as const
|
|
|
|
private didSendPartialToolAsk = false
|
|
private partialToolAskRelPath: string | undefined
|
|
|
|
async execute(params: EditFileParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
|
// Coerce old_string/new_string to handle malformed native tool calls where they could be non-strings.
|
|
// In native mode, malformed calls can pass numbers/objects; normalize those to "" to avoid later crashes.
|
|
const file_path = params.file_path
|
|
const old_string = typeof params.old_string === "string" ? params.old_string : ""
|
|
const new_string = typeof params.new_string === "string" ? params.new_string : ""
|
|
const expected_replacements = params.expected_replacements ?? 1
|
|
const { askApproval, handleError, pushToolResult } = callbacks
|
|
let relPathForErrorHandling: string | undefined
|
|
let operationPreviewForErrorHandling: string | undefined
|
|
|
|
const finalizePartialToolAskIfNeeded = async (relPath: string): Promise<void> => {
|
|
if (!this.didSendPartialToolAsk) {
|
|
return
|
|
}
|
|
|
|
if (this.partialToolAskRelPath && this.partialToolAskRelPath !== relPath) {
|
|
return
|
|
}
|
|
|
|
const absolutePath = path.resolve(task.cwd, relPath)
|
|
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
|
|
|
|
const sharedMessageProps: ClineSayTool = {
|
|
tool: "appliedDiff",
|
|
path: getReadablePath(task.cwd, relPath),
|
|
diff: operationPreviewForErrorHandling,
|
|
isOutsideWorkspace,
|
|
}
|
|
|
|
// Finalize the existing partial tool ask row so the UI doesn't get stuck in a spinner state.
|
|
await task.ask("tool", JSON.stringify(sharedMessageProps), false).catch(() => {})
|
|
}
|
|
|
|
const recordFailureForPathAndMaybeEscalate = async (relPath: string, formattedError: string): Promise<void> => {
|
|
const currentCount = (task.consecutiveMistakeCountForEditFile.get(relPath) || 0) + 1
|
|
task.consecutiveMistakeCountForEditFile.set(relPath, currentCount)
|
|
|
|
if (currentCount >= 2) {
|
|
await task.say("diff_error", formattedError)
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Validate required parameters
|
|
if (!file_path) {
|
|
task.consecutiveMistakeCount++
|
|
task.recordToolError("edit_file")
|
|
task.didToolFailInCurrentTurn = true
|
|
pushToolResult(await task.sayAndCreateMissingParamError("edit_file", "file_path"))
|
|
return
|
|
}
|
|
|
|
// Determine relative path - file_path can be absolute or relative
|
|
let relPath: string
|
|
if (path.isAbsolute(file_path)) {
|
|
relPath = path.relative(task.cwd, file_path)
|
|
} else {
|
|
relPath = file_path
|
|
}
|
|
relPathForErrorHandling = relPath
|
|
|
|
operationPreviewForErrorHandling =
|
|
old_string === ""
|
|
? "creating new file"
|
|
: (() => {
|
|
const preview = old_string.length > 50 ? old_string.substring(0, 50) + "..." : old_string
|
|
return `replacing: "${preview}"`
|
|
})()
|
|
|
|
const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
|
|
|
|
if (!accessAllowed) {
|
|
// Finalize the partial tool preview before emitting any say() messages.
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
task.didToolFailInCurrentTurn = true
|
|
await task.say("rooignore_error", relPath)
|
|
pushToolResult(formatResponse.rooIgnoreError(relPath))
|
|
return
|
|
}
|
|
|
|
// Check if file is write-protected
|
|
const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false
|
|
|
|
const absolutePath = path.resolve(task.cwd, relPath)
|
|
const fileExists = await fileExistsAtPath(absolutePath)
|
|
|
|
let currentContent: string | null = null
|
|
let currentContentLF: string | null = null
|
|
let originalEol: LineEnding = "\n"
|
|
let isNewFile = false
|
|
|
|
// Read file or determine if creating new
|
|
if (fileExists) {
|
|
try {
|
|
currentContent = await fs.readFile(absolutePath, "utf8")
|
|
originalEol = detectLineEnding(currentContent)
|
|
// Normalize line endings to LF for matching
|
|
currentContentLF = normalizeToLF(currentContent)
|
|
} catch (error) {
|
|
task.consecutiveMistakeCount++
|
|
task.didToolFailInCurrentTurn = true
|
|
const errorDetails = error instanceof Error ? error.message : String(error)
|
|
const formattedError = `Failed to read file: ${absolutePath}\n\n<error_details>\nRead error: ${errorDetails}\n\nRecovery suggestions:\n1. Verify the file exists and is readable\n2. Check file permissions\n3. If the file may have changed, use read_file to confirm its current contents\n</error_details>`
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
await recordFailureForPathAndMaybeEscalate(relPath, formattedError)
|
|
task.recordToolError("edit_file", formattedError)
|
|
pushToolResult(formattedError)
|
|
return
|
|
}
|
|
|
|
// Check if trying to create a file that already exists
|
|
if (old_string === "") {
|
|
task.consecutiveMistakeCount++
|
|
task.didToolFailInCurrentTurn = true
|
|
const formattedError = `File already exists: ${absolutePath}\n\n<error_details>\nYou provided an empty old_string, which indicates file creation, but the target file already exists.\n\nRecovery suggestions:\n1. To modify an existing file, provide a non-empty old_string that matches the current file contents\n2. Use read_file to confirm the exact text to match\n3. If you intended to overwrite the entire file, use write_to_file instead\n</error_details>`
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
await recordFailureForPathAndMaybeEscalate(relPath, formattedError)
|
|
task.recordToolError("edit_file", formattedError)
|
|
pushToolResult(formattedError)
|
|
return
|
|
}
|
|
} else {
|
|
// File doesn't exist
|
|
if (old_string === "") {
|
|
// Creating a new file
|
|
isNewFile = true
|
|
} else {
|
|
// Trying to replace in non-existent file
|
|
task.consecutiveMistakeCount++
|
|
task.didToolFailInCurrentTurn = true
|
|
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found, so the replacement could not be performed.\n\nRecovery suggestions:\n1. Verify the file path is correct\n2. If you intended to create a new file, set old_string to an empty string\n3. Use list_files or read_file to confirm the correct path\n</error_details>`
|
|
// Match apply_diff behavior: surface missing file via the generic error channel.
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
await task.say("error", formattedError)
|
|
await recordFailureForPathAndMaybeEscalate(relPath, formattedError)
|
|
task.recordToolError("edit_file", formattedError)
|
|
pushToolResult(formattedError)
|
|
return
|
|
}
|
|
}
|
|
|
|
const oldLF = normalizeToLF(old_string)
|
|
const newLF = normalizeToLF(new_string)
|
|
const expectedReplacements = Math.max(1, expected_replacements)
|
|
|
|
// Validate replacement operation
|
|
if (!isNewFile && currentContentLF !== null) {
|
|
// Validate that old_string and new_string are different (normalized for EOL)
|
|
if (oldLF === newLF) {
|
|
task.consecutiveMistakeCount++
|
|
task.didToolFailInCurrentTurn = true
|
|
const formattedError = `No changes to apply for file: ${absolutePath}\n\n<error_details>\nThe provided old_string and new_string are identical (after normalizing line endings), so there is nothing to change.\n\nRecovery suggestions:\n1. Update new_string to the intended replacement text\n2. If you intended to verify file state only, use read_file instead\n</error_details>`
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
await recordFailureForPathAndMaybeEscalate(relPath, formattedError)
|
|
task.recordToolError("edit_file", formattedError)
|
|
pushToolResult(formattedError)
|
|
return
|
|
}
|
|
|
|
const wsRegex = buildWhitespaceTolerantRegex(oldLF)
|
|
const tokenRegex = buildTokenRegex(oldLF)
|
|
|
|
// Strategy 1: exact literal match
|
|
const exactOccurrences = countOccurrences(currentContentLF, oldLF)
|
|
if (exactOccurrences === expectedReplacements) {
|
|
// Apply literal replacement on LF-normalized content
|
|
currentContentLF = safeLiteralReplace(currentContentLF, oldLF, newLF)
|
|
} else {
|
|
// Strategy 2: whitespace-tolerant regex
|
|
const wsOccurrences = countRegexMatches(currentContentLF, wsRegex)
|
|
if (wsOccurrences === expectedReplacements) {
|
|
currentContentLF = currentContentLF.replace(wsRegex, () => newLF)
|
|
} else {
|
|
// Strategy 3: token-based regex
|
|
const tokenOccurrences = countRegexMatches(currentContentLF, tokenRegex)
|
|
if (tokenOccurrences === expectedReplacements) {
|
|
currentContentLF = currentContentLF.replace(tokenRegex, () => newLF)
|
|
} else {
|
|
// Error reporting
|
|
const anyMatches = exactOccurrences > 0 || wsOccurrences > 0 || tokenOccurrences > 0
|
|
if (!anyMatches) {
|
|
task.consecutiveMistakeCount++
|
|
task.didToolFailInCurrentTurn = true
|
|
const formattedError = `No match found in file: ${absolutePath}\n\n<error_details>\nThe provided old_string could not be found using exact, whitespace-tolerant, or token-based matching.\n\nRecovery suggestions:\n1. Use read_file to confirm the file's current contents\n2. Ensure old_string matches exactly (including whitespace/indentation and line endings)\n3. Provide more surrounding context in old_string to make the match unique\n4. If the file has changed since you constructed old_string, re-read and retry\n</error_details>`
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
await recordFailureForPathAndMaybeEscalate(relPath, formattedError)
|
|
task.recordToolError("edit_file", formattedError)
|
|
pushToolResult(formattedError)
|
|
return
|
|
}
|
|
|
|
// If exact matching finds occurrences but doesn't match expected, keep the existing message
|
|
if (exactOccurrences > 0) {
|
|
task.consecutiveMistakeCount++
|
|
task.didToolFailInCurrentTurn = true
|
|
const formattedError = `Occurrence count mismatch in file: ${absolutePath}\n\n<error_details>\nExpected ${expectedReplacements} occurrence(s) but found ${exactOccurrences} exact match(es).\n\nRecovery suggestions:\n1. Provide a more specific old_string so it matches exactly once\n2. If you intend to replace all occurrences, set expected_replacements to ${exactOccurrences}\n3. Use read_file to confirm the exact text and counts\n</error_details>`
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
await recordFailureForPathAndMaybeEscalate(relPath, formattedError)
|
|
task.recordToolError("edit_file", formattedError)
|
|
pushToolResult(formattedError)
|
|
return
|
|
}
|
|
|
|
task.consecutiveMistakeCount++
|
|
task.didToolFailInCurrentTurn = true
|
|
const formattedError = `Occurrence count mismatch in file: ${absolutePath}\n\n<error_details>\nExpected ${expectedReplacements} occurrence(s), but matching found ${wsOccurrences} (whitespace-tolerant) and ${tokenOccurrences} (token-based).\n\nRecovery suggestions:\n1. Provide more surrounding context in old_string to make the match unique\n2. If multiple replacements are intended, adjust expected_replacements to the intended count\n3. Use read_file to confirm the current file contents and refine the match\n</error_details>`
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
await recordFailureForPathAndMaybeEscalate(relPath, formattedError)
|
|
task.recordToolError("edit_file", formattedError)
|
|
pushToolResult(formattedError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply the replacement
|
|
const newContent = isNewFile
|
|
? new_string
|
|
: restoreLineEnding(currentContentLF ?? currentContent ?? "", originalEol)
|
|
|
|
// Check if any changes were made
|
|
if (!isNewFile && newContent === currentContent) {
|
|
if (relPathForErrorHandling) {
|
|
task.consecutiveMistakeCount = 0
|
|
task.consecutiveMistakeCountForEditFile.delete(relPathForErrorHandling)
|
|
}
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
pushToolResult(`No changes needed for '${relPath}'`)
|
|
return
|
|
}
|
|
|
|
task.consecutiveMistakeCount = 0
|
|
task.consecutiveMistakeCountForEditFile.delete(relPath)
|
|
|
|
// Initialize diff view
|
|
task.diffViewProvider.editType = isNewFile ? "create" : "modify"
|
|
task.diffViewProvider.originalContent = currentContent || ""
|
|
|
|
// Generate and validate diff
|
|
const diff = formatResponse.createPrettyPatch(relPath, currentContent || "", newContent)
|
|
if (!diff && !isNewFile) {
|
|
task.consecutiveMistakeCount = 0
|
|
task.consecutiveMistakeCountForEditFile.delete(relPath)
|
|
await finalizePartialToolAskIfNeeded(relPath)
|
|
pushToolResult(`No changes needed for '${relPath}'`)
|
|
await task.diffViewProvider.reset()
|
|
return
|
|
}
|
|
|
|
// Check if preventFocusDisruption experiment is enabled
|
|
const provider = task.providerRef.deref()
|
|
const state = await provider?.getState()
|
|
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
|
|
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
|
|
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
|
|
state?.experiments ?? {},
|
|
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
|
)
|
|
|
|
const sanitizedDiff = sanitizeUnifiedDiff(diff || "")
|
|
const diffStats = computeDiffStats(sanitizedDiff) || undefined
|
|
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
|
|
|
|
const sharedMessageProps: ClineSayTool = {
|
|
tool: isNewFile ? "newFileCreated" : "appliedDiff",
|
|
path: getReadablePath(task.cwd, relPath),
|
|
diff: sanitizedDiff,
|
|
isOutsideWorkspace,
|
|
}
|
|
|
|
const completeMessage = JSON.stringify({
|
|
...sharedMessageProps,
|
|
content: sanitizedDiff,
|
|
isProtected: isWriteProtected,
|
|
diffStats,
|
|
} satisfies ClineSayTool)
|
|
|
|
await checkpointBeforeEdit(task.cwd, [relPath])
|
|
|
|
// Show diff view if focus disruption prevention is disabled
|
|
if (!isPreventFocusDisruptionEnabled) {
|
|
await task.diffViewProvider.open(relPath)
|
|
await task.diffViewProvider.update(newContent, true)
|
|
task.diffViewProvider.scrollToFirstDiff()
|
|
}
|
|
|
|
const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
|
|
|
|
if (!didApprove) {
|
|
// Revert changes if diff view was shown
|
|
if (!isPreventFocusDisruptionEnabled) {
|
|
await task.diffViewProvider.revertChanges()
|
|
}
|
|
pushToolResult("Changes were rejected by the user.")
|
|
await task.diffViewProvider.reset()
|
|
return
|
|
}
|
|
|
|
// Save the changes
|
|
if (isPreventFocusDisruptionEnabled) {
|
|
// Direct file write without diff view or opening the file
|
|
await task.diffViewProvider.saveDirectly(
|
|
relPath,
|
|
newContent,
|
|
isNewFile,
|
|
diagnosticsEnabled,
|
|
writeDelayMs,
|
|
)
|
|
} else {
|
|
// Call saveChanges to update the DiffViewProvider properties
|
|
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
|
|
}
|
|
await checkpointAfterEdit(task.cwd, task, [relPath])
|
|
|
|
// Track file edit operation
|
|
if (relPath) {
|
|
await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
|
|
}
|
|
|
|
task.didEditFile = true
|
|
|
|
// Get the formatted response message
|
|
const replacementInfo =
|
|
!isNewFile && expected_replacements > 1 ? ` (${expected_replacements} replacements)` : ""
|
|
const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, isNewFile)
|
|
|
|
pushToolResult(message + replacementInfo)
|
|
|
|
// Record successful tool usage and cleanup
|
|
task.recordToolUsage("edit_file")
|
|
await task.diffViewProvider.reset()
|
|
this.resetPartialState()
|
|
|
|
// Process any queued messages after file edit completes
|
|
task.processQueuedMessages()
|
|
} catch (error) {
|
|
if (relPathForErrorHandling) {
|
|
await finalizePartialToolAskIfNeeded(relPathForErrorHandling)
|
|
}
|
|
await handleError("edit_file", error as Error)
|
|
await task.diffViewProvider.reset()
|
|
task.didToolFailInCurrentTurn = true
|
|
} finally {
|
|
this.didSendPartialToolAsk = false
|
|
this.partialToolAskRelPath = undefined
|
|
this.resetPartialState()
|
|
}
|
|
}
|
|
|
|
override async handlePartial(task: Task, block: ToolUse<"edit_file">): Promise<void> {
|
|
const filePath: string | undefined = block.params.file_path
|
|
const oldString: string | undefined = block.params.old_string
|
|
|
|
// Wait for path to stabilize before showing UI (prevents truncated paths)
|
|
if (!this.hasPathStabilized(filePath)) {
|
|
return
|
|
}
|
|
|
|
let operationPreview: string | undefined
|
|
if (oldString !== undefined) {
|
|
if (oldString === "") {
|
|
operationPreview = "creating new file"
|
|
} else {
|
|
const preview = oldString.length > 50 ? oldString.substring(0, 50) + "..." : oldString
|
|
operationPreview = `replacing: "${preview}"`
|
|
}
|
|
}
|
|
|
|
// Determine relative path for display (filePath is guaranteed non-null after hasPathStabilized)
|
|
let relPath = filePath!
|
|
if (path.isAbsolute(relPath)) {
|
|
relPath = path.relative(task.cwd, relPath)
|
|
}
|
|
this.didSendPartialToolAsk = true
|
|
this.partialToolAskRelPath = relPath
|
|
|
|
const absolutePath = path.resolve(task.cwd, relPath)
|
|
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
|
|
|
|
const sharedMessageProps: ClineSayTool = {
|
|
tool: "appliedDiff",
|
|
path: getReadablePath(task.cwd, relPath),
|
|
diff: operationPreview,
|
|
isOutsideWorkspace,
|
|
}
|
|
|
|
await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})
|
|
}
|
|
}
|
|
|
|
export const editFileTool = new EditFileTool()
|