Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -92,6 +102,7 @@ export interface CreateTaskOptions {
consecutiveMistakeLimit?: number
experiments?: Record<string, boolean>
initialTodos?: TodoItem[]
selectionContext?: SelectionContext
}

export enum TaskStatus {
Expand Down
57 changes: 57 additions & 0 deletions src/core/environment/__tests__/getEnvironmentDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe("getEnvironmentDetails", () => {
deref: vi.fn().mockReturnValue(mockProvider),
[Symbol.toStringTag]: "WeakRef",
} as unknown as WeakRef<ClineProvider>,
getAndClearSelectionContext: vi.fn().mockReturnValue(undefined),
}

// Mock other dependencies.
Expand Down Expand Up @@ -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()
})
})
})
9 changes: 9 additions & 0 deletions src/core/environment/getEnvironmentDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 31 additions & 3 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type ToolProgressStatus,
type HistoryItem,
type CreateTaskOptions,
type SelectionContext,
RooCodeEventName,
TelemetryEventName,
TaskStatus,
Expand Down Expand Up @@ -142,6 +143,7 @@ export interface TaskOptions extends CreateTaskOptions {
onCreated?: (task: Task) => void
initialTodos?: TodoItem[]
workspacePath?: string
selectionContext?: SelectionContext
}

export class Task extends EventEmitter<TaskEvents> implements TaskLike {
Expand All @@ -155,6 +157,10 @@ export class Task extends EventEmitter<TaskEvents> 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
Expand Down Expand Up @@ -319,6 +325,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
onCreated,
initialTodos,
workspacePath,
selectionContext,
}: TaskOptions) {
super()

Expand Down Expand Up @@ -440,7 +447,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

if (startTask) {
if (task || images) {
this.startTask(task, images)
this.startTask(task, images, selectionContext)
} else if (historyItem) {
this.resumeTaskFromHistory()
} else {
Expand All @@ -449,6 +456,16 @@ export class Task extends EventEmitter<TaskEvents> 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.
Expand Down Expand Up @@ -964,11 +981,19 @@ export class Task extends EventEmitter<TaskEvents> 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.
Expand Down Expand Up @@ -1244,7 +1269,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Lifecycle
// Start / Resume / Abort / Dispose

private async startTask(task?: string, images?: string[]): Promise<void> {
private async startTask(task?: string, images?: string[], selectionContext?: SelectionContext): Promise<void> {
if (this.enableBridge) {
try {
await BridgeOrchestrator.subscribeToTask(this)
Expand All @@ -1264,6 +1289,9 @@ export class Task extends EventEmitter<TaskEvents> 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

Expand Down
50 changes: 47 additions & 3 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ClineMessage,
type TelemetrySetting,
type UserSettingsConfig,
type SelectionContext,
TelemetryEventName,
RooCodeSettings,
Experiments,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])

// 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
Expand Down Expand Up @@ -809,7 +808,9 @@ 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()
})

const visibleMessages = useMemo(() => {
// Pre-compute checkpoint hashes that have associated user messages for O(1) lookup
Expand Down