From bb8d44d26f75ed6f9fe662c9435f7cfd3a69417c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 13 Nov 2025 02:25:33 +0000 Subject: [PATCH] feat: add automatic selection context to user messages - Add selection context fields to WebviewMessage and ExtensionMessage types - Implement selection capture in ChatView on component mount - Add backend message handler to capture editor selection with line numbers - Store selection context in Task instance for use in environment details - Include selection context at start of environment details sent to LLM - Automatically clear selection context after use to prevent persistence - Add comprehensive tests for selection context functionality - Convert VSCode 0-based line numbers to 1-based for user-friendly display - Handle both workspace-relative and absolute file paths This allows users to select text in the editor and ask questions like "fix this" or "what does this do" without explicitly pasting code. The selection is automatically included as hidden context for the LLM. --- .../__tests__/getEnvironmentDetails.spec.ts | 45 ++++++++++ src/core/environment/getEnvironmentDetails.ts | 11 +++ src/core/task/Task.ts | 7 ++ src/core/webview/webviewMessageHandler.ts | 82 ++++++++++++++++++- src/shared/ExtensionMessage.ts | 5 ++ src/shared/WebviewMessage.ts | 5 ++ webview-ui/src/components/chat/ChatView.tsx | 39 ++++++++- 7 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 1110aa8831..e1f0090e59 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -390,4 +390,49 @@ describe("getEnvironmentDetails", () => { const result = await getEnvironmentDetails(cline as Task) expect(result).toContain("REMINDERS") }) + + describe("Selection Context", () => { + it("should include selection context when available", async () => { + const clineWithSelection = { + ...mockCline, + selectionContext: { + selectedText: "const x = 1;\nconst y = 2;", + selectionFilePath: "src/test.ts", + selectionStartLine: 10, + selectionEndLine: 11, + }, + } + + const result = await getEnvironmentDetails(clineWithSelection as Task) + + expect(result).toContain("# Current Selection") + expect(result).toContain("File: src/test.ts:10-11") + expect(result).toContain("```") + expect(result).toContain("const x = 1;") + expect(result).toContain("const y = 2;") + }) + + it("should clear selection context after including it", async () => { + const clineWithSelection = { + ...mockCline, + selectionContext: { + selectedText: "test code", + selectionFilePath: "src/app.ts", + selectionStartLine: 5, + selectionEndLine: 5, + }, + } + + await getEnvironmentDetails(clineWithSelection as Task) + + // Selection context should be cleared after use + expect(clineWithSelection.selectionContext).toBeUndefined() + }) + + it("should not include selection section when no context is available", async () => { + const result = await getEnvironmentDetails(mockCline as Task) + + expect(result).not.toContain("# Current Selection") + }) + }) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 30d9cd0b0d..c01ec9bb83 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -32,6 +32,17 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo maxWorkspaceFiles = 200, } = state ?? {} + // Include selection context if available + if (cline.selectionContext) { + const { selectedText, selectionFilePath, selectionStartLine, selectionEndLine } = cline.selectionContext + details += "\n\n# Current Selection" + details += `\nFile: ${selectionFilePath}:${selectionStartLine}-${selectionEndLine}` + details += `\n\`\`\`\n${selectedText}\n\`\`\`` + + // Clear the selection context after including it once + cline.selectionContext = undefined + } + // It could be useful for cline to know if the user went from one or no // file to another between messages, so we always include this context. details += "\n\n# VSCode Visible Files" diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4f2bdd72da..c28852c20b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -155,6 +155,13 @@ export class Task extends EventEmitter implements TaskLike { todoList?: TodoItem[] + selectionContext?: { + selectedText: string + selectionFilePath: string + selectionStartLine: number + selectionEndLine: number + } + readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined readonly taskNumber: number diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c85dea9d16..b890513b05 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -428,6 +428,61 @@ export const webviewMessageHandler = async ( } switch (message.type) { + case "requestSelectionContext": { + // Get the active editor and its selection + const editor = vscode.window.activeTextEditor + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection + const selectedText = editor.document.getText(selection) + const filePath = editor.document.uri.fsPath + + // Convert to workspace-relative path if possible + const workspacePath = provider.cwd + let relativeFilePath: string + if (filePath.startsWith(workspacePath)) { + relativeFilePath = path.relative(workspacePath, filePath) + } else { + // File is outside workspace, use absolute path + relativeFilePath = filePath + } + + // VSCode uses 0-based line numbers, convert to 1-based for user-friendly display + const startLine = selection.start.line + 1 + const endLine = selection.end.line + 1 + + // Send selection context to webview + await provider.postMessageToWebview({ + type: "selectionContext", + selectedText, + selectionFilePath: relativeFilePath, + selectionStartLine: startLine, + selectionEndLine: endLine, + }) + + // Store selection context in current task for use in environment details + const currentTask = provider.getCurrentTask() + if (currentTask) { + currentTask.selectionContext = { + selectedText, + selectionFilePath: relativeFilePath, + selectionStartLine: startLine, + selectionEndLine: endLine, + } + } + } else { + // No selection, send empty context + await provider.postMessageToWebview({ + type: "selectionContext", + }) + + // Clear selection context in current task + const currentTask = provider.getCurrentTask() + if (currentTask) { + currentTask.selectionContext = undefined + } + } + break + } case "webviewDidLaunch": // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() @@ -508,6 +563,18 @@ export const webviewMessageHandler = async ( // task. This essentially creates a fresh slate for the new task. try { await provider.createTask(message.text, message.images) + + // Store selection context in the newly created task + const newTask = provider.getCurrentTask() + if (newTask && message.selectedText) { + newTask.selectionContext = { + selectedText: message.selectedText, + selectionFilePath: message.selectionFilePath, + selectionStartLine: message.selectionStartLine, + selectionEndLine: message.selectionEndLine, + } + } + // Task created successfully - notify the UI to reset await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" }) } catch (error) { @@ -523,9 +590,20 @@ export const webviewMessageHandler = async ( await provider.updateCustomInstructions(message.text) break - case "askResponse": - provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images) + case "askResponse": { + // Store selection context in current task before handling response + const task = provider.getCurrentTask() + if (task && message.selectedText) { + task.selectionContext = { + selectedText: message.selectedText, + selectionFilePath: message.selectionFilePath, + selectionStartLine: message.selectionStartLine, + selectionEndLine: message.selectionEndLine, + } + } + task?.handleWebviewAskResponse(message.askResponse!, message.text, message.images) break + } case "updateSettings": if (message.updatedSettings) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 80c5532930..c8969842c0 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -128,7 +128,12 @@ export interface ExtensionMessage { | "dismissedUpsells" | "organizationSwitchResult" | "interactionRequired" + | "selectionContext" text?: string + selectedText?: string + selectionFilePath?: string + selectionStartLine?: number + selectionEndLine?: number payload?: any // Add a generic payload for now, can refine later // Checkpoint warning message checkpointWarning?: { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 02f0876ad3..64eeeb37a8 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -165,7 +165,12 @@ export interface WebviewMessage { | "dismissUpsell" | "getDismissedUpsells" | "updateSettings" + | "requestSelectionContext" text?: string + selectedText?: string + selectionFilePath?: string + selectionStartLine?: number + selectionEndLine?: number editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 9adf603ee4..c9f6b3ffde 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -134,6 +134,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) + const [selectionContext, setSelectionContext] = useState<{ + selectedText?: string + selectionFilePath?: string + selectionStartLine?: number + selectionEndLine?: number + } | null>(null) // We need to hold on to the ask because useEffect > lastMessage will always // let us know when an ask comes in and handle it, but by the time @@ -563,7 +569,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction textAreaRef.current?.focus()) + useMount(() => { + textAreaRef.current?.focus() + // Request initial selection context when component mounts + vscode.postMessage({ type: "requestSelectionContext" }) + }) const visibleMessages = useMemo(() => { // Pre-compute checkpoint hashes that have associated user messages for O(1) lookup