WriteToFileToolHandler:寫檔案與 diff 呈現
職責
WriteToFileToolHandler 是 Cline 落盤檔案改動的主力工具處理器。它註冊到 ClineDefaultTool.FILE_NEW 這個名字下,但實際承擔三種呼叫:write_to_file (整檔案覆蓋)、replace_in_file (SEARCH/REPLACE 區塊補丁)、new_rule (規則檔案覆蓋) (class declaration:26-27)。一個處理器沿用三種工具名,是因為它們走的都是同一條「校驗路徑 → 構造 newContent → 開啟 diff 視窗 → 審批 → 落盤」的管線。
它在 Cline 工具體系裡的位置是「被 ToolExecutor 調起來的具體 handler」,實作 IFullyManagedTool 介面。每個 handler 有兩條入口:handlePartialBlock 在 LLM 還在串流吐內容時驅動 UI (把內容邊吐邊推進 DiffViewProvider),execute 在 block 收齊後做真正的寫盤、審批、checkpoint 收尾。所有 UI 互動都透過 config.callbacks 與 uiHelpers 回到 Task,handler 自己不直接碰 webview。
它還兼任檔案寫盤前的最後一道防線:校驗 clineignore、解析多工作區路徑、清理模型常出的亂碼 (三引號圍欄、HTML 實體未轉義、多餘轉義符),把不規範的模型輸出規整成可落盤的內容,再把 newContent 交給 DiffViewProvider 在 VSCode 的 diff 編輯器裡呈現給使用者。
設計動機
- 一個 handler 三個名:write_to_file、replace_in_file、new_rule 落盤路徑本質相同,只在「newContent 怎麼構造」上分叉 (整內容 vs SEARCH/REPLACE vs 規則檔案),合併到一個類別裡避免重複(
validateAndPrepareFileOperation:436)。 - 串流預覽:LLM 邊吐 content,handler 邊把它推進 DiffViewProvider,使用者能在 LLM 還沒說完時看到 diff 在變。這要求
handlePartialBlock不能在內容不全時報錯(handlePartialBlock:35)。 - 模型輸出兜底:弱模型常在 content 裡多加 ``` 圍欄或 HTML 實體未轉義,handler 在落盤前剝掉圍欄、調
applyModelContentFixes修 HTML 實體(markdown strip:564-573)。 - 審批後置 PreToolUse hook:hook 在使用者審批通過後、真正落盤前跑,這樣 hook 拒絕時能 revertChanges 把 diff 視窗恢復原狀(
PreToolUse hook:344-356)。 - 使用者編輯感知:使用者在審批視窗裡手動改了內容,handler 會把這份 userEdits 單獨發回 LLM,並在遙測裡區分 agent accepted 與 human accepted 兩種來源(
user edits:380-411)。 - mistake 計數器分批重置:不是進入 execute 就重置,只有
saveChanges成功後才置 0,讓連錯能累積到 YOLO 模式上限(reset counter:365-366).
關鍵檔案
class declaration:26—export class WriteToFileToolHandler implements IFullyManagedTool,name = ClineDefaultTool.FILE_NEW,註解說明它沿用三個工具名。handlePartialBlock:35— 串流入口,等 path + content/diff 都到位後開 diff 視窗,邊吐邊update(newContent, false)。execute:97— block 收齊後的主流程:校驗 → 構造 newContent → 審批 → 落盤 → 處理 userEdits。missing content error:126-150— write_to_file 缺 content 時的漸進式錯誤,帶上下文使用率提示,連錯兩次後改提示語。auto-approval flow:205-235— 自動審批走 say 而非 ask,然後setTimeoutPromise(3_500)等 diagnostics 跟上。validateAndPrepareFileOperation:436— 共用校驗邏輯:解析多工作區路徑、clineignore 檢查、決定 editType、構造 newContent。diff construct:490-558— replace_in_file 分支:先applyModelContentFixes修 diff 文字,再constructNewFileContent套到 originalContent 上,失敗時按錯誤類型打遙測。content branch:559-577— write_to_file 分支:剝 ``` 圍欄、調applyModelContentFixes修模型特定問題。save & track:359-377—markFileAsEditedByCline+saveChanges+ 失效 fileReadCache +trackFileContext("cline_edited")。user edits:380-411— 偵測使用者在審批視窗的手改,用applyPatch(newContent, userEdits)還原 pre-save 內容,分別上報 human accepted 遙測。
資料流
進入 execute 後,先調 validateAndPrepareFileOperation 把參數和 newContent 都備好,再走審批與落盤。核心構造段是 replace_in_file 走的 diff 套用路徑:
// apps/vscode/src/core/task/tools/handlers/WriteToFileToolHandler.ts
if (diff) {
diff = applyModelContentFixes(diff, config.api.getModel().id, resolvedPath)
if (!config.services.diffViewProvider.isEditing) {
await config.services.diffViewProvider.open(absolutePath, { displayPath: relPath })
}
try {
const result = await constructNewFileContent(
diff,
config.services.diffViewProvider.originalContent || "",
!block.partial,
)
newContent = result.newContent
matchIndices = result.matchIndices
} catch (error) {
if (block.partial) {
return
}
config.taskState.consecutiveMistakeCount++
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "diff_error")
await config.callbacks.say("diff_error", relPath, undefined, undefined, true)
// ...
}
}constructNewFileContent 用 SEARCH/REPLACE 區塊的 7 字元圍欄 (------- SEARCH / ======= / +++++++ REPLACE) 把 SEARCH 段在 originalContent 裡定位,失敗時按 fuzz 策略回退(constructNewFileContent:245)。拿到 newContent 後,diff 視窗被打開,update(newContent, true) 把最終內容推進去並 finalize,然後 scrollToFirstDiff 把游標滾到第一處差異。
審批通過後,saveChanges 真正寫盤並回傳 userEdits / autoFormattingEdits / finalContent(saveChanges:337)。如果使用者在審批視窗改了內容,handler 用 applyPatch(newContent, userEdits) 還原 pre-save 內容上報 human accepted 遙測,並把 userEdits 作為 user_feedback_diff 回送給 LLM。落盤完成後,Task 在所有工具執行結束後統一跑一次 checkpointManager.saveCheckpoint()(post-tool checkpoint:3811),把這次檔案改動固化成 checkpoint。
邊界與失敗
- diff 套用失敗:SEARCH 段在原檔案裡定位不到時拋錯,partial block 靜默 return 避免串流抖動,完整 block 走
diff_error提示並按search_not_found/other_diff_error分桶打遙測(diff error path:509-558)。 - clineignore 拒絕:路徑命中 clineignore 規則時直接 pushToolResult 回傳
clineIgnoreError,不走 diff 視窗(clineignore check:454-474)。 - 空 content:write_to_file 收到空字串 content 是合法的 (合法的清空檔案),用
== null判斷而不是 truthy,但缺 content 走漸進式錯誤並按上下文使用率提示重試(missing content error:126-150)。 - PreToolUse hook 取消:hook 拋
PreToolUseHookCancellationError時 revertChanges + reset,回傳toolDenied,不拋錯給上層(PreToolUse hook:344-356)。 - 使用者拒絕:revertChanges 把 diff 視窗恢復成原狀,
didRejectTool = true,後續 text block 在 Task 層會被 skip(revert on reject:300)。 - 快取失效:落盤後
fileReadCache.delete(absolutePath.toLowerCase()),避免下次 read_file 讀到舊內容;任何 execute_command 執行後 Task 會整體清快取(cache invalidate:371)。 - checkpoint 落點:handler 自己不存 checkpoint,落盤後由 Task 在
userMessageContentReady滿足後統一存,使用者在審批視窗給回饋時再存一次(user feedback checkpoint:1542)。
小結
WriteToFileToolHandler 把「整檔案覆蓋」和「SEARCH/REPLACE 補丁」合到一個類別裡,透過 validateAndPrepareFileOperation 共用前置校驗,透過 DiffViewProvider 在 VSCode 原生 diff 編輯器裡呈現改動並支援使用者手改回流。所有模型輸出都經過 applyModelContentFixes 兜底,弱模型多加的圍欄和未轉義 HTML 實體在落盤前被清掉。
想往裡鑽可以接著看:
- 替代它的多檔案補丁:
/edit-tools/apply-patch - Task 如何調度它:
/agent-loop/task-class - diff 視窗底層:
/edit-tools/diff-view-provider