From 8523e43ca8b080278b6888e10c63ad6b506adc3d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 17 Sep 2025 17:41:15 +0000 Subject: [PATCH] feat: add Current Editor Selection context option to @ mentions - Add CurrentEditorSelection to ContextMenuOptionType enum - Implement handler in webviewMessageHandler to retrieve selected text - Add hasEditorSelection state tracking in ClineProvider - Monitor editor selection changes and notify webview - Update ChatTextArea to show option when text is selected - Add proper UI rendering in ContextMenu component - Add translation keys for the new feature - Fix test mocks for onDidChangeTextEditorSelection - Fix ESLint warning by adding missing dependency to useMemo This feature allows users to easily include selected text from their editor as context in their prompts using the @ mention system, similar to other AI coding assistants. Fixes #8078 --- src/core/webview/ClineProvider.ts | 28 ++++++++ .../webview/__tests__/ClineProvider.spec.ts | 2 + .../ClineProvider.sticky-mode.spec.ts | 2 + src/core/webview/webviewMessageHandler.ts | 23 +++++++ src/shared/ExtensionMessage.ts | 12 ++++ src/shared/WebviewMessage.ts | 1 + .../src/components/chat/ChatTextArea.tsx | 67 +++++++++++++++++-- .../src/components/chat/ContextMenu.tsx | 21 ++++++ webview-ui/src/i18n/locales/en/chat.json | 4 +- webview-ui/src/utils/context-mentions.ts | 7 +- 10 files changed, 161 insertions(+), 6 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9abddc6d96..3dedcc8c28 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -757,9 +757,17 @@ export class ClineProvider const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => { // Update subscription when workspace might have changed. this.updateCodeIndexStatusSubscription() + // Update editor selection state + this.updateEditorSelectionState() }) this.webviewDisposables.push(activeEditorSubscription) + // Listen for selection changes in the editor + const selectionChangeSubscription = vscode.window.onDidChangeTextEditorSelection(() => { + this.updateEditorSelectionState() + }) + this.webviewDisposables.push(selectionChangeSubscription) + // Listen for when the panel becomes visible. // https://github.com/microsoft/vscode-discussions/discussions/840 if ("onDidChangeViewState" in webviewView) { @@ -1920,9 +1928,29 @@ export class ClineProvider openRouterImageGenerationSelectedModel, openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, + hasEditorSelection: this.hasEditorSelection(), } } + /** + * Check if there's currently a text selection in the active editor + */ + private hasEditorSelection(): boolean { + const activeEditor = vscode.window.activeTextEditor + return !!(activeEditor && !activeEditor.selection.isEmpty) + } + + /** + * Update the webview with the current editor selection state + */ + private updateEditorSelectionState(): void { + const hasSelection = this.hasEditorSelection() + this.postMessageToWebview({ + type: "editorSelectionChanged", + hasSelection, + }) + } + /** * Storage * https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bd4608c6eb..41a407e43b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -152,6 +152,8 @@ vi.mock("vscode", () => ({ showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), + activeTextEditor: undefined, }, workspace: { getConfiguration: vi.fn().mockReturnValue({ diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 29aefcaeba..4a42673e3a 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -27,6 +27,8 @@ vi.mock("vscode", () => ({ showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), + activeTextEditor: undefined, }, workspace: { getConfiguration: vi.fn().mockReturnValue({ diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index abdfae29fa..b3fb8e6a60 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3044,5 +3044,28 @@ export const webviewMessageHandler = async ( }) break } + case "requestCurrentEditorSelection": { + // Get the current editor selection + const activeEditor = vscode.window.activeTextEditor + if (activeEditor && !activeEditor.selection.isEmpty) { + const selectedText = activeEditor.document.getText(activeEditor.selection) + const fileName = activeEditor.document.fileName + + // Insert the selected text as a mention in the chat + await provider.postMessageToWebview({ + type: "currentEditorSelection", + text: selectedText, + values: { fileName }, + }) + } else { + // No selection available + await provider.postMessageToWebview({ + type: "currentEditorSelection", + text: undefined, + values: { hasSelection: false }, + }) + } + break + } } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb..b937ecee3b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -124,6 +124,8 @@ export interface ExtensionMessage { | "commands" | "insertTextIntoTextarea" | "dismissedUpsells" + | "currentEditorSelection" + | "editorSelectionChanged" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -201,6 +203,15 @@ export interface ExtensionMessage { commands?: Command[] queuedMessages?: QueuedMessage[] list?: string[] // For dismissedUpsells + hasSelection?: boolean // For editorSelectionChanged + selection?: { + text: string + fileName: string + range: { + start: { line: number; character: number } + end: { line: number; character: number } + } + } | null // For currentEditorSelection } export type ExtensionState = Pick< @@ -347,6 +358,7 @@ export type ExtensionState = Pick< remoteControlEnabled: boolean taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean + hasEditorSelection?: boolean } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc45..4d841a040f 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -225,6 +225,7 @@ export interface WebviewMessage { | "editQueuedMessage" | "dismissUpsell" | "getDismissedUpsells" + | "requestCurrentEditorSelection" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index fb105056b5..409a5c9500 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -87,6 +87,7 @@ export const ChatTextArea = forwardRef( taskHistory, clineMessages, commands, + hasEditorSelection, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -121,7 +122,43 @@ export const ChatTextArea = forwardRef( const messageHandler = (event: MessageEvent) => { const message = event.data - if (message.type === "enhancedPrompt") { + if (message.type === "editorSelectionChanged") { + // Update will happen through ExtensionStateContext + // No need to handle here as hasEditorSelection comes from state + } else if (message.type === "currentEditorSelection") { + // Handle the editor selection response + if (message.selection && textAreaRef.current) { + const selection = message.selection + const mentionText = `@${selection.fileName}:${selection.range.start.line + 1}-${selection.range.end.line + 1}` + + // Insert the mention at the current cursor position + const currentValue = inputValue + const cursorPos = textAreaRef.current.selectionStart || 0 + + // Check if we need to add a space before the mention + const textBefore = currentValue.slice(0, cursorPos) + const needsSpaceBefore = textBefore.length > 0 && !textBefore.endsWith(" ") + const prefix = needsSpaceBefore ? " " : "" + + // Insert the mention at cursor position + const newValue = + currentValue.slice(0, cursorPos) + + prefix + + mentionText + + " " + + currentValue.slice(cursorPos) + setInputValue(newValue) + + // Set cursor position after the inserted mention + const newCursorPos = cursorPos + prefix.length + mentionText.length + 1 + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.focus() + textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos) + } + }, 0) + } + } else if (message.type === "enhancedPrompt") { if (message.text && textAreaRef.current) { try { // Use execCommand to replace text while preserving undo history @@ -251,7 +288,19 @@ export const ChatTextArea = forwardRef( }, [inputValue]) const queryItems = useMemo(() => { - return [ + const items = [] + + // Add current editor selection if available + if (hasEditorSelection) { + items.push({ + type: ContextMenuOptionType.CurrentEditorSelection, + value: "current-selection", + label: t("chat:contextMenu.currentEditorSelection"), + description: t("chat:contextMenu.currentEditorSelectionDescription"), + }) + } + + items.push( { type: ContextMenuOptionType.Problems, value: "problems" }, { type: ContextMenuOptionType.Terminal, value: "terminal" }, ...gitCommits, @@ -268,8 +317,10 @@ export const ChatTextArea = forwardRef( type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File, value: path, })), - ] - }, [filePaths, gitCommits, openedTabs]) + ) + + return items + }, [filePaths, gitCommits, openedTabs, hasEditorSelection, t]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -326,6 +377,14 @@ export const ChatTextArea = forwardRef( return } + if (type === ContextMenuOptionType.CurrentEditorSelection) { + // Request current editor selection from the extension + setShowContextMenu(false) + setSelectedType(null) + vscode.postMessage({ type: "requestCurrentEditorSelection" }) + return + } + if ( type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder || diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index cf4b10a981..12eca93c6f 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -144,6 +144,25 @@ const ContextMenu: React.FC = ({ return {t("chat:contextMenu.terminal")} case ContextMenuOptionType.URL: return {t("chat:contextMenu.url")} + case ContextMenuOptionType.CurrentEditorSelection: + return ( +
+ {option.label} + {option.description && ( + + {option.description} + + )} +
+ ) case ContextMenuOptionType.NoResults: return {t("chat:contextMenu.noResults")} case ContextMenuOptionType.Git: @@ -230,6 +249,8 @@ const ContextMenu: React.FC = ({ return "link" case ContextMenuOptionType.Git: return "git-commit" + case ContextMenuOptionType.CurrentEditorSelection: + return "selection" case ContextMenuOptionType.NoResults: return "info" default: diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index d6fb807888..4b4024e8ce 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -399,6 +399,8 @@ "noResults": "No results", "problems": "Problems", "terminal": "Terminal", - "url": "Paste URL to fetch contents" + "url": "Paste URL to fetch contents", + "currentEditorSelection": "Current Editor Selection", + "currentEditorSelectionDescription": "Include the currently selected text from the editor" } } diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index d7aeb0fdd5..3ecd910b63 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -109,6 +109,7 @@ export enum ContextMenuOptionType { Mode = "mode", // Add mode type Command = "command", // Add command type SectionHeader = "sectionHeader", // Add section header type + CurrentEditorSelection = "currentEditorSelection", // Add current editor selection type } export interface ContextMenuQueryItem { @@ -249,7 +250,7 @@ export function getContextMenuOptions( return commits.length > 0 ? [workingChanges, ...commits] : [workingChanges] } - return [ + const defaultOptions = [ { type: ContextMenuOptionType.Problems }, { type: ContextMenuOptionType.Terminal }, { type: ContextMenuOptionType.URL }, @@ -257,6 +258,10 @@ export function getContextMenuOptions( { type: ContextMenuOptionType.File }, { type: ContextMenuOptionType.Git }, ] + + // Add CurrentEditorSelection at the top if there's an active selection + // This will be determined by the extension host checking if there's selected text + return defaultOptions } const lowerQuery = query.toLowerCase()