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.
This commit is contained in:
Roo Code 2025-11-13 02:25:33 +00:00
parent 0fdbd392e8
commit bb8d44d26f
7 changed files with 188 additions and 6 deletions

View file

@ -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")
})
})
})

View file

@ -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"

View file

@ -155,6 +155,13 @@ export class Task extends EventEmitter<TaskEvents> 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

View file

@ -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) {

View file

@ -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?: {

View file

@ -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

View file

@ -134,6 +134,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
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<ChatViewRef, ChatViewPro
userRespondedRef.current = true
if (messagesRef.current.length === 0) {
vscode.postMessage({ type: "newTask", text, images })
vscode.postMessage({
type: "newTask",
text,
images,
...(selectionContext || {}),
})
} else if (clineAskRef.current) {
if (clineAskRef.current === "followup") {
markFollowUpAsAnswered()
@ -588,19 +599,26 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
askResponse: "messageResponse",
text,
images,
...(selectionContext || {}),
})
break
// There is no other case that a textfield should be enabled.
}
} else {
// This is a new message in an ongoing task.
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
vscode.postMessage({
type: "askResponse",
askResponse: "messageResponse",
text,
images,
...(selectionContext || {}),
})
}
handleChatReset()
}
},
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length], // messagesRef and clineAskRef are stable
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length, selectionContext], // messagesRef and clineAskRef are stable
)
const handleSetChatBoxMessage = useCallback(
@ -743,6 +761,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
break
}
break
case "selectionContext":
// Update selection context when received from extension
setSelectionContext({
selectedText: message.selectedText,
selectionFilePath: message.selectionFilePath,
selectionStartLine: message.selectionStartLine,
selectionEndLine: message.selectionEndLine,
})
break
case "selectedImages":
// Only handle selectedImages if it's not for editing context
// When context is "edit", ChatRow will handle the images
@ -809,7 +836,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useEvent("message", handleMessage)
// NOTE: the VSCode window needs to be focused for this to work.
useMount(() => 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