Remove Explain Changes feature (#11278)

* chore(vscode): remove explain changes entry points

* chore(vscode): remove explain changes feature
This commit is contained in:
Ara
2026-06-04 17:15:54 -07:00
committed by Dominic Cooney
parent f9fcf7f5ce
commit 9c030a93b6
19 changed files with 8 additions and 1216 deletions
-35
View File
@@ -229,26 +229,9 @@
"command": "cline.reconstructTaskHistory",
"title": "Reconstruct Task History",
"category": "Cline"
},
{
"command": "cline.reviewComment.reply",
"title": "Reply",
"category": "Cline",
"enablement": "!commentIsEmpty"
},
{
"command": "cline.reviewComment.addToChat",
"title": "Add to Cline Chat",
"category": "Cline",
"icon": "$(link-external)"
}
],
"keybindings": [
{
"command": "editor.action.submitComment",
"key": "enter",
"when": "commentEditorFocused && commentController == cline-ai-review && !commentIsEmpty"
},
{
"command": "cline.addToChat",
"key": "cmd+'",
@@ -350,24 +333,6 @@
{
"command": "cline.abortGitCommitMessage",
"when": "config.git.enabled && cline.isGeneratingCommit"
},
{
"command": "cline.reviewComment.reply",
"when": "false"
}
],
"comments/commentThread/context": [
{
"command": "cline.reviewComment.reply",
"group": "inline",
"when": "commentController == cline-ai-review"
}
],
"comments/commentThread/title": [
{
"command": "cline.reviewComment.addToChat",
"group": "inline",
"when": "commentController == cline-ai-review"
}
]
},
+1 -1
View File
@@ -23,7 +23,7 @@ message SlashCommandInfo {
string name = 1; // Command name without slash, e.g., "newtask", "smol"
string description = 2; // Human-readable description
string section = 3; // "default", "custom", or "cli"
bool cli_compatible = 4; // false for VS Code-only commands like explain-changes
bool cli_compatible = 4; // false for VS Code-only commands
}
// Response containing all available slash commands
-9
View File
@@ -40,8 +40,6 @@ service TaskService {
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
// Deletes all task history
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
// Explains changes with AI and adds inline comments to the diff view
rpc explainChanges(ExplainChangesRequest) returns (Empty);
}
// Request message for creating a new task
@@ -127,10 +125,3 @@ message ExecuteQuickWinRequest {
message DeleteAllTaskHistoryCount {
int32 tasks_deleted = 1;
}
// Request for explaining changes with AI
message ExplainChangesRequest {
Metadata metadata = 1;
// Timestamp of the completion message to explain changes for
int64 message_ts = 2;
}
-1
View File
@@ -67,7 +67,6 @@ enum ClineSay {
INFO = 26;
TASK_PROGRESS = 27;
ERROR_RETRY = 28;
GENERATE_EXPLANATION = 29;
HOOK_STATUS = 30;
HOOK_OUTPUT_STREAM = 31;
COMMAND_PERMISSION_DENIED = 32;
@@ -1,252 +0,0 @@
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import { findLast } from "@shared/array"
import { Empty } from "@shared/proto/cline/common"
import { ExplainChangesRequest } from "@shared/proto/cline/task"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
import { sendRelinquishControlEvent } from "../ui/subscribeToRelinquishControl"
import {
buildDiffContent,
openDiffView,
setupCommentController,
streamAIExplanationComments,
stringifyConversationHistory,
} from "./explainChangesShared"
/**
* Explains the changes made by the AI and adds inline comments explaining them.
*
* This handler streams comments in real-time:
* 1. Gets the diff from the checkpoint tracker
* 2. Opens the diff view IMMEDIATELY so user sees progress
* 3. Streams the AI response and adds comments as they're generated
* 4. Each comment appears in the diff view as soon as it's parsed
*/
export async function explainChanges(controller: Controller, request: ExplainChangesRequest): Promise<Empty> {
const relinquishButton = () => {
sendRelinquishControlEvent()
}
try {
// Validate we have an active task with checkpoint manager
if (!controller.task) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "No active task",
})
relinquishButton()
return Empty.create({})
}
const checkpointManager = controller.task.checkpointManager as any
if (!checkpointManager) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Checkpoints not enabled",
})
relinquishButton()
return Empty.create({})
}
// Check if checkpoints are enabled
if (!checkpointManager.config?.enableCheckpoints) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Checkpoints are disabled in settings. Cannot review changes.",
})
relinquishButton()
return Empty.create({})
}
// Get message state handler
const messageStateHandler = checkpointManager.services?.messageStateHandler
if (!messageStateHandler) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Message state handler not available",
})
relinquishButton()
return Empty.create({})
}
// Find the message
const clineMessages = messageStateHandler.getClineMessages()
const messageIndex = clineMessages.findIndex((m: any) => m.ts === request.messageTs)
const message = clineMessages[messageIndex]
if (!message) {
Logger.error(`[explainChanges] Message not found for timestamp ${request.messageTs}`)
relinquishButton()
return Empty.create({})
}
const hash = message.lastCheckpointHash
if (!hash) {
Logger.error(`[explainChanges] No checkpoint hash found for message ${request.messageTs}`)
relinquishButton()
return Empty.create({})
}
// Initialize checkpoint tracker if needed (same logic as presentMultifileDiff)
if (
!checkpointManager.state?.checkpointTracker &&
checkpointManager.config?.enableCheckpoints &&
!checkpointManager.state?.checkpointManagerErrorMessage
) {
try {
const workspacePath = await checkpointManager.getWorkspacePath()
checkpointManager.state.checkpointTracker = await CheckpointTracker.create(
checkpointManager.task.taskId,
checkpointManager.config.enableCheckpoints,
workspacePath,
)
messageStateHandler.setCheckpointTracker(checkpointManager.state.checkpointTracker)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
Logger.error(`[explainChanges] Failed to initialize checkpoint tracker:`, errorMessage)
checkpointManager.state.checkpointManagerErrorMessage = errorMessage
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
relinquishButton()
return Empty.create({})
}
}
const checkpointTracker = checkpointManager.state?.checkpointTracker as CheckpointTracker | undefined
if (!checkpointTracker) {
Logger.error(`[explainChanges] Checkpoint tracker not available`)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Checkpoint tracker not available",
})
relinquishButton()
return Empty.create({})
}
// Get changed files (using seeNewChangesSinceLastTaskCompletion logic)
const lastTaskCompletedMessageCheckpointHash = findLast(
clineMessages.slice(0, messageIndex),
(m: any) => m.say === "completion_result",
)?.lastCheckpointHash
const firstCheckpointMessageCheckpointHash = clineMessages.find(
(m: any) => m.say === "checkpoint_created",
)?.lastCheckpointHash
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash
if (!previousCheckpointHash) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Unexpected error: No checkpoint hash found",
})
relinquishButton()
return Empty.create({})
}
const changedFiles = await checkpointTracker.getDiffSet(previousCheckpointHash, hash)
if (!changedFiles?.length) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "No changes found to review",
})
relinquishButton()
return Empty.create({})
}
// Get API configuration
const apiConfiguration = controller.stateManager.getApiConfiguration()
if (!apiConfiguration) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "API configuration not available",
})
relinquishButton()
return Empty.create({})
}
// Get conversation summary for context
const apiConversationHistory = messageStateHandler.getApiConversationHistory()
const conversationSummary = stringifyConversationHistory(apiConversationHistory)
// Set up the comment controller with reply handler
const commentController = await setupCommentController(apiConfiguration, changedFiles, conversationSummary)
// Build the diff content for the AI
const diffContent = buildDiffContent(changedFiles)
// For 3+ files, cycle through each file showing comments as they stream
// For 2 or fewer files, just open the multi-diff view directly
const shouldRevealComments = changedFiles.length >= 3
// If 2 or fewer files, open the diff view first so user sees it immediately
if (!shouldRevealComments) {
await openDiffView("Explain Changes", changedFiles)
}
// Capture reference to the task for abort checking
const task = controller.task
// Stream AI explanation comments and add them as they arrive
// Each comment will open its virtual doc and scroll to show the comment (if 3+ files)
await streamAIExplanationComments(
apiConfiguration,
diffContent,
conversationSummary,
changedFiles,
// onCommentStart: Create the comment UI immediately when we know the location
(filePath, startLine, endLine) => {
const matchingFile = changedFiles.find((f) => f.absolutePath === filePath || f.relativePath === filePath)
commentController.startStreamingComment(
filePath,
startLine,
endLine,
matchingFile?.relativePath,
matchingFile?.after,
shouldRevealComments, // Only cycle through files if 3+ files
)
},
// onCommentChunk: Append text as it streams in
(chunk) => {
commentController.appendToStreamingComment(chunk)
},
// onCommentEnd: Finalize the comment
() => {
commentController.endStreamingComment()
},
// shouldAbort: Check if task was cancelled
() => task?.taskState?.abort === true,
)
// Check if we were aborted during streaming
if (task?.taskState?.abort) {
// Close diff views and clear comments when cancelled
commentController.clearAllComments()
await commentController.closeDiffViews()
relinquishButton()
return Empty.create({})
}
// After all comments are done, open the multi-diff view to show everything together (if 3+ files)
if (shouldRevealComments) {
await openDiffView("Explain Changes", changedFiles)
}
// Relinquish button after comments are done
relinquishButton()
return Empty.create({})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
Logger.error("Error in explainChanges:", errorMessage)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to explain changes: " + errorMessage,
})
sendRelinquishControlEvent()
return Empty.create({})
}
}
@@ -1,462 +0,0 @@
import { isBinaryFile } from "isbinaryfile"
import { HostProvider } from "@/hosts/host-provider"
import { formatContentBlockToMarkdown } from "@/integrations/misc/export-markdown"
import { buildApiHandler } from "@/sdk/sdk-api-handler"
import { ApiConfiguration } from "@/shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
export interface ChangedFile {
relativePath: string
absolutePath: string
before: string
after: string
}
const EXPLAINER_SYSTEM_PROMPT = `You are an AI coding assistant called Cline that will be explaining code changes to a developer. Your goal is to help the user understand what changed and why.
- Use a friendly, conversational tone as if pair programming
- When relevant, briefly explain technical concepts or patterns used
- Focus on helping the user learn and understand the codebase
- Highlight any important decisions, trade-offs, or things the user should be aware of
Remember: The user wants to understand the changes well enough to maintain, extend, or debug this code themselves.
`
/**
* Add line numbers to content (0-indexed for AI reference)
*/
function addLineNumbers(content: string): string {
if (!content) {
return content
}
const lines = content.split("\n")
return lines.map((line, index) => `${index}: ${line}`).join("\n")
}
/**
* Build a unified diff content string for the AI
* Includes 0-indexed line numbers so the AI can reference specific lines
*/
export function buildDiffContent(changedFiles: ChangedFile[]): string {
const parts: string[] = []
for (const file of changedFiles) {
parts.push(`\n=== File: ${file.absolutePath} ===\n`)
parts.push(`--- Before ---\n${file.before || "(new file)"}\n`)
parts.push(
`--- After (use these line numbers for comments) ---\n${file.after ? addLineNumbers(file.after) : "(deleted)"}\n`,
)
}
return parts.join("\n")
}
/**
* Open the multi-file diff view with the changed files
*/
export async function openDiffView(title: string, changedFiles: ChangedFile[]): Promise<void> {
await HostProvider.diff.openMultiFileDiff({
title,
diffs: changedFiles.map((file) => ({
filePath: file.absolutePath,
leftContent: file.before,
rightContent: file.after,
})),
})
}
/**
* Set up the comment controller and reply handler
*/
export async function setupCommentController(
apiConfiguration: ApiConfiguration,
changedFiles: ChangedFile[],
conversationContext: string,
) {
const commentController = HostProvider.get().createCommentReviewController()
commentController.clearAllComments()
// Ensure the Comments panel won't auto-open when we add comments
await commentController.ensureCommentsViewDisabled()
// Set up reply handler for conversations
commentController.setOnReplyCallback(async (filePath, startLine, endLine, replyText, existingComments, onChunk) => {
await handleCommentReply(
apiConfiguration,
filePath,
startLine,
endLine,
replyText,
existingComments,
changedFiles,
conversationContext,
onChunk,
)
})
return commentController
}
/**
* Stream AI explanation comments with real-time updates.
* Uses a structured format that allows creating comment UI immediately when location is known,
* then streaming the comment text as it arrives.
*
* @param shouldAbort - Optional callback that returns true if the operation should be aborted
*/
export async function streamAIExplanationComments(
apiConfiguration: ApiConfiguration,
diffContent: string,
contextDescription: string,
changedFiles: ChangedFile[],
onCommentStart: (filePath: string, startLine: number, endLine: number) => void,
onCommentChunk: (chunk: string) => void,
onCommentEnd: () => void,
shouldAbort?: () => boolean,
): Promise<number> {
// Disable thinking/reasoning for a faster, cheaper response.
const apiHandler = buildApiHandler(apiConfiguration, "act", { disableReasoning: true })
const fileCount = changedFiles.length
const maxCommentsPerFile = fileCount > 3 ? 1 : 3
const systemPrompt = `${EXPLAINER_SYSTEM_PROMPT}
CRITICAL: Create comments for LOGICAL GROUPINGS of changes, not individual lines. Think in terms of:
- "Added a new function that does X" (spanning the entire function)
- "Refactored error handling in this section" (spanning all related changes)
- "Updated imports and dependencies" (one comment for all import changes)
OUTPUT FORMAT - Use this exact structure for each comment:
@@@ FILE: /absolute/path/to/file.ts
@@@ LINE: 45
Your explanation of what changed and why goes here. Can be multiple sentences.
@@@
@@@ FILE: /absolute/path/to/other.ts
@@@ LINE: 20
Another explanation here.
@@@
Rules:
1. Start each comment with @@@ FILE: followed by the absolute file path
2. Next line must be @@@ LINE: followed by a single line number (0-indexed from the "After" content)
- For ADDITIONS or MODIFICATIONS: Use the LAST LINE of the changed code block
- For DELETIONS: Use the FIRST LINE where the deletion occurred (the line number in "After" where content was removed)
- The diff view collapses unchanged lines, so comments must be on a line that's part of the diff to be visible
3. Then write your comment text (can span multiple lines). Use markdown formatting where appropriate.
4. End with @@@ on its own line
5. Each file MUST have at least one comment, MAX ${maxCommentsPerFile} comment${maxCommentsPerFile > 1 ? "s" : ""} per file - focus on the most significant changes
6. Explain important/non-obvious changes, not every little thing. Skip trivial changes - ignore whitespace, formatting, simple renames, obvious fixes.
`
const userMessage = `Explain these code changes:
## Context
${contextDescription}
## Files changed
${changedFiles.map((f) => `- ${f.absolutePath}`).join("\n")}
## Diff content
${diffContent}
Output your explanation comments now using the @@@ format:`
let commentCount = 0
let buffer = ""
let currentFile: string | null = null
let currentStartLine: number | null = null
let currentEndLine: number | null = null
let inComment = false
try {
for await (const chunk of apiHandler.createMessage(systemPrompt, [{ role: "user", content: userMessage }])) {
// Check if we should abort before processing each chunk
if (shouldAbort?.()) {
// If we're in the middle of a comment, end it cleanly
if (inComment) {
onCommentEnd()
}
return commentCount
}
if (chunk.type === "text") {
buffer += chunk.text
// Process buffer line by line, keeping incomplete lines
while (true) {
// Check abort before processing each line
if (shouldAbort?.()) {
if (inComment) {
onCommentEnd()
}
return commentCount
}
const newlineIndex = buffer.indexOf("\n")
if (newlineIndex === -1) {
break
}
const line = buffer.substring(0, newlineIndex)
buffer = buffer.substring(newlineIndex + 1)
const trimmedLine = line.trim()
// Check for FILE header
if (trimmedLine.startsWith("@@@ FILE:")) {
const filePath = trimmedLine.substring("@@@ FILE:".length).trim()
const matchingFile = changedFiles.find((f) => f.absolutePath === filePath || f.relativePath === filePath)
currentFile = matchingFile?.absolutePath || filePath
continue
}
// Check for LINE header (single line number)
if (trimmedLine.startsWith("@@@ LINE:")) {
const lineStr = trimmedLine.substring("@@@ LINE:".length).trim()
const lineNum = Number.parseInt(lineStr, 10)
if (!Number.isNaN(lineNum) && currentFile) {
currentStartLine = lineNum
currentEndLine = lineNum
// Now we have location - create the comment UI immediately!
onCommentStart(currentFile, currentStartLine, currentEndLine)
inComment = true
commentCount++
}
continue
}
// Check for end marker
if (trimmedLine === "@@@") {
if (inComment) {
onCommentEnd()
inComment = false
currentFile = null
currentStartLine = null
currentEndLine = null
}
continue
}
// If we're in a comment, stream the text
if (inComment) {
onCommentChunk(line + "\n")
}
}
// Stream partial content in buffer for more responsive UI
// But don't stream if it might be a marker (starts with @)
if (inComment && buffer.length > 0 && !buffer.startsWith("@")) {
onCommentChunk(buffer)
buffer = "" // Clear buffer after streaming
}
}
}
// Handle any remaining content in buffer
if (buffer.trim()) {
const trimmedBuffer = buffer.trim()
if (trimmedBuffer === "@@@") {
if (inComment) {
onCommentEnd()
inComment = false
}
} else if (inComment && !trimmedBuffer.startsWith("@@@")) {
onCommentChunk(buffer)
onCommentEnd()
inComment = false
}
} else if (inComment) {
onCommentEnd()
}
return commentCount
} catch (error) {
Logger.error("Error streaming AI explanation comments:", error)
if (inComment) {
onCommentEnd()
}
return commentCount
}
}
/**
* Handle a reply to a comment thread with streaming
*/
async function handleCommentReply(
apiConfiguration: ApiConfiguration,
filePath: string,
startLine: number,
endLine: number,
replyText: string,
existingComments: string[],
changedFiles: ChangedFile[],
conversationContext: string,
onChunk: (chunk: string) => void,
): Promise<void> {
// Find the relevant file - check both absolutePath and relativePath for robustness
const file = changedFiles.find((f) => f.absolutePath === filePath || f.relativePath === filePath)
if (!file) {
onChunk("Error: Could not find the file context")
return
}
// Get the relevant code snippet
const afterLines = file.after.split("\n")
const codeSnippet = afterLines.slice(startLine, endLine + 1).join("\n")
// Disable thinking/reasoning for a faster, cheaper response.
const apiHandler = buildApiHandler(apiConfiguration, "act", { disableReasoning: true })
const systemPrompt = `${EXPLAINER_SYSTEM_PROMPT}
The user is asking followup questions about code change explanations you provided.
Respond helpfully to the user's question about the code.
Use markdown formatting where appropriate.
If the user asks you to make changes, fix something, or do any work that requires modifying code, let them know they can click the "Add to Cline Chat" button (the arrow icon in the top-right of the comment box) to send this conversation to the main Cline agent, which can then make the requested changes.
`
const userMessage = `## Context
${conversationContext}
## Code Being Discussed
File: ${file.relativePath}
Lines ${startLine + 1}-${endLine + 1}:
\`\`\`
${codeSnippet}
\`\`\`
## Comment Thread
${existingComments.join("\n\n")}
## User's Question
${replyText}
Please respond to the user's question about this code.`
try {
for await (const chunk of apiHandler.createMessage(systemPrompt, [{ role: "user", content: userMessage }])) {
if (chunk.type === "text") {
onChunk(chunk.text)
}
}
} catch (error) {
Logger.error("Error getting reply:", error)
onChunk(`Error: ${error instanceof Error ? error.message : "Unknown error"}`)
}
}
/**
* Stringify conversation history into a readable summary for context
*/
export function stringifyConversationHistory(apiConversationHistory: ClineStorageMessage[]): string {
if (!apiConversationHistory || apiConversationHistory.length === 0) {
return "No prior conversation context available."
}
return apiConversationHistory
.map((message) => {
const role = message.role === "user" ? "**User:**" : "**Assistant:**"
const content = Array.isArray(message.content)
? message.content.map((block) => formatContentBlockToMarkdown(block)).join("\n")
: message.content
return `${role}\n\n${content}\n\n`
})
.join("---\n\n")
}
/**
* Binary file extensions to exclude from diff view
*/
const BINARY_EXTENSIONS = new Set([
// Images
".png",
".jpg",
".jpeg",
".gif",
".bmp",
".ico",
".webp",
".svg",
".tiff",
".tif",
// Audio
".mp3",
".wav",
".ogg",
".flac",
".aac",
".m4a",
// Video
".mp4",
".avi",
".mov",
".wmv",
".flv",
".webm",
".mkv",
// Archives
".zip",
".tar",
".gz",
".rar",
".7z",
".bz2",
// Documents
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
// Fonts
".ttf",
".otf",
".woff",
".woff2",
".eot",
// Executables/binaries
".exe",
".dll",
".so",
".dylib",
".bin",
".o",
".a",
// Other
".db",
".sqlite",
".sqlite3",
".lock",
".wasm",
])
/**
* Check if a file is binary based on its extension or content.
* @param filePath - Absolute path to the file to check
* @returns Promise<boolean> - true if the file is binary, false if text or if detection fails
*/
export async function detectBinaryFile(filePath: string): Promise<boolean> {
const lastDotIndex = filePath.lastIndexOf(".")
const lastSlashIndex = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"))
const ext = lastDotIndex > lastSlashIndex ? filePath.substring(lastDotIndex).toLowerCase() : ""
const isDotfile = lastDotIndex !== -1 && lastDotIndex === lastSlashIndex + 1
// Legacy/fast method: Check known binary extensions
if (ext && BINARY_EXTENSIONS.has(ext)) {
return true
}
// Use actual binary check for dotfiles or files without extensions. Returns true if file is binary.
if (!ext || isDotfile) {
try {
const result = await isBinaryFile(filePath)
return result
} catch {
return false
}
}
return false
}
@@ -1,4 +1,4 @@
import { CommentReviewController, type OnReplyCallback, type ReviewComment } from "@/integrations/editor/CommentReviewController"
import { CommentReviewController, type ReviewComment } from "@/integrations/editor/CommentReviewController"
/**
* External (non-VS Code) implementation of CommentReviewController.
@@ -7,10 +7,6 @@ import { CommentReviewController, type OnReplyCallback, type ReviewComment } fro
* inline code comments (e.g., JetBrains, CLI).
*/
export class ExternalCommentReviewController extends CommentReviewController {
setOnReplyCallback(_callback: OnReplyCallback): void {
// No-op
}
async ensureCommentsViewDisabled(): Promise<void> {
// No-op
}
@@ -1,6 +1,5 @@
import * as vscode from "vscode"
import { sendAddToInputEvent } from "@/core/controller/ui/subscribeToAddToInput"
import { CommentReviewController, type OnReplyCallback, type ReviewComment } from "@/integrations/editor/CommentReviewController"
import { CommentReviewController, type ReviewComment } from "@/integrations/editor/CommentReviewController"
import { Logger } from "@/shared/services/Logger"
import { DIFF_VIEW_URI_SCHEME } from "../VscodeDiffViewProvider"
@@ -18,10 +17,6 @@ const CLINE_AVATAR_URL = "https://avatars.githubusercontent.com/u/184127137"
export class VscodeCommentReviewController extends CommentReviewController implements vscode.Disposable {
private commentController: vscode.CommentController
private threads: Map<string, vscode.CommentThread> = new Map()
/** Maps thread to its absolute file path (needed because virtual URIs don't contain the full path) */
private threadFilePaths: Map<vscode.CommentThread, string> = new Map()
private onReplyCallback?: OnReplyCallback
private disposables: vscode.Disposable[] = []
/** The currently streaming comment thread */
private streamingThread: vscode.CommentThread | null = null
@@ -31,42 +26,6 @@ export class VscodeCommentReviewController extends CommentReviewController imple
super()
// Create the comment controller
this.commentController = vscode.comments.createCommentController("cline-ai-review", "Cline AI Review")
// Configure options for the reply input
this.commentController.options = {
placeHolder: "Ask a question about this code...",
prompt: "Reply to Cline",
}
// Configure the commenting range provider (optional - allows commenting on any line)
this.commentController.commentingRangeProvider = {
provideCommentingRanges: (document: vscode.TextDocument, _token: vscode.CancellationToken): vscode.Range[] => {
// Allow commenting on any line in the document
const lineCount = document.lineCount
return [new vscode.Range(0, 0, lineCount - 1, 0)]
},
}
// Register reply command - this is called when user clicks the Reply button
this.disposables.push(
vscode.commands.registerCommand("cline.reviewComment.reply", async (reply: vscode.CommentReply) => {
await this.handleReply(reply)
}),
)
// Register add to chat command - sends the conversation to Cline's main chat
this.disposables.push(
vscode.commands.registerCommand("cline.reviewComment.addToChat", async (thread: vscode.CommentThread) => {
await this.handleAddToChat(thread)
}),
)
}
/**
* Set the callback for handling user replies
*/
setOnReplyCallback(callback: OnReplyCallback): void {
this.onReplyCallback = callback
}
/**
@@ -114,14 +73,12 @@ export class VscodeCommentReviewController extends CommentReviewController imple
const thread = this.commentController.createCommentThread(uri, range, [commentObj])
// Configure thread
thread.canReply = true
thread.canReply = false
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded
// Store for later management
const threadKey = this.getThreadKey(comment.filePath, comment.startLine, comment.endLine)
this.threads.set(threadKey, thread)
// Store absolute file path for reply handling (virtual URIs don't contain the full path)
this.threadFilePaths.set(thread, comment.filePath)
}
/**
@@ -159,7 +116,7 @@ export class VscodeCommentReviewController extends CommentReviewController imple
// Create the thread
const thread = this.commentController.createCommentThread(uri, range, [commentObj])
thread.canReply = true
thread.canReply = false
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded
// Store for streaming updates
@@ -169,7 +126,6 @@ export class VscodeCommentReviewController extends CommentReviewController imple
// Store for later management
const threadKey = this.getThreadKey(filePath, startLine, endLine)
this.threads.set(threadKey, thread)
this.threadFilePaths.set(thread, filePath)
// Open the virtual document and scroll to show the comment in center (only if requested)
if (revealComment) {
@@ -263,7 +219,6 @@ export class VscodeCommentReviewController extends CommentReviewController imple
*/
clearAllComments(): void {
for (const thread of this.threads.values()) {
this.threadFilePaths.delete(thread)
thread.dispose()
}
this.threads.clear()
@@ -276,7 +231,6 @@ export class VscodeCommentReviewController extends CommentReviewController imple
const keysToRemove: string[] = []
for (const [key, thread] of this.threads.entries()) {
if (key.startsWith(filePath + ":")) {
this.threadFilePaths.delete(thread)
thread.dispose()
keysToRemove.push(key)
}
@@ -293,122 +247,6 @@ export class VscodeCommentReviewController extends CommentReviewController imple
return this.threads.size
}
/**
* Handle a reply from the user
*/
private async handleReply(reply: vscode.CommentReply): Promise<void> {
const thread = reply.thread
const replyText = reply.text
// Add user's reply to the thread immediately
const userComment: vscode.Comment = {
body: new vscode.MarkdownString(replyText),
mode: vscode.CommentMode.Preview,
author: {
name: "You",
},
}
thread.comments = [...thread.comments, userComment]
// If we have a callback, get AI response
if (this.onReplyCallback) {
// Use stored absolute path (virtual URIs don't contain the full path)
const filePath = this.threadFilePaths.get(thread) || thread.uri.fsPath
const startLine = thread.range.start.line
const endLine = thread.range.end.line
// Collect existing comments for context (exclude the user's reply we just added)
const existingComments = thread.comments.slice(0, -1).map((c) => {
const author = c.author.name
const body = typeof c.body === "string" ? c.body : c.body.value
return `${author}: ${body}`
})
// Add an empty streaming comment that will be updated as chunks arrive
let streamingContent = ""
const updateStreamingComment = (content: string) => {
const streamingComment: vscode.Comment = {
body: new vscode.MarkdownString(content || "_Thinking..._"),
mode: vscode.CommentMode.Preview,
author: {
name: "Cline",
iconPath: vscode.Uri.parse(CLINE_AVATAR_URL),
},
}
thread.comments = [...thread.comments.slice(0, -1), streamingComment]
}
// Add initial thinking placeholder
const thinkingComment: vscode.Comment = {
body: new vscode.MarkdownString("_Thinking..._"),
mode: vscode.CommentMode.Preview,
author: {
name: "Cline",
iconPath: vscode.Uri.parse(CLINE_AVATAR_URL),
},
}
thread.comments = [...thread.comments, thinkingComment]
// Fire off the AI request with streaming callback
this.onReplyCallback(filePath, startLine, endLine, replyText, existingComments, (chunk) => {
// Append chunk and update the comment
streamingContent += chunk
updateStreamingComment(streamingContent)
})
.then(() => {
// Ensure final content is displayed
if (streamingContent) {
updateStreamingComment(streamingContent)
}
})
.catch((error) => {
// Show error
const errorComment: vscode.Comment = {
body: new vscode.MarkdownString(
`_Error getting response: ${error instanceof Error ? error.message : "Unknown error"}_`,
),
mode: vscode.CommentMode.Preview,
author: {
name: "Cline",
iconPath: vscode.Uri.parse(CLINE_AVATAR_URL),
},
}
thread.comments = [...thread.comments.slice(0, -1), errorComment]
})
}
}
/**
* Handle adding the thread conversation to Cline's main chat
*/
private async handleAddToChat(thread: vscode.CommentThread): Promise<void> {
const filePath = this.threadFilePaths.get(thread) || thread.uri.fsPath
const startLine = thread.range.start.line + 1 // Convert to 1-indexed for display
const endLine = thread.range.end.line + 1
// Collect all comments from the thread
const conversation = thread.comments
.map((c) => {
const author = c.author.name === "You" ? "User" : c.author.name
const body = typeof c.body === "string" ? c.body : c.body.value
return `**${author}:** ${body}`
})
.join("\n\n")
// Format the context message
const contextMessage = `The following is a conversation from a code review comment on \`${filePath}\` (lines ${startLine}-${endLine}). The user would like to continue this discussion with you:
---
${conversation}
---
Please continue helping the user with their question about this code.`
await sendAddToInputEvent(contextMessage)
}
private getThreadKey(filePath: string, startLine: number, endLine: number): string {
return `${filePath}:${startLine}:${endLine}`
}
@@ -443,9 +281,6 @@ Please continue helping the user with their question about this code.`
dispose(): void {
this.clearAllComments()
this.commentController.dispose()
for (const disposable of this.disposables) {
disposable.dispose()
}
}
}
@@ -18,36 +18,17 @@ export interface ReviewComment {
fileContent?: string
}
/**
* Callback for when user replies to a comment thread.
* The onChunk callback is called with each text chunk as it streams in.
*/
export type OnReplyCallback = (
filePath: string,
startLine: number,
endLine: number,
replyText: string,
existingComments: string[],
onChunk: (chunk: string) => void,
) => Promise<void>
/**
* Abstract base class for managing AI code review comments.
*
* This controller:
* - Creates inline comment threads on files at specific line ranges
* - Displays AI-generated review comments with markdown support
* - Handles user replies and dispatches them to the AI
* - Manages the lifecycle of all comment threads
*
* Platform-specific implementations handle the actual UI rendering.
*/
export abstract class CommentReviewController {
/**
* Set the callback for handling user replies
*/
abstract setOnReplyCallback(callback: OnReplyCallback): void
/**
* Ensure the comments view won't auto-open when comments are added
*/
+2 -2
View File
@@ -3,7 +3,7 @@
// Builds an SDK ApiHandler (from `@cline/llms`) directly from the extension's
// legacy ApiConfiguration. This is the single inference path: the main task
// loop runs through ClineCore (see cline-session-factory.ts), and standalone
// utility callers (commit message generation, explain-changes) use the handler
// utility callers (commit message generation) use the handler
// returned here. Both share the same provider/model/key/baseUrl resolution so
// there is no second source of truth.
@@ -18,7 +18,7 @@ import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
export interface BuildApiHandlerOptions {
/**
* Disable extended thinking/reasoning for this handler. Standalone utility
* calls (commit message generation, explain-changes) want fast, cheap,
* calls (commit message generation) want fast, cheap,
* deterministic completions and don't benefit from reasoning. When true we
* send `thinking: false` and omit both effort and budget so providers like
* OpenRouter don't receive a reasoning config at all.
@@ -237,7 +237,6 @@ export type ClineSay =
| "command_permission_denied"
| "checkpoint_created"
| "load_mcp_documentation"
| "generate_explanation"
| "info" // Added for general informational messages like retry status
| "task_progress"
| "hook_status"
@@ -319,14 +318,6 @@ export interface ClineSayBrowserAction {
text?: string
}
export interface ClineSayGenerateExplanation {
title: string
fromRef: string
toRef: string
status: "generating" | "complete" | "error"
error?: string
}
export type SubagentExecutionStatus = "pending" | "running" | "completed" | "failed"
export interface SubagentStatusItem {
@@ -108,7 +108,6 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
subagent: ClineSay.SUBAGENT_STATUS,
use_subagents: ClineSay.USE_SUBAGENTS_SAY,
subagent_usage: ClineSay.SUBAGENT_USAGE,
generate_explanation: ClineSay.GENERATE_EXPLANATION,
}
const result = mapping[say]
@@ -153,7 +152,6 @@ function convertProtoEnumToClineSay(say: ClineSay): AppClineSay | undefined {
[ClineSay.INFO]: "info",
[ClineSay.TASK_PROGRESS]: "task_progress",
[ClineSay.ERROR_RETRY]: "error_retry",
[ClineSay.GENERATE_EXPLANATION]: "generate_explanation",
[ClineSay.HOOK_STATUS]: "hook_status",
[ClineSay.HOOK_OUTPUT_STREAM]: "hook_output_stream",
[ClineSay.CONDITIONAL_RULES_APPLIED]: "conditional_rules_applied",
+1 -7
View File
@@ -39,13 +39,7 @@ export const BASE_SLASH_COMMANDS: SlashCommand[] = [
]
// VS Code-only slash commands
export const VSCODE_ONLY_COMMANDS: SlashCommand[] = [
{
name: "explain-changes",
description: "Explain code changes between git refs (PRs, commits, branches, etc.)",
section: "default",
},
]
export const VSCODE_ONLY_COMMANDS: SlashCommand[] = []
// CLI-only slash commands (handled locally, not sent to backend)
export const CLI_ONLY_COMMANDS: SlashCommand[] = [
-1
View File
@@ -30,7 +30,6 @@ export enum ClineDefaultTool {
REPORT_BUG = "report_bug",
NEW_RULE = "new_rule",
APPLY_PATCH = "apply_patch",
GENERATE_EXPLANATION = "generate_explanation",
USE_SKILL = "use_skill",
USE_SUBAGENTS = "use_subagents",
}
-118
View File
@@ -903,124 +903,6 @@ export const ErrorRetryFailed: Story = {
},
}
export const GenerateExplanationInProgress: Story = {
decorators: [
createStoryDecorator({
clineMessages: [
createMessage(5, "say", "task", "Explain my recent changes"),
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
createMessage(
4.5,
"say",
"generate_explanation",
JSON.stringify({
title: "Authentication refactor",
fromRef: "abc123def",
toRef: "working directory",
status: "generating",
}),
),
],
}),
],
parameters: {
docs: {
description: {
story: "Shows explanation generation in progress with spinner.",
},
},
},
}
export const GenerateExplanationComplete: Story = {
decorators: [
createStoryDecorator({
clineMessages: [
createMessage(5, "say", "task", "Explain my recent changes"),
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
createMessage(
4.5,
"say",
"generate_explanation",
JSON.stringify({
title: "Authentication refactor",
fromRef: "abc123def",
toRef: "xyz789ghi",
status: "complete",
}),
),
],
}),
],
parameters: {
docs: {
description: {
story: "Shows successfully generated explanation with git refs.",
},
},
},
}
export const GenerateExplanationError: Story = {
decorators: [
createStoryDecorator({
clineMessages: [
createMessage(5, "say", "task", "Explain my recent changes"),
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
createMessage(
4.5,
"say",
"generate_explanation",
JSON.stringify({
title: "Authentication refactor",
fromRef: "abc123def",
toRef: "",
status: "error",
error: "Failed to generate explanation: Git repository not found",
}),
),
],
}),
],
parameters: {
docs: {
description: {
story: "Shows explanation generation error with error message.",
},
},
},
}
export const GenerateExplanationCancelled: Story = {
decorators: [
createStoryDecorator({
clineMessages: [
createMessage(5, "say", "task", "Explain my recent changes"),
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
createMessage(
4.5,
"say",
"generate_explanation",
JSON.stringify({
title: "Authentication refactor",
fromRef: "abc123def",
toRef: "",
status: "generating",
}),
),
createMessage(4.3, "ask", undefined, "Task was cancelled", { ask: "resume_task" }),
],
}),
],
parameters: {
docs: {
description: {
story: "Shows explanation generation cancelled state (detected via resume_task message).",
},
},
},
}
// Diff Edit Stories - New Format
const createNewFormatMultiFileMessages = () => [
createMessage(5, "say", "task", "Help me refactor the authentication module"),
@@ -5,7 +5,6 @@ import {
ClineAskUseMcpServer,
ClineMessage,
ClinePlanModeResponse,
ClineSayGenerateExplanation,
ClineSayTool,
COMPLETION_RESULT_CHANGES_FLAG,
} from "@shared/ExtensionMessage"
@@ -13,12 +12,10 @@ import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
import { Mode } from "@shared/storage/types"
import deepEqual from "fast-deep-equal"
import {
ArrowRightIcon,
BellIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleSlashIcon,
CircleXIcon,
FileCode2Icon,
FilePlus2Icon,
@@ -158,7 +155,6 @@ export const ChatRowContent = memo(
showFeatureTips,
} = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
const [explainChangesDisabled, setExplainChangesDisabled] = useState(false)
const [quoteButtonState, setQuoteButtonState] = useState<QuoteButtonState>({
visible: false,
top: 0,
@@ -235,7 +231,6 @@ export const ChatRowContent = memo(
useEffect(() => {
return onRelinquishControl(() => {
setSeeNewChangesDisabled(false)
setExplainChangesDisabled(false)
})
}, [onRelinquishControl])
@@ -951,87 +946,17 @@ export const ChatRowContent = memo(
Loading MCP documentation
</div>
)
case "generate_explanation": {
let explanationInfo: ClineSayGenerateExplanation = {
title: "code changes",
fromRef: "",
toRef: "",
status: "generating",
}
try {
if (message.text) {
explanationInfo = JSON.parse(message.text)
}
} catch {
// Use defaults if parsing fails
}
// Check if generation was interrupted:
// 1. If status is "generating" but this isn't the last message, it was interrupted
// 2. If status is "generating" and lastModifiedMessage is a resume ask, task was just cancelled
const wasCancelled =
explanationInfo.status === "generating" &&
(!isLast ||
lastModifiedMessage?.ask === "resume_task" ||
lastModifiedMessage?.ask === "resume_completed_task")
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
const isError = explanationInfo.status === "error"
return (
<div className="bg-code flex flex-col border border-editor-group-border rounded-sm py-2.5 px-3">
<div className="flex items-center">
{isGenerating ? (
<ProgressIndicator />
) : isError ? (
<CircleXIcon className="size-2 mr-2 text-error" />
) : wasCancelled ? (
<CircleSlashIcon className="size-2 mr-2" />
) : (
<CheckIcon className="size-2 mr-2 text-success" />
)}
<span className="font-semibold">
{isGenerating
? "Generating explanation"
: isError
? "Failed to generate explanation"
: wasCancelled
? "Explanation cancelled"
: "Generated explanation"}
</span>
</div>
{isError && explanationInfo.error && (
<div className="opacity-80 ml-6 mt-1.5 text-error break-words">{explanationInfo.error}</div>
)}
{!isError && (explanationInfo.title || explanationInfo.fromRef) && (
<div className="opacity-80 ml-6 mt-1.5">
<div>{explanationInfo.title}</div>
{explanationInfo.fromRef && (
<div className="opacity-70 mt-1.5 break-all text-xs">
<code className="bg-quote rounded-sm py-0.5 pr-1.5">
{explanationInfo.fromRef}
</code>
<ArrowRightIcon className="inline size-2 mx-1" />
<code className="bg-quote rounded-sm py-0.5 px-1.5">
{explanationInfo.toRef || "working directory"}
</code>
</div>
)}
</div>
)}
</div>
)
}
case "completion_result":
const hasChanges = message.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
const text = hasChanges ? message.text?.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text
return (
<CompletionOutputRow
explainChangesDisabled={explainChangesDisabled}
handleQuoteClick={handleQuoteClick}
headClassNames={HEADER_CLASSNAMES}
messageTs={message.ts}
quoteButtonState={quoteButtonState}
seeNewChangesDisabled={seeNewChangesDisabled}
setExplainChangesDisabled={setExplainChangesDisabled}
setSeeNewChangesDisabled={setSeeNewChangesDisabled}
showActionRow={message.partial !== true && hasChanges}
text={text || ""}
@@ -1172,13 +1097,11 @@ export const ChatRowContent = memo(
const text = hasChanges ? message.text.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text
return (
<CompletionOutputRow
explainChangesDisabled={explainChangesDisabled}
handleQuoteClick={handleQuoteClick}
headClassNames={HEADER_CLASSNAMES}
messageTs={message.ts}
quoteButtonState={quoteButtonState}
seeNewChangesDisabled={seeNewChangesDisabled}
setExplainChangesDisabled={setExplainChangesDisabled}
setSeeNewChangesDisabled={setSeeNewChangesDisabled}
showActionRow={message.partial !== true && hasChanges}
text={text || ""}
@@ -3,7 +3,6 @@ import { cn } from "@/lib/utils"
import { MarkdownRow } from "./MarkdownRow"
import { Int64Request } from "@shared/proto/cline/common"
import { CheckIcon } from "lucide-react"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
import { TaskServiceClient } from "@/services/grpc-client"
import { CopyButton } from "../common/CopyButton"
import SuccessButton from "../common/SuccessButton"
@@ -18,8 +17,6 @@ interface CompletionOutputRowProps {
showActionRow?: boolean
seeNewChangesDisabled: boolean
setSeeNewChangesDisabled: (value: boolean) => void
explainChangesDisabled: boolean
setExplainChangesDisabled: (value: boolean) => void
messageTs: number
}
@@ -31,8 +28,6 @@ export const CompletionOutputRow = memo(
showActionRow,
seeNewChangesDisabled,
setSeeNewChangesDisabled,
explainChangesDisabled,
setExplainChangesDisabled,
messageTs,
handleQuoteClick,
}: CompletionOutputRowProps) => {
@@ -60,10 +55,8 @@ export const CompletionOutputRow = memo(
{/* Action Buttons */}
{showActionRow && (
<CompletionOutputActionRow
explainChangesDisabled={explainChangesDisabled}
messageTs={messageTs}
seeNewChangesDisabled={seeNewChangesDisabled}
setExplainChangesDisabled={setExplainChangesDisabled}
setSeeNewChangesDisabled={setSeeNewChangesDisabled}
/>
)}
@@ -78,14 +71,10 @@ const CompletionOutputActionRow = memo(
({
seeNewChangesDisabled,
setSeeNewChangesDisabled,
explainChangesDisabled,
setExplainChangesDisabled,
messageTs,
}: {
seeNewChangesDisabled: boolean
setSeeNewChangesDisabled: (value: boolean) => void
explainChangesDisabled: boolean
setExplainChangesDisabled: (value: boolean) => void
messageTs: number
}) => {
return (
@@ -107,28 +96,6 @@ const CompletionOutputActionRow = memo(
<i className="codicon codicon-new-file" style={{ marginRight: 6 }} />
View Changes
</SuccessButton>
{PLATFORM_CONFIG.type === PlatformType.VSCODE && (
<SuccessButton
disabled={explainChangesDisabled}
onClick={() => {
setExplainChangesDisabled(true)
TaskServiceClient.explainChanges({
metadata: {},
messageTs,
}).catch((err) => {
console.error("Failed to explain changes:", err)
setExplainChangesDisabled(false)
})
}}
style={{
cursor: explainChangesDisabled ? "wait" : "pointer",
width: "100%",
}}>
<i className="codicon codicon-comment-discussion" style={{ marginRight: 6 }} />
{explainChangesDisabled ? "Explaining..." : "Explain Changes"}
</SuccessButton>
)}
</div>
)
},
-11
View File
@@ -20,7 +20,6 @@ Type `/` in the chat input to see available slash commands:
| `/smol` | Compress conversation history while preserving essential context |
| `/newrule` | Create a rule file to teach Cline your preferences |
| `/deep-planning` | Investigate codebase, plan thoroughly, then create implementation task |
| `/explain-changes` | Generate AI explanations for any git diff (VS Code only) |
| `/reportbug` | Report a bug with diagnostic info |
### /newtask
@@ -52,16 +51,6 @@ Transform Cline into a meticulous architect who investigates your codebase, asks
Use `/deep-planning` for features touching multiple parts of your codebase, architectural changes, or complex integrations.
### /explain-changes
<Note>
This command is only available in VS Code.
</Note>
`/explain-changes` generates AI-powered explanations for any git diff. You can explain the last commit, uncommitted work, staged changes, specific commits, branches, PRs, or any range of changes.
Use `/explain-changes` when reviewing code, onboarding to a new codebase, or understanding what changed. For the full list of use cases and examples, see [Explain Changes Command](#explain-changes).
### /reportbug
`/reportbug` collects diagnostic information and helps you report issues with Cline. It gathers relevant context like your configuration, recent errors, and system details to make bug reports more useful for the development team.
-4
View File
@@ -650,10 +650,6 @@
"source": "/features/slash-commands/smol",
"destination": "/core-workflows/using-commands#smol"
},
{
"source": "/features/slash-commands/explain-changes",
"destination": "/core-workflows/using-commands#explain-changes"
},
{
"source": "/features/slash-commands/new-rule",
"destination": "/core-workflows/using-commands#newrule"