From b9560dca658b9e28077ee77ebf20266503899729 Mon Sep 17 00:00:00 2001
From: code-crusher
Date: Mon, 17 Nov 2025 17:46:07 +0530
Subject: [PATCH 1/2] tool refinements
---
README.md | 4 +-
src/core/prompts/tools/native-tools/index.ts | 4 +-
.../prompts/tools/native-tools/read_file.ts | 6 +-
.../tools/native-tools/search_files.ts | 4 +-
.../ui/hooks/useOpenRouterModelProviders.ts | 68 -------------------
5 files changed, 9 insertions(+), 77 deletions(-)
diff --git a/README.md b/README.md
index 5dc1c3f1e1..49c0b2068f 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ Axon Code delivers frontier models performance for coding and support all the ma
### Agentic Tools Supported
+
- `fileEdit`: A new advanced Diff-Patch apply tool
- `web_search`: Search the web
- `web_fetch`: Fetch specific link data from the web
@@ -73,9 +74,8 @@ Axon Code delivers frontier models performance for coding and support all the ma
- `writeToFile`: Write to a file
### Support Models
+
- `axon-mini`: Mini Axon Code model for everyday low-effort tasks
- `axon-code`: High intelligence Axon Code model for all-day coding tasks
-- `axon-code-pro`: Heavily powerful Axon Code model for high-effort coding tasks
You can read more about the models here: https://docs.matterai.so/axon-overview
-
diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts
index c25c0d4aca..3aca28a154 100644
--- a/src/core/prompts/tools/native-tools/index.ts
+++ b/src/core/prompts/tools/native-tools/index.ts
@@ -33,7 +33,7 @@ export const nativeTools = [
executeCommand,
fetchInstructions,
// generateImage,
- insertContent,
+ // insertContent,
listCodeDefinitionNames,
listFiles,
newTask,
@@ -42,5 +42,5 @@ export const nativeTools = [
// searchAndReplace,
searchFiles,
updateTodoList,
- writeToFile,
+ // writeToFile,
] satisfies OpenAI.Chat.ChatCompletionTool[]
diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts
index 70fd77a5cd..14c2764424 100644
--- a/src/core/prompts/tools/native-tools/read_file.ts
+++ b/src/core/prompts/tools/native-tools/read_file.ts
@@ -5,7 +5,7 @@ export const read_file_multi = {
function: {
name: "read_file",
description:
- "Read one or more files and return their contents with line numbers for diffing or discussion. Use line ranges when available to keep reads efficient and combine related files when possible.",
+ "Read one or more files and return their contents with line numbers for diffing or discussion. Use line ranges always to keep reads efficient and combine related files when possible. You will always read less thatn 100 lines at a time",
strict: true,
parameters: {
type: "object",
@@ -21,9 +21,9 @@ export const read_file_multi = {
description: "Path to the file to read, relative to the workspace",
},
line_ranges: {
- type: ["array", "null"],
+ type: ["array"],
description:
- "Optional 1-based inclusive ranges to read (format: start-end). Use multiple ranges for non-contiguous sections and keep ranges tight to the needed context.",
+ "Always required line ranges to read (format: start-end). If you are unsure about about the what line numbers to query, you can perform a search on the file to determine line numbers. You will never read more than 100 lines at a time!",
items: {
type: "string",
pattern: "^\\d+-\\d+$",
diff --git a/src/core/prompts/tools/native-tools/search_files.ts b/src/core/prompts/tools/native-tools/search_files.ts
index 95f60db0ae..9c9de584d3 100644
--- a/src/core/prompts/tools/native-tools/search_files.ts
+++ b/src/core/prompts/tools/native-tools/search_files.ts
@@ -18,8 +18,8 @@ export default {
description: "Rust-compatible regular expression pattern to match",
},
file_pattern: {
- type: ["string", "null"],
- description: "Optional string glob to limit which files are searched (e.g., '*.ts')",
+ type: ["string"],
+ description: "String glob to limit which files are searched (e.g., '*.ts')",
},
},
required: ["path", "regex", "file_pattern"],
diff --git a/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts b/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts
index 9b5a3754b2..1f13373519 100644
--- a/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts
+++ b/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts
@@ -72,74 +72,6 @@ const KILO_CODE_MODELS: Record = {
input_cache_writes: "0",
},
},
- "axon-code-pro": {
- id: "axon-code-pro",
- name: "Axon Code Pro",
- description: "Axon Code Pro is a heavy, powerfull and super intelligent LLM model for coding tasks",
- input_modalities: ["text"],
- context_length: 192000,
- max_output_length: 192000,
- output_modalities: ["text"],
- supported_sampling_parameters: [
- "temperature",
- "top_p",
- "top_k",
- "repetition_penalty",
- "frequency_penalty",
- "presence_penalty",
- "seed",
- "stop",
- ],
- supported_features: ["tools", "structured_outputs", "web_search"],
- openrouter: {
- slug: "matterai/axon-code-pro",
- },
- datacenters: [{ country_code: "US" }],
- created: 1762009846,
- owned_by: "matterai",
- pricing: {
- prompt: "0.000002",
- completion: "0.000008",
- image: "0",
- request: "0",
- input_cache_reads: "0",
- input_cache_writes: "0",
- },
- },
- axon: {
- id: "axon",
- name: "Axon",
- description: "Axon is an general purpose super intelligent LLM model for high-effort day-to-day tasks",
- input_modalities: ["text"],
- context_length: 256000,
- max_output_length: 32768,
- output_modalities: ["text"],
- supported_sampling_parameters: [
- "temperature",
- "top_p",
- "top_k",
- "repetition_penalty",
- "frequency_penalty",
- "presence_penalty",
- "seed",
- "stop",
- ],
- supported_features: ["tools", "structured_outputs", "web_search"],
- openrouter: {
- slug: "matterai/axon",
- },
- datacenters: [{ country_code: "US" }],
- created: 1750426201,
- owned_by: "matterai",
- pricing: {
- prompt: "0.000001",
- completion: "0.000004",
- image: "0",
- request: "0",
- input_cache_reads: "0",
- input_cache_writes: "0",
- },
- },
"axon-mini": {
id: "axon-mini",
name: "Axon Mini",
From d3c41364b912bd7cdbda28d83c9b397cb6d1aac3 Mon Sep 17 00:00:00 2001
From: code-crusher
Date: Wed, 19 Nov 2025 13:21:34 +0530
Subject: [PATCH 2/2] refine tool calling for read and edit
---
packages/types/src/mode.ts | 5 +-
.../presentAssistantMessage.ts | 2 +-
src/core/prompts/system.ts | 151 ++++-------
src/core/prompts/tools/native-tools/index.ts | 9 +-
src/core/task/Task.ts | 9 +
src/core/tools/fileEditTool.ts | 63 ++---
.../editor/FileEditReviewController.ts | 242 ++++++++++++++++++
webview-ui/src/components/chat/ChatRow.tsx | 16 +-
8 files changed, 337 insertions(+), 160 deletions(-)
create mode 100644 src/integrations/editor/FileEditReviewController.ts
diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts
index 46f3f1e0d2..ac1d3a99c7 100644
--- a/packages/types/src/mode.ts
+++ b/packages/types/src/mode.ts
@@ -371,7 +371,10 @@ for i in range(10):
Code chunks that you receive (via tool calls or from user) may include inline line numbers in the form LINE_NUMBER|LINE_CONTENT. Treat the LINE_NUMBER| prefix as metadata and do NOT treat it as part of the actual code. LINE_NUMBER is right-aligned number padded with spaces to 6 characters.
-`,
+
+
+CRITICAL: For any task, small or big, you will always and always use the update_todo_list tool to create the TODO list, always keep is upto date with updates to the status and updating/editing the list as needed.
+`,
whenToUse:
"Use this mode when you need to write, modify, or refactor code. Ideal for implementing features, fixing bugs, creating new files, or making code improvements across any programming language or framework.",
description: "Write, modify, and refactor code",
diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts
index af3f4f83f8..41aad6ee48 100644
--- a/src/core/assistant-message/presentAssistantMessage.ts
+++ b/src/core/assistant-message/presentAssistantMessage.ts
@@ -512,7 +512,7 @@ export async function presentAssistantMessage(cline: Task) {
await searchAndReplaceTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
case "file_edit":
- await fileEditTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+ await fileEditTool(cline, block, handleError, pushToolResult, removeClosingTag)
break
// kilocode_change start: Morph fast apply
case "edit_file":
diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts
index 7a1b0bf9c7..32b098f44c 100644
--- a/src/core/prompts/system.ts
+++ b/src/core/prompts/system.ts
@@ -33,116 +33,27 @@ export function getPromptComponent(
}
const applyDiffToolDescription = `
-## apply_diff Tool Usage
+Common tool calls and explanations
-The \`apply_diff\` tool allows you to make precise, surgical edits to one or more files simultaneously.
+## file_edit
-### CRITICAL: Single Content Field with Complete Structure
+**Description**: Perform targeted text replacements within a single file without constructing manual diff blocks.
-**Each diff has ONLY TWO fields:**
-- \`content\`: A single string containing the complete SEARCH/REPLACE block
-- \`start_line\`: The line number where SEARCH begins
+**When to use**:
+- You know the exact text that should be replaced and its updated form.
+- You want a deterministic edit without invoking Fast Apply models.
+- You need to delete or rewrite a block of code but don't want to craft search/replace diff markers manually.
-**DO NOT create separate fields like \`search\`, \`replace\`, \`old\`, \`new\`, etc.**
+**Parameters**:
+1. \`target_file\` — Relative path to the file you want to modify.
+2. \`old_string\` — The current text you expect to replace. Provide enough context for a unique match; this can be empty to replace the entire file.
+3. \`new_string\` — The text that should replace the match. Use an empty string to delete the matched content.
+4. \`replace_all\` (optional, default false) — Set to true to replace every occurrence of the matched text. Leave false to replace only a single uniquely identified match.
-### REQUIRED Content Format
-
-The \`content\` field MUST contain this COMPLETE structure as a SINGLE STRING:
-\`\`\`
-<<<<<<< SEARCH
-[exact lines from original file]
-=======
-[new lines to replace with]
->>>>>>> REPLACE
-\`\`\`
-
-**All three markers must be present IN THE CONTENT STRING.**
-
-### Correct Example
-\`\`\`json
-{
- "files": [
- {
- "path": "src/services/llmPricing.js",
- "diffs": [
- {
- "content": "<<<<<<< SEARCH\n \"accounts/fireworks/models/glm-4p5\": {\n input: 0.55,\n output: 2.19,\n },\n \"gpt-oss-120b\": {\n input: 0.25,\n output: 0.69,\n },\n=======\n \"accounts/fireworks/models/glm-4p5\": {\n input: 0.55,\n output: 2.19,\n },\n \"accounts/fireworks/models/glm-4.6\": {\n input: 0.6,\n output: 2.2,\n },\n \"gpt-oss-120b\": {\n input: 0.25,\n output: 0.69,\n },\n>>>>>>> REPLACE",
- "start_line": 30
- }
- ]
- }
- ]
-}
-\`\`\`
-
-### ❌ INCORRECT Examples
-\`\`\`json
-// WRONG - Incomplete content (missing ======= and >>>>>>> REPLACE)
-{
- "content": "<<<<<<< SEARCH\n old code\n",
- "start_line": 30
-}
-
-// WRONG - Creating separate fields
-{
- "content": "<<<<<<< SEARCH\n old code\n",
- "replace": "new code\n>>>>>>> REPLACE",
- "start_line": 30
-}
-
-// WRONG - Using search/replace fields
-{
- "search": "old code",
- "replace": "new code",
- "start_line": 30
-}
-\`\`\`
-
-### Step-by-Step Process
-
-When creating a diff:
-
-1. **Identify the exact lines** to change from the original file
-2. **Write the SEARCH block**: Include 2-3 lines of context before and after
-3. **Add the separator**: \`=======\` on its own line
-4. **Write the REPLACE block**: The new content (can include the context lines)
-5. **Close with marker**: \`>>>>>>> REPLACE\` on its own line
-6. **Combine into single string**: Put all of this into the \`content\` field
-7. **Add start_line**: The line number where your SEARCH block begins
-
-### JSON Schema Reminder
-\`\`\`typescript
-{
- path: string, // File path
- diffs: [
- {
- content: string, // COMPLETE "<<<<<<< SEARCH\n...\n=======\n...\n>>>>>>> REPLACE" block
- start_line: number // Line where SEARCH begins
- }
- ]
-}
-\`\`\`
-
-**Only these two fields exist in each diff object. Do not invent additional fields.**
-
-### Common Errors to Avoid
-
-- ❌ **Stopping the content string before \`=======\` and \`>>>>>>> REPLACE\`** (most common)
-- ❌ Creating \`replace\`, \`search\`, \`old\`, or \`new\` fields
-- ❌ Missing the \`=======\` separator line
-- ❌ Missing the \`>>>>>>> REPLACE\` closing marker
-- ❌ Not including enough context in SEARCH block
-- ❌ Whitespace mismatches between SEARCH and original file
-
-### Verification Checklist
-
-Before submitting, verify each diff has:
-- ✅ Single \`content\` field (not multiple fields)
-- ✅ Starts with \`<<<<<<< SEARCH\n\`
-- ✅ Contains \`=======\n\` in the middle
-- ✅ Ends with \`>>>>>>> REPLACE\`
-- ✅ SEARCH block matches original file exactly
-- ✅ Correct \`start_line\` number
+**Guidance**:
+- Prefer multi-line snippets for \`old_string\` to help the tool locate the correct section.
+- If multiple matches exist, either refine \`old_string\` or set \`replace_all\` to true when you intend to change every occurrence.
+- The tool shows a diff before applying changes so you can confirm the result.
# execute_command
@@ -323,7 +234,6 @@ Before submitting, verify:
- \`poetry\` - Python Package Manager
- \`virtualenv\` - Python Virtual Environment
-
CRITICAL:
1. A command never starts with \`:\`
2. A command never uses <|tool_call_argument_begin|> OR any <> TAG
@@ -333,6 +243,35 @@ CRITICAL:
6. Commands are always valid for the user's shell
7. Commands are always valid with executable permissions
8. Commands are always valid with the user's current working directory
+
+
+## update_todo_list
+
+**Description:**
+Replace the entire TODO list with an updated checklist reflecting the current state. Always provide the full list; the system will overwrite the previous one. This tool is designed for step-by-step task tracking, allowing you to confirm completion of each step before updating, update multiple task statuses at once (e.g., mark one as completed and start the next), and dynamically add new todos discovered during long or complex tasks.
+
+**Checklist Format:**
+- Use a single-level markdown checklist (no nesting or subtasks).
+- List todos in the intended execution order.
+- Status options:
+ - [ ] Task description (pending)
+ - [x] Task description (completed)
+ - [-] Task description (in progress)
+
+**Status Rules:**
+- [ ] = pending (not started)
+- [x] = completed (fully finished, no unresolved issues)
+- [-] = in_progress (currently being worked on)
+
+**Core Principles:**
+- Before updating, always confirm which todos have been completed since the last update.
+- You may update multiple statuses in a single update (e.g., mark the previous as completed and the next as in progress).
+- When a new actionable item is discovered during a long or complex task, add it to the todo list immediately.
+- Do not remove any unfinished todos unless explicitly instructed.
+- Always retain all unfinished tasks, updating their status as needed.
+- Only mark a task as completed when it is fully accomplished (no partials, no unresolved dependencies).
+- If a task is blocked, keep it as in_progress and add a new todo describing what needs to be resolved.
+- Remove tasks only if they are no longer relevant or if the user requests deletion.
`
async function generatePrompt(
diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts
index 3aca28a154..7af433626c 100644
--- a/src/core/prompts/tools/native-tools/index.ts
+++ b/src/core/prompts/tools/native-tools/index.ts
@@ -1,12 +1,8 @@
import { OpenAI } from "openai/client"
import askFollowupQuestion from "./ask_followup_question"
import attemptCompletion from "./attempt_completion"
-import browserAction from "./browser_action"
-import codebaseSearch from "./codebase_search"
import executeCommand from "./execute_command"
import fetchInstructions from "./fetch_instructions"
-import generateImage from "./generate_image"
-import insertContent from "./insert_content"
import listCodeDefinitionNames from "./list_code_definition_names"
import listFiles from "./list_files"
import newTask from "./new_task"
@@ -15,11 +11,8 @@ import runSlashCommand from "./run_slash_command"
// import searchAndReplace from "./search_and_replace"
import searchFiles from "./search_files"
// import switchMode from "./switch_mode"
-import updateTodoList from "./update_todo_list"
-import writeToFile from "./write_to_file"
-import { apply_diff_single_file, apply_diff_multi_file } from "./apply_diff"
-import editFile from "./edit_file"
import fileEdit from "./file_edit"
+import updateTodoList from "./update_todo_list"
export const nativeTools = [
// apply_diff_single_file,
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 8c00a379c4..08dd14134e 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -67,6 +67,7 @@ import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
// integrations
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
+import { FileEditReviewController } from "../../integrations/editor/FileEditReviewController"
import { findToolName, formatContentBlockToMarkdown } from "../../integrations/misc/export-markdown"
import { RooTerminalProcess } from "../../integrations/terminal/types"
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
@@ -263,6 +264,7 @@ export class Task extends EventEmitter implements TaskLike {
diffEnabled: boolean = false
fuzzyMatchThreshold: number
didEditFile: boolean = false
+ fileEditReviewController: FileEditReviewController
// LLM Messages & Chat Messages
apiConversationHistory: ApiMessage[] = []
@@ -386,6 +388,7 @@ export class Task extends EventEmitter implements TaskLike {
this.providerRef = new WeakRef(provider)
this.globalStoragePath = provider.context.globalStorageUri.fsPath
this.diffViewProvider = new DiffViewProvider(this.cwd, this)
+ this.fileEditReviewController = new FileEditReviewController(this.cwd)
this.enableCheckpoints = enableCheckpoints
this.enableBridge = enableBridge
@@ -1781,6 +1784,12 @@ export class Task extends EventEmitter implements TaskLike {
console.error("Error disposing file context tracker:", error)
}
+ try {
+ this.fileEditReviewController.dispose()
+ } catch (error) {
+ console.error("Error disposing file edit review controller:", error)
+ }
+
try {
// If we're not streaming then `abortStream` won't be called.
if (this.isStreaming && this.diffViewProvider.isEditing) {
diff --git a/src/core/tools/fileEditTool.ts b/src/core/tools/fileEditTool.ts
index 34914afc32..0bba05b09e 100644
--- a/src/core/tools/fileEditTool.ts
+++ b/src/core/tools/fileEditTool.ts
@@ -5,7 +5,7 @@ import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
-import { AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolUse } from "../../shared/tools"
+import { HandleError, PushToolResult, RemoveClosingTag, ToolUse } from "../../shared/tools"
import { fileExistsAtPath } from "../../utils/fs"
import { getReadablePath } from "../../utils/path"
import { ClineSayTool } from "../../shared/ExtensionMessage"
@@ -24,7 +24,6 @@ const PREVIEW_LIMIT = 500
export async function fileEditTool(
cline: Task,
block: ToolUse,
- askApproval: AskApproval,
handleError: HandleError,
pushToolResult: PushToolResult,
removeClosingTag: RemoveClosingTag,
@@ -106,13 +105,6 @@ export async function fileEditTool(
return
}
- const diff = formatResponse.createPrettyPatch(relPath, originalContent, newContent)
-
- if (!diff) {
- pushToolResult(`No changes needed for '${relPath}'.`)
- return
- }
-
const provider = cline.providerRef.deref()
const state = await provider?.getState()
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
@@ -125,40 +117,33 @@ export async function fileEditTool(
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
cline.diffViewProvider.originalContent = originalContent
- if (!isPreventFocusDisruptionEnabled) {
- await cline.diffViewProvider.open(relPath)
- await cline.diffViewProvider.update(newContent, true)
- cline.diffViewProvider.scrollToFirstDiff()
- }
+ await cline.diffViewProvider.saveDirectly(
+ relPath,
+ newContent,
+ !isPreventFocusDisruptionEnabled,
+ diagnosticsEnabled,
+ writeDelayMs,
+ )
- const approvalMessage = JSON.stringify({
+ const sayMessageProps: ClineSayTool = {
tool: "fileEdit",
path: readablePath,
- diff,
isProtected: isWriteProtected,
search: truncatePreview(oldString ?? "", PREVIEW_LIMIT),
replace: truncatePreview(newString ?? "", PREVIEW_LIMIT),
+ content: truncatePreview(newString ?? "", PREVIEW_LIMIT),
useRegex: false,
ignoreCase: false,
replaceAll,
- } satisfies ClineSayTool)
-
- const approved = await askApproval("tool", approvalMessage, undefined, isWriteProtected)
-
- if (!approved) {
- if (!isPreventFocusDisruptionEnabled) {
- await cline.diffViewProvider.revertChanges()
- }
- pushToolResult("Changes were rejected by the user.")
- await cline.diffViewProvider.reset()
- return
}
- if (isPreventFocusDisruptionEnabled) {
- await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
- } else {
- await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
- }
+ await cline.say("tool" as any, JSON.stringify(sayMessageProps))
+ cline.fileEditReviewController.addEdit({
+ relPath,
+ absolutePath,
+ originalContent,
+ newContent,
+ })
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
cline.didEditFile = true
@@ -286,13 +271,6 @@ function countOccurrences(haystack: string, needle: string): number {
return count
}
-function truncatePreview(value: string, limit: number): string {
- if (value.length <= limit) {
- return value
- }
- return value.slice(0, limit) + "\n...(truncated)"
-}
-
function* simpleReplacer(content: string, find: string): Generator {
if (find.length === 0) return
if (content.includes(find)) {
@@ -622,6 +600,13 @@ function extractBlock(content: string, lines: string[], start: number, end: numb
return content.substring(startIndex, endIndex)
}
+function truncatePreview(value: string, limit: number): string {
+ if (value.length <= limit) {
+ return value
+ }
+ return value.slice(0, limit) + "\n...(truncated)"
+}
+
function levenshtein(a: string, b: string): number {
if (a === "" || b === "") {
return Math.max(a.length, b.length)
diff --git a/src/integrations/editor/FileEditReviewController.ts b/src/integrations/editor/FileEditReviewController.ts
new file mode 100644
index 0000000000..16f1dace78
--- /dev/null
+++ b/src/integrations/editor/FileEditReviewController.ts
@@ -0,0 +1,242 @@
+import * as vscode from "vscode"
+import { promises as fs } from "fs"
+import * as path from "path"
+
+import { getReadablePath } from "../../utils/path"
+
+type PendingFileEdit = {
+ relPath: string
+ readablePath: string
+ absolutePath: string
+ originalContent: string
+ diffAnchor: vscode.Range
+}
+
+const labelDecorationType = vscode.window.createTextEditorDecorationType({
+ after: {
+ margin: "0 0 0 12px",
+ backgroundColor: new vscode.ThemeColor("badge.background"),
+ color: new vscode.ThemeColor("badge.foreground"),
+ fontWeight: "500",
+ },
+})
+
+const highlightDecorationType = vscode.window.createTextEditorDecorationType({
+ isWholeLine: true,
+ backgroundColor: new vscode.ThemeColor("editor.hoverHighlightBackground"),
+})
+
+const KEEP_COMMAND = "roo.fileEdit.keep"
+const UNDO_COMMAND = "roo.fileEdit.undo"
+const NEXT_COMMAND = "roo.fileEdit.reviewNext"
+
+export class FileEditReviewController implements vscode.Disposable {
+ private readonly disposables: vscode.Disposable[] = []
+ private pendingEdits = new Map()
+ private reviewQueue: string[] = []
+
+ constructor(private cwd: string) {
+ this.disposables.push(
+ vscode.window.onDidChangeActiveTextEditor(() => this.refreshDecorations()),
+ vscode.window.onDidChangeVisibleTextEditors(() => this.refreshDecorations()),
+ vscode.workspace.onDidCloseTextDocument((doc) => this.handleDocumentClosed(doc)),
+ vscode.commands.registerCommand(KEEP_COMMAND, (relPath?: string) => this.handleKeep(relPath)),
+ vscode.commands.registerCommand(UNDO_COMMAND, (relPath?: string) => this.handleUndo(relPath)),
+ vscode.commands.registerCommand(NEXT_COMMAND, () => this.handleReviewNext()),
+ )
+ }
+
+ addEdit(params: { relPath: string; absolutePath: string; originalContent: string; newContent: string }) {
+ const readablePath = getReadablePath(this.cwd, params.relPath)
+ const diffAnchor = computeFirstDifferenceRange(params.originalContent, params.newContent)
+
+ const pending: PendingFileEdit = {
+ relPath: params.relPath,
+ readablePath,
+ absolutePath: params.absolutePath,
+ originalContent: params.originalContent,
+ diffAnchor,
+ }
+
+ this.pendingEdits.set(readablePath, pending)
+ this.reviewQueue = this.reviewQueue.filter((path) => path !== readablePath)
+ this.reviewQueue.push(readablePath)
+
+ this.refreshDecorations()
+ }
+
+ async handleKeep(relPath?: string) {
+ const entry = this.getEntryForPath(relPath)
+ if (!entry) {
+ return
+ }
+
+ this.pendingEdits.delete(entry.readablePath)
+ this.reviewQueue = this.reviewQueue.filter((path) => path !== entry.readablePath)
+ this.refreshDecorations()
+ }
+
+ async handleUndo(relPath?: string) {
+ const entry = this.getEntryForPath(relPath)
+ if (!entry) {
+ return
+ }
+
+ await fs.writeFile(entry.absolutePath, entry.originalContent, "utf-8")
+ await vscode.workspace.openTextDocument(entry.absolutePath).then((doc) => doc.save())
+
+ this.pendingEdits.delete(entry.readablePath)
+ this.reviewQueue = this.reviewQueue.filter((path) => path !== entry.readablePath)
+ this.refreshDecorations()
+ }
+
+ async handleReviewNext() {
+ const nextEntry = this.getNextEntry()
+ if (!nextEntry) {
+ return
+ }
+
+ const document = await vscode.workspace.openTextDocument(nextEntry.absolutePath)
+ const editor = await vscode.window.showTextDocument(document, { preview: false })
+ editor.revealRange(nextEntry.diffAnchor, vscode.TextEditorRevealType.InCenter)
+ }
+
+ private getEntryForPath(relPath?: string): PendingFileEdit | undefined {
+ if (relPath) {
+ const readablePath = getReadablePath(this.cwd, relPath)
+ return this.pendingEdits.get(readablePath)
+ }
+
+ const activeEditor = vscode.window.activeTextEditor
+ if (!activeEditor) {
+ return undefined
+ }
+
+ const readablePath = getReadablePath(this.cwd, path.relative(this.cwd, activeEditor.document.uri.fsPath))
+ return this.pendingEdits.get(readablePath)
+ }
+
+ private getNextEntry(): PendingFileEdit | undefined {
+ for (const readablePath of this.reviewQueue) {
+ const entry = this.pendingEdits.get(readablePath)
+ if (entry) {
+ return entry
+ }
+ }
+ return undefined
+ }
+
+ private handleDocumentClosed(doc: vscode.TextDocument) {
+ const readablePath = getReadablePath(this.cwd, path.relative(this.cwd, doc.uri.fsPath))
+ if (this.pendingEdits.has(readablePath)) {
+ this.refreshDecorations()
+ }
+ }
+
+ private getOverlayMarkdown(entry?: PendingFileEdit): vscode.MarkdownString | undefined {
+ if (!entry && this.pendingEdits.size === 0) {
+ return undefined
+ }
+
+ const markdown = new vscode.MarkdownString(undefined, true)
+ markdown.isTrusted = true
+
+ if (entry) {
+ const args = encodeURIComponent(JSON.stringify({ relPath: entry.relPath }))
+ const keepLink = `[Keep](command:${KEEP_COMMAND}?${args})`
+ const undoLink = `[Undo](command:${UNDO_COMMAND}?${args})`
+ markdown.appendMarkdown(`${keepLink} • ${undoLink}`)
+ } else {
+ markdown.appendMarkdown(`[Review next file](command:${NEXT_COMMAND})`)
+ }
+
+ markdown.appendMarkdown("\n\n")
+ markdown.appendMarkdown("**Pending files**\n")
+ for (const filePath of this.reviewQueue) {
+ if (this.pendingEdits.has(filePath)) {
+ markdown.appendMarkdown(`- ${filePath}\n`)
+ }
+ }
+
+ return markdown
+ }
+
+ private refreshDecorations() {
+ const hasPending = this.pendingEdits.size > 0
+ for (const editor of vscode.window.visibleTextEditors) {
+ const readableEditorPath = getReadablePath(this.cwd, path.relative(this.cwd, editor.document.uri.fsPath))
+ const entry = this.pendingEdits.get(readableEditorPath)
+
+ const labelDecorations: vscode.DecorationOptions[] = []
+ const highlightDecorations: vscode.DecorationOptions[] = []
+
+ if (entry) {
+ const labelRange = new vscode.Range(editor.document.lineCount - 1, 0, editor.document.lineCount - 1, 0)
+ labelDecorations.push({
+ range: labelRange,
+ hoverMessage: this.getOverlayMarkdown(entry),
+ renderOptions: {
+ after: {
+ contentText: ` $(tools) Review changes`,
+ },
+ },
+ })
+
+ highlightDecorations.push({
+ range: entry.diffAnchor,
+ hoverMessage: this.getOverlayMarkdown(entry),
+ })
+ } else if (hasPending) {
+ const labelRange = new vscode.Range(editor.document.lineCount - 1, 0, editor.document.lineCount - 1, 0)
+ labelDecorations.push({
+ range: labelRange,
+ hoverMessage: this.getOverlayMarkdown(),
+ renderOptions: {
+ after: {
+ contentText: ` $(arrow-right) Review next file`,
+ },
+ },
+ })
+ }
+
+ editor.setDecorations(labelDecorationType, labelDecorations)
+ editor.setDecorations(highlightDecorationType, highlightDecorations)
+ }
+ }
+
+ dispose() {
+ for (const disposable of this.disposables) {
+ disposable.dispose()
+ }
+ this.pendingEdits.clear()
+ this.reviewQueue = []
+ vscode.window.visibleTextEditors.forEach((editor) => {
+ editor.setDecorations(labelDecorationType, [])
+ editor.setDecorations(highlightDecorationType, [])
+ })
+ }
+}
+
+function computeFirstDifferenceRange(originalContent: string, newContent: string): vscode.Range {
+ const originalLines = originalContent.split("\n")
+ const newLines = newContent.split("\n")
+
+ let line = 0
+ while (line < originalLines.length && line < newLines.length && originalLines[line] === newLines[line]) {
+ line++
+ }
+
+ const originalLine = originalLines[line] ?? ""
+ const newLine = newLines[line] ?? ""
+
+ let character = 0
+ while (
+ character < originalLine.length &&
+ character < newLine.length &&
+ originalLine[character] === newLine[character]
+ ) {
+ character++
+ }
+
+ return new vscode.Range(line, character, line, character)
+}
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx
index 2dc0b72091..4973a909bf 100644
--- a/webview-ui/src/components/chat/ChatRow.tsx
+++ b/webview-ui/src/components/chat/ChatRow.tsx
@@ -372,10 +372,16 @@ export const ChatRowContent = ({
wordBreak: "break-word",
}
- const tool = useMemo(
- () => (message.ask === "tool" ? safeJsonParse(message.text) : null),
- [message.ask, message.text],
- )
+ const tool = useMemo(() => {
+ const isToolAsk = message.type === "ask" && message.ask === "tool"
+ const isToolSay = message.type === "say" && (message.say as any) === "tool"
+
+ if (isToolAsk || isToolSay) {
+ return safeJsonParse(message.text)
+ }
+
+ return null
+ }, [message.type, message.ask, message.say, message.text])
const followUpData = useMemo(() => {
if (message.type === "ask" && message.ask === "followup" && !message.partial) {
@@ -468,7 +474,7 @@ export const ChatRowContent = ({