Skip to content

WriteToFileToolHandler:寫檔案與 diff 呈現

源码版本v4.0.10

職責

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.callbacksuiHelpers 回到 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:26export 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-377markFileAsEditedByCline + saveChanges + 失效 fileReadCache + trackFileContext("cline_edited")
  • user edits:380-411 — 偵測使用者在審批視窗的手改,用 applyPatch(newContent, userEdits) 還原 pre-save 內容,分別上報 human accepted 遙測。

資料流

進入 execute 後,先調 validateAndPrepareFileOperation 把參數和 newContent 都備好,再走審批與落盤。核心構造段是 replace_in_file 走的 diff 套用路徑:

typescript
// 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

對照官方資料:Cline 文件 · README