mirror of
https://github.com/cline/cline.git
synced 2026-08-31 01:40:46 +08:00
Remove Explain Changes feature (#11278)
* chore(vscode): remove explain changes entry points * chore(vscode): remove explain changes feature
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user