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
This commit is contained in:
Roo Code 2025-09-17 17:41:15 +00:00
parent 7b1e3a0ee5
commit 8523e43ca8
10 changed files with 161 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -225,6 +225,7 @@ export interface WebviewMessage {
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "requestCurrentEditorSelection"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

View file

@ -87,6 +87,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
}, [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<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
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 ||

View file

@ -144,6 +144,25 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return <span>{t("chat:contextMenu.terminal")}</span>
case ContextMenuOptionType.URL:
return <span>{t("chat:contextMenu.url")}</span>
case ContextMenuOptionType.CurrentEditorSelection:
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
<span style={{ lineHeight: "1.2" }}>{option.label}</span>
{option.description && (
<span
style={{
opacity: 0.5,
fontSize: "0.9em",
lineHeight: "1.2",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{option.description}
</span>
)}
</div>
)
case ContextMenuOptionType.NoResults:
return <span>{t("chat:contextMenu.noResults")}</span>
case ContextMenuOptionType.Git:
@ -230,6 +249,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return "link"
case ContextMenuOptionType.Git:
return "git-commit"
case ContextMenuOptionType.CurrentEditorSelection:
return "selection"
case ContextMenuOptionType.NoResults:
return "info"
default:

View file

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

View file

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