diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index d3db937c66..56a1acf828 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -81,6 +81,16 @@ export type TaskProviderEvents = { [RooCodeEventName.ProviderProfileChanged]: [config: { name: string; provider?: string }] } +/** + * Selection context captured from the editor + */ +export interface SelectionContext { + selectedText: string + selectionFilePath: string + selectionStartLine: number + selectionEndLine: number +} + /** * TaskLike */ @@ -92,6 +102,7 @@ export interface CreateTaskOptions { consecutiveMistakeLimit?: number experiments?: Record initialTodos?: TodoItem[] + selectionContext?: SelectionContext } export enum TaskStatus { diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 1110aa8831..77760ddf50 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -116,6 +116,7 @@ describe("getEnvironmentDetails", () => { deref: vi.fn().mockReturnValue(mockProvider), [Symbol.toStringTag]: "WeakRef", } as unknown as WeakRef, + getAndClearSelectionContext: vi.fn().mockReturnValue(undefined), } // Mock other dependencies. @@ -390,4 +391,60 @@ 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 selectionContext = { + selectedText: "const x = 1;\nconst y = 2;", + selectionFilePath: "src/test.ts", + selectionStartLine: 10, + selectionEndLine: 11, + } + + const clineWithSelection = { + ...mockCline, + getAndClearSelectionContext: vi.fn().mockReturnValueOnce(selectionContext), + } + + const result = await getEnvironmentDetails(clineWithSelection as unknown 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;") + expect(clineWithSelection.getAndClearSelectionContext).toHaveBeenCalledOnce() + }) + + it("should clear selection context after including it", async () => { + const selectionContext = { + selectedText: "test code", + selectionFilePath: "src/app.ts", + selectionStartLine: 5, + selectionEndLine: 5, + } + + const clineWithSelection = { + ...mockCline, + getAndClearSelectionContext: vi.fn().mockReturnValueOnce(selectionContext), + } + + await getEnvironmentDetails(clineWithSelection as unknown as Task) + + // Selection context should be cleared after use (method called once) + expect(clineWithSelection.getAndClearSelectionContext).toHaveBeenCalledOnce() + }) + + it("should not include selection section when no context is available", async () => { + const clineWithoutSelection = { + ...mockCline, + getAndClearSelectionContext: vi.fn().mockReturnValueOnce(undefined), + } + + const result = await getEnvironmentDetails(clineWithoutSelection as unknown as Task) + + expect(result).not.toContain("# Current Selection") + expect(clineWithoutSelection.getAndClearSelectionContext).toHaveBeenCalledOnce() + }) + }) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 30d9cd0b0d..c85075f78b 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -32,6 +32,15 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo maxWorkspaceFiles = 200, } = state ?? {} + // Include selection context if available (and automatically clear it) + const selectionContext = cline.getAndClearSelectionContext() + if (selectionContext) { + const { selectedText, selectionFilePath, selectionStartLine, selectionEndLine } = selectionContext + details += "\n\n# Current Selection" + details += `\nFile: ${selectionFilePath}:${selectionStartLine}-${selectionEndLine}` + details += `\n\`\`\`\n${selectedText}\n\`\`\`` + } + // 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..c77089ad5b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -24,6 +24,7 @@ import { type ToolProgressStatus, type HistoryItem, type CreateTaskOptions, + type SelectionContext, RooCodeEventName, TelemetryEventName, TaskStatus, @@ -142,6 +143,7 @@ export interface TaskOptions extends CreateTaskOptions { onCreated?: (task: Task) => void initialTodos?: TodoItem[] workspacePath?: string + selectionContext?: SelectionContext } export class Task extends EventEmitter implements TaskLike { @@ -155,6 +157,10 @@ export class Task extends EventEmitter implements TaskLike { todoList?: TodoItem[] + // Temporary storage for selection context during message processing + // This is cleared after being used in getEnvironmentDetails + private _currentSelectionContext?: SelectionContext + readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined readonly taskNumber: number @@ -319,6 +325,7 @@ export class Task extends EventEmitter implements TaskLike { onCreated, initialTodos, workspacePath, + selectionContext, }: TaskOptions) { super() @@ -440,7 +447,7 @@ export class Task extends EventEmitter implements TaskLike { if (startTask) { if (task || images) { - this.startTask(task, images) + this.startTask(task, images, selectionContext) } else if (historyItem) { this.resumeTaskFromHistory() } else { @@ -449,6 +456,16 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Get and clear the current selection context. + * This ensures selection context is only used once and doesn't persist. + */ + public getAndClearSelectionContext(): SelectionContext | undefined { + const context = this._currentSelectionContext + this._currentSelectionContext = undefined + return context + } + /** * Initialize the task mode from the provider state. * This method handles async initialization with proper error handling. @@ -964,11 +981,19 @@ export class Task extends EventEmitter implements TaskLike { return result } - handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + handleWebviewAskResponse( + askResponse: ClineAskResponse, + text?: string, + images?: string[], + selectionContext?: SelectionContext, + ) { this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images + // Store selection context temporarily for use in the next getEnvironmentDetails call + this._currentSelectionContext = selectionContext + // Create a checkpoint whenever the user sends a message. // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. @@ -1244,7 +1269,7 @@ export class Task extends EventEmitter implements TaskLike { // Lifecycle // Start / Resume / Abort / Dispose - private async startTask(task?: string, images?: string[]): Promise { + private async startTask(task?: string, images?: string[], selectionContext?: SelectionContext): Promise { if (this.enableBridge) { try { await BridgeOrchestrator.subscribeToTask(this) @@ -1264,6 +1289,9 @@ export class Task extends EventEmitter implements TaskLike { this.clineMessages = [] this.apiConversationHistory = [] + // Store selection context temporarily for use in the first getEnvironmentDetails call + this._currentSelectionContext = selectionContext + // The todo list is already set in the constructor if initialTodos were provided // No need to add any messages - the todoList property is already set diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c85dea9d16..4e5ee0cf91 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -11,6 +11,7 @@ import { type ClineMessage, type TelemetrySetting, type UserSettingsConfig, + type SelectionContext, TelemetryEventName, RooCodeSettings, Experiments, @@ -427,6 +428,38 @@ export const webviewMessageHandler = async ( } } + // Helper function to capture current editor selection + const captureSelectionContext = (): SelectionContext | undefined => { + 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 + + return { + selectedText, + selectionFilePath: relativeFilePath, + selectionStartLine: startLine, + selectionEndLine: endLine, + } + } + return undefined + } + switch (message.type) { case "webviewDidLaunch": // Load custom modes first @@ -507,7 +540,13 @@ export const webviewMessageHandler = async ( // agentically running promises in old instance don't affect our new // task. This essentially creates a fresh slate for the new task. try { - await provider.createTask(message.text, message.images) + // Capture selection context directly when creating task + const selectionContext = captureSelectionContext() + + await provider.createTask(message.text, message.images, undefined, { + selectionContext, + }) + // Task created successfully - notify the UI to reset await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" }) } catch (error) { @@ -523,9 +562,14 @@ export const webviewMessageHandler = async ( await provider.updateCustomInstructions(message.text) break - case "askResponse": - provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images) + case "askResponse": { + const task = provider.getCurrentTask() + // Capture selection context directly when handling response + const selectionContext = captureSelectionContext() + + task?.handleWebviewAskResponse(message.askResponse!, message.text, message.images, selectionContext) break + } case "updateSettings": if (message.updatedSettings) { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 9adf603ee4..9402746ee8 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -134,7 +134,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) - // 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 // handleMessage is called, the last message might not be the ask anymore @@ -809,7 +808,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction textAreaRef.current?.focus()) + useMount(() => { + textAreaRef.current?.focus() + }) const visibleMessages = useMemo(() => { // Pre-compute checkpoint hashes that have associated user messages for O(1) lookup