Merge branch 'RooVetGit:main' into fix-context-calculation

This commit is contained in:
Murilo Pires
2025-02-01 18:00:41 -03:00
committed by GitHub
99 changed files with 9429 additions and 10399 deletions

View File

@@ -5,9 +5,25 @@ const vscode = {
createTextEditorDecorationType: jest.fn().mockReturnValue({
dispose: jest.fn(),
}),
tabGroups: {
onDidChangeTabs: jest.fn(() => {
return {
dispose: jest.fn(),
}
}),
all: [],
},
},
workspace: {
onDidSaveTextDocument: jest.fn(),
createFileSystemWatcher: jest.fn().mockReturnValue({
onDidCreate: jest.fn().mockReturnValue({ dispose: jest.fn() }),
onDidDelete: jest.fn().mockReturnValue({ dispose: jest.fn() }),
dispose: jest.fn(),
}),
fs: {
stat: jest.fn(),
},
},
Disposable: class {
dispose() {}
@@ -52,6 +68,22 @@ const vscode = {
this.id = id
}
},
ExtensionMode: {
Production: 1,
Development: 2,
Test: 3,
},
FileType: {
Unknown: 0,
File: 1,
Directory: 2,
SymbolicLink: 64,
},
TabInputText: class {
constructor(uri) {
this.uri = uri
}
},
}
module.exports = vscode

32
src/activate/handleUri.ts Normal file
View File

@@ -0,0 +1,32 @@
import * as vscode from "vscode"
import { ClineProvider } from "../core/webview/ClineProvider"
export const handleUri = async (uri: vscode.Uri) => {
const path = uri.path
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
const visibleProvider = ClineProvider.getVisibleInstance()
if (!visibleProvider) {
return
}
switch (path) {
case "/glama": {
const code = query.get("code")
if (code) {
await visibleProvider.handleGlamaCallback(code)
}
break
}
case "/openrouter": {
const code = query.get("code")
if (code) {
await visibleProvider.handleOpenRouterCallback(code)
}
break
}
default:
break
}
}

3
src/activate/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { handleUri } from "./handleUri"
export { registerCommands } from "./registerCommands"
export { registerCodeActions } from "./registerCodeActions"

View File

@@ -0,0 +1,91 @@
import * as vscode from "vscode"
import { ACTION_NAMES, COMMAND_IDS } from "../core/CodeActionProvider"
import { EditorUtils } from "../core/EditorUtils"
import { ClineProvider } from "../core/webview/ClineProvider"
export const registerCodeActions = (context: vscode.ExtensionContext) => {
registerCodeActionPair(
context,
COMMAND_IDS.EXPLAIN,
"EXPLAIN",
"What would you like Roo to explain?",
"E.g. How does the error handling work?",
)
registerCodeActionPair(
context,
COMMAND_IDS.FIX,
"FIX",
"What would you like Roo to fix?",
"E.g. Maintain backward compatibility",
)
registerCodeActionPair(
context,
COMMAND_IDS.IMPROVE,
"IMPROVE",
"What would you like Roo to improve?",
"E.g. Focus on performance optimization",
)
registerCodeAction(context, COMMAND_IDS.ADD_TO_CONTEXT, "ADD_TO_CONTEXT")
}
const registerCodeAction = (
context: vscode.ExtensionContext,
command: string,
promptType: keyof typeof ACTION_NAMES,
inputPrompt?: string,
inputPlaceholder?: string,
) => {
let userInput: string | undefined
context.subscriptions.push(
vscode.commands.registerCommand(command, async (...args: any[]) => {
if (inputPrompt) {
userInput = await vscode.window.showInputBox({
prompt: inputPrompt,
placeHolder: inputPlaceholder,
})
}
// Handle both code action and direct command cases.
let filePath: string
let selectedText: string
let diagnostics: any[] | undefined
if (args.length > 1) {
// Called from code action.
;[filePath, selectedText, diagnostics] = args
} else {
// Called directly from command palette.
const context = EditorUtils.getEditorContext()
if (!context) return
;({ filePath, selectedText, diagnostics } = context)
}
const params = {
...{ filePath, selectedText },
...(diagnostics ? { diagnostics } : {}),
...(userInput ? { userInput } : {}),
}
await ClineProvider.handleCodeAction(command, promptType, params)
}),
)
}
const registerCodeActionPair = (
context: vscode.ExtensionContext,
baseCommand: string,
promptType: keyof typeof ACTION_NAMES,
inputPrompt?: string,
inputPlaceholder?: string,
) => {
// Register new task version.
registerCodeAction(context, baseCommand, promptType, inputPrompt, inputPlaceholder)
// Register current task version.
registerCodeAction(context, `${baseCommand}InCurrentTask`, promptType, inputPrompt, inputPlaceholder)
}

View File

@@ -0,0 +1,83 @@
import * as vscode from "vscode"
import delay from "delay"
import { ClineProvider } from "../core/webview/ClineProvider"
export type RegisterCommandOptions = {
context: vscode.ExtensionContext
outputChannel: vscode.OutputChannel
provider: ClineProvider
}
export const registerCommands = (options: RegisterCommandOptions) => {
const { context, outputChannel } = options
for (const [command, callback] of Object.entries(getCommandsMap(options))) {
context.subscriptions.push(vscode.commands.registerCommand(command, callback))
}
}
const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => {
return {
"roo-cline.plusButtonClicked": async () => {
await provider.clearTask()
await provider.postStateToWebview()
await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
},
"roo-cline.mcpButtonClicked": () => {
provider.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
},
"roo-cline.promptsButtonClicked": () => {
provider.postMessageToWebview({ type: "action", action: "promptsButtonClicked" })
},
"roo-cline.popoutButtonClicked": () => openClineInNewTab({ context, outputChannel }),
"roo-cline.openInNewTab": () => openClineInNewTab({ context, outputChannel }),
"roo-cline.settingsButtonClicked": () => {
provider.postMessageToWebview({ type: "action", action: "settingsButtonClicked" })
},
"roo-cline.historyButtonClicked": () => {
provider.postMessageToWebview({ type: "action", action: "historyButtonClicked" })
},
}
}
const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
outputChannel.appendLine("Opening Roo Code in new tab")
// (This example uses webviewProvider activation event which is necessary to
// deserialize cached webview, but since we use retainContextWhenHidden, we
// don't need to use that event).
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
const tabProvider = new ClineProvider(context, outputChannel)
// const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
// Check if there are any visible text editors, otherwise open a new group
// to the right.
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
if (!hasVisibleEditors) {
await vscode.commands.executeCommand("workbench.action.newGroupRight")
}
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
})
// TODO: use better svg icon with light and dark variants (see
// https://stackoverflow.com/questions/58365687/vscode-extension-iconpath).
panel.iconPath = {
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
}
tabProvider.resolveWebviewView(panel)
// Lock the editor group so clicking on files doesn't open them over the panel
await delay(100)
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
}

View File

@@ -289,6 +289,21 @@ describe("OpenAiNativeHandler", () => {
})
})
it("should complete prompt successfully with o3-mini model", async () => {
handler = new OpenAiNativeHandler({
apiModelId: "o3-mini",
openAiNativeApiKey: "test-api-key",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith({
model: "o3-mini",
messages: [{ role: "user", content: "Test prompt" }],
reasoning_effort: "medium",
})
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(

View File

@@ -72,28 +72,30 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
maxTokens = 8_192
}
const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = {
model: this.getModel().id,
max_tokens: maxTokens,
messages: openAiMessages,
stream: true,
}
if (this.supportsTemperature()) {
requestOptions.temperature = 0
}
const { data: completion, response } = await this.client.chat.completions
.create(
{
model: this.getModel().id,
max_tokens: maxTokens,
temperature: 0,
messages: openAiMessages,
stream: true,
.create(requestOptions, {
headers: {
"X-Glama-Metadata": JSON.stringify({
labels: [
{
key: "app",
value: "vscode.rooveterinaryinc.roo-cline",
},
],
}),
},
{
headers: {
"X-Glama-Metadata": JSON.stringify({
labels: [
{
key: "app",
value: "vscode.rooveterinaryinc.roo-cline",
},
],
}),
},
},
)
})
.withResponse()
const completionRequestId = response.headers.get("x-completion-request-id")
@@ -148,6 +150,10 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
}
}
private supportsTemperature(): boolean {
return !this.getModel().id.startsWith("openai/o3-mini")
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.glamaModelId
const modelInfo = this.options.glamaModelInfo
@@ -164,7 +170,10 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: this.getModel().id,
messages: [{ role: "user", content: prompt }],
temperature: 0,
}
if (this.supportsTemperature()) {
requestOptions.temperature = 0
}
if (this.getModel().id.startsWith("anthropic/")) {

View File

@@ -48,6 +48,37 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
}
break
}
case "o3-mini":
case "o3-mini-low":
case "o3-mini-high": {
const stream = await this.client.chat.completions.create({
model: "o3-mini",
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: this.getModel().info.reasoningEffort,
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// contains a null value except for the last chunk which contains the token usage statistics for the entire request
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
break
}
default: {
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
@@ -104,6 +135,16 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
messages: [{ role: "user", content: prompt }],
}
break
case "o3-mini":
case "o3-mini-low":
case "o3-mini-high":
// o3 doesn't support non-1 temp
requestOptions = {
model: "o3-mini",
messages: [{ role: "user", content: prompt }],
reasoning_effort: this.getModel().info.reasoningEffort,
}
break
default:
requestOptions = {
model: modelId,

View File

@@ -118,8 +118,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
// Handle models based on deepseek-r1
if (
this.getModel().id === "deepseek/deepseek-r1" ||
this.getModel().id.startsWith("deepseek/deepseek-r1:") ||
this.getModel().id.startsWith("deepseek/deepseek-r1") ||
this.getModel().id === "perplexity/sonar-reasoning"
) {
// Recommended temperature for DeepSeek reasoning models

View File

@@ -96,6 +96,7 @@ export class Cline {
didFinishAborting = false
abandoned = false
private diffViewProvider: DiffViewProvider
private lastApiRequestTime?: number
// streaming
private currentStreamingContentIndex = 0
@@ -793,12 +794,43 @@ export class Cline {
}
}
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
async *attemptApiRequest(previousApiReqIndex: number, retryAttempt: number = 0): ApiStream {
let mcpHub: McpHub | undefined
const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } =
const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, rateLimitSeconds } =
(await this.providerRef.deref()?.getState()) ?? {}
let finalDelay = 0
// Only apply rate limiting if this isn't the first request
if (this.lastApiRequestTime) {
const now = Date.now()
const timeSinceLastRequest = now - this.lastApiRequestTime
const rateLimit = rateLimitSeconds || 0
const rateLimitDelay = Math.max(0, rateLimit * 1000 - timeSinceLastRequest)
finalDelay = rateLimitDelay
}
// Add exponential backoff delay for retries
if (retryAttempt > 0) {
const baseDelay = requestDelaySeconds || 5
const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, retryAttempt)) * 1000
finalDelay = Math.max(finalDelay, exponentialDelay)
}
if (finalDelay > 0) {
// Show countdown timer
for (let i = Math.ceil(finalDelay / 1000); i > 0; i--) {
const delayMessage =
retryAttempt > 0 ? `Retrying in ${i} seconds...` : `Rate limiting for ${i} seconds...`
await this.say("api_req_retry_delayed", delayMessage, undefined, true)
await delay(1000)
}
}
// Update last request time before making the request
this.lastApiRequestTime = Date.now()
if (mcpEnabled ?? true) {
mcpHub = this.providerRef.deref()?.mcpHub
if (!mcpHub) {
@@ -810,8 +842,14 @@ export class Cline {
})
}
const { browserViewportSize, mode, customModePrompts, preferredLanguage, experiments } =
(await this.providerRef.deref()?.getState()) ?? {}
const {
browserViewportSize,
mode,
customModePrompts,
preferredLanguage,
experiments,
enableMcpServerCreation,
} = (await this.providerRef.deref()?.getState()) ?? {}
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
const systemPrompt = await (async () => {
const provider = this.providerRef.deref()
@@ -832,6 +870,7 @@ export class Cline {
preferredLanguage,
this.diffEnabled,
experiments,
enableMcpServerCreation,
)
})()
@@ -887,21 +926,29 @@ export class Cline {
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (alwaysApproveResubmit) {
const errorMsg = error.message ?? "Unknown error"
const requestDelay = requestDelaySeconds || 5
// Automatically retry with delay
// Show countdown timer in error color
for (let i = requestDelay; i > 0; i--) {
const baseDelay = requestDelaySeconds || 5
const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, retryAttempt))
// Show countdown timer with exponential backoff
for (let i = exponentialDelay; i > 0; i--) {
await this.say(
"api_req_retry_delayed",
`${errorMsg}\n\nRetrying in ${i} seconds...`,
`${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying in ${i} seconds...`,
undefined,
true,
)
await delay(1000)
}
await this.say("api_req_retry_delayed", `${errorMsg}\n\nRetrying now...`, undefined, false)
// delegate generator output from the recursive call
yield* this.attemptApiRequest(previousApiReqIndex)
await this.say(
"api_req_retry_delayed",
`${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying now...`,
undefined,
false,
)
// delegate generator output from the recursive call with incremented retry count
yield* this.attemptApiRequest(previousApiReqIndex, retryAttempt + 1)
return
} else {
const { response } = await this.ask(
@@ -1085,35 +1132,23 @@ export class Cline {
const askApproval = async (type: ClineAsk, partialMessage?: string) => {
const { response, text, images } = await this.ask(type, partialMessage, false)
if (response !== "yesButtonClicked") {
if (response === "messageResponse") {
// Handle both messageResponse and noButtonClicked with text
if (text) {
await this.say("user_feedback", text, images)
pushToolResult(
formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images),
)
// this.userMessageContent.push({
// type: "text",
// text: `${toolDescription()}`,
// })
// this.toolResults.push({
// type: "tool_result",
// tool_use_id: toolUseId,
// content: this.formatToolResponseWithImages(
// await this.formatToolDeniedFeedback(text),
// images
// ),
// })
this.didRejectTool = true
return false
} else {
pushToolResult(formatResponse.toolDenied())
}
pushToolResult(formatResponse.toolDenied())
// this.toolResults.push({
// type: "tool_result",
// tool_use_id: toolUseId,
// content: await this.formatToolDenied(),
// })
this.didRejectTool = true
return false
}
// Handle yesButtonClicked with text
if (text) {
await this.say("user_feedback", text, images)
pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
}
return true
}

View File

@@ -1,113 +1,27 @@
import * as vscode from "vscode"
import * as path from "path"
import { ClineProvider } from "./webview/ClineProvider"
import { EditorUtils } from "./EditorUtils"
export const ACTION_NAMES = {
EXPLAIN: "Roo Code: Explain Code",
FIX: "Roo Code: Fix Code",
FIX_LOGIC: "Roo Code: Fix Logic",
IMPROVE: "Roo Code: Improve Code",
ADD_TO_CONTEXT: "Roo Code: Add to Context",
} as const
const COMMAND_IDS = {
export const COMMAND_IDS = {
EXPLAIN: "roo-cline.explainCode",
FIX: "roo-cline.fixCode",
IMPROVE: "roo-cline.improveCode",
ADD_TO_CONTEXT: "roo-cline.addToContext",
} as const
interface DiagnosticData {
message: string
severity: vscode.DiagnosticSeverity
code?: string | number | { value: string | number; target: vscode.Uri }
source?: string
range: vscode.Range
}
interface EffectiveRange {
range: vscode.Range
text: string
}
export class CodeActionProvider implements vscode.CodeActionProvider {
public static readonly providedCodeActionKinds = [
vscode.CodeActionKind.QuickFix,
vscode.CodeActionKind.RefactorRewrite,
]
// Cache file paths for performance
private readonly filePathCache = new WeakMap<vscode.TextDocument, string>()
private getEffectiveRange(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
): EffectiveRange | null {
try {
const selectedText = document.getText(range)
if (selectedText) {
return { range, text: selectedText }
}
const currentLine = document.lineAt(range.start.line)
if (!currentLine.text.trim()) {
return null
}
// Optimize range creation by checking bounds first
const startLine = Math.max(0, currentLine.lineNumber - 1)
const endLine = Math.min(document.lineCount - 1, currentLine.lineNumber + 1)
// Only create new positions if needed
const effectiveRange = new vscode.Range(
startLine === currentLine.lineNumber ? range.start : new vscode.Position(startLine, 0),
endLine === currentLine.lineNumber
? range.end
: new vscode.Position(endLine, document.lineAt(endLine).text.length),
)
return {
range: effectiveRange,
text: document.getText(effectiveRange),
}
} catch (error) {
console.error("Error getting effective range:", error)
return null
}
}
private getFilePath(document: vscode.TextDocument): string {
// Check cache first
let filePath = this.filePathCache.get(document)
if (filePath) {
return filePath
}
try {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
if (!workspaceFolder) {
filePath = document.uri.fsPath
} else {
const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath)
filePath = !relativePath || relativePath.startsWith("..") ? document.uri.fsPath : relativePath
}
// Cache the result
this.filePathCache.set(document, filePath)
return filePath
} catch (error) {
console.error("Error getting file path:", error)
return document.uri.fsPath
}
}
private createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData {
return {
message: diagnostic.message,
severity: diagnostic.severity,
code: diagnostic.code,
source: diagnostic.source,
range: diagnostic.range, // Reuse the range object
}
}
private createAction(title: string, kind: vscode.CodeActionKind, command: string, args: any[]): vscode.CodeAction {
const action = new vscode.CodeAction(title, kind)
action.command = { command, title, arguments: args }
@@ -126,32 +40,20 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
]
}
private hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean {
// Optimize range intersection check
return !(
range2.end.line < range1.start.line ||
range2.start.line > range1.end.line ||
(range2.end.line === range1.start.line && range2.end.character < range1.start.character) ||
(range2.start.line === range1.end.line && range2.start.character > range1.end.character)
)
}
public provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
try {
const effectiveRange = this.getEffectiveRange(document, range)
const effectiveRange = EditorUtils.getEffectiveRange(document, range)
if (!effectiveRange) {
return []
}
const filePath = this.getFilePath(document)
const filePath = EditorUtils.getFilePath(document)
const actions: vscode.CodeAction[] = []
// Create actions using helper method
// Add explain actions
actions.push(
...this.createActionPair(ACTION_NAMES.EXPLAIN, vscode.CodeActionKind.QuickFix, COMMAND_IDS.EXPLAIN, [
filePath,
@@ -159,14 +61,13 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
]),
)
// Only process diagnostics if they exist
if (context.diagnostics.length > 0) {
const relevantDiagnostics = context.diagnostics.filter((d) =>
this.hasIntersectingRange(effectiveRange.range, d.range),
EditorUtils.hasIntersectingRange(effectiveRange.range, d.range),
)
if (relevantDiagnostics.length > 0) {
const diagnosticMessages = relevantDiagnostics.map(this.createDiagnosticData)
const diagnosticMessages = relevantDiagnostics.map(EditorUtils.createDiagnosticData)
actions.push(
...this.createActionPair(ACTION_NAMES.FIX, vscode.CodeActionKind.QuickFix, COMMAND_IDS.FIX, [
filePath,
@@ -175,9 +76,15 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
]),
)
}
} else {
actions.push(
...this.createActionPair(ACTION_NAMES.FIX_LOGIC, vscode.CodeActionKind.QuickFix, COMMAND_IDS.FIX, [
filePath,
effectiveRange.text,
]),
)
}
// Add improve actions
actions.push(
...this.createActionPair(
ACTION_NAMES.IMPROVE,
@@ -187,6 +94,15 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
),
)
actions.push(
this.createAction(
ACTION_NAMES.ADD_TO_CONTEXT,
vscode.CodeActionKind.QuickFix,
COMMAND_IDS.ADD_TO_CONTEXT,
[filePath, effectiveRange.text],
),
)
return actions
} catch (error) {
console.error("Error providing code actions:", error)

141
src/core/EditorUtils.ts Normal file
View File

@@ -0,0 +1,141 @@
import * as vscode from "vscode"
import * as path from "path"
export interface EffectiveRange {
range: vscode.Range
text: string
}
export interface DiagnosticData {
message: string
severity: vscode.DiagnosticSeverity
code?: string | number | { value: string | number; target: vscode.Uri }
source?: string
range: vscode.Range
}
export interface EditorContext {
filePath: string
selectedText: string
diagnostics?: DiagnosticData[]
}
export class EditorUtils {
// Cache file paths for performance
private static readonly filePathCache = new WeakMap<vscode.TextDocument, string>()
static getEffectiveRange(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
): EffectiveRange | null {
try {
const selectedText = document.getText(range)
if (selectedText) {
return { range, text: selectedText }
}
const currentLine = document.lineAt(range.start.line)
if (!currentLine.text.trim()) {
return null
}
// Optimize range creation by checking bounds first
const startLine = Math.max(0, currentLine.lineNumber - 1)
const endLine = Math.min(document.lineCount - 1, currentLine.lineNumber + 1)
// Only create new positions if needed
const effectiveRange = new vscode.Range(
startLine === currentLine.lineNumber ? range.start : new vscode.Position(startLine, 0),
endLine === currentLine.lineNumber
? range.end
: new vscode.Position(endLine, document.lineAt(endLine).text.length),
)
return {
range: effectiveRange,
text: document.getText(effectiveRange),
}
} catch (error) {
console.error("Error getting effective range:", error)
return null
}
}
static getFilePath(document: vscode.TextDocument): string {
// Check cache first
let filePath = this.filePathCache.get(document)
if (filePath) {
return filePath
}
try {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
if (!workspaceFolder) {
filePath = document.uri.fsPath
} else {
const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath)
filePath = !relativePath || relativePath.startsWith("..") ? document.uri.fsPath : relativePath
}
// Cache the result
this.filePathCache.set(document, filePath)
return filePath
} catch (error) {
console.error("Error getting file path:", error)
return document.uri.fsPath
}
}
static createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData {
return {
message: diagnostic.message,
severity: diagnostic.severity,
code: diagnostic.code,
source: diagnostic.source,
range: diagnostic.range,
}
}
static hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean {
return !(
range2.end.line < range1.start.line ||
range2.start.line > range1.end.line ||
(range2.end.line === range1.start.line && range2.end.character < range1.start.character) ||
(range2.start.line === range1.end.line && range2.start.character > range1.end.character)
)
}
static getEditorContext(editor?: vscode.TextEditor): EditorContext | null {
try {
if (!editor) {
editor = vscode.window.activeTextEditor
}
if (!editor) {
return null
}
const document = editor.document
const selection = editor.selection
const effectiveRange = this.getEffectiveRange(document, selection)
if (!effectiveRange) {
return null
}
const filePath = this.getFilePath(document)
const diagnostics = vscode.languages
.getDiagnostics(document.uri)
.filter((d) => this.hasIntersectingRange(effectiveRange.range, d.range))
.map(this.createDiagnosticData)
return {
filePath,
selectedText: effectiveRange.text,
...(diagnostics.length > 0 ? { diagnostics } : {}),
}
} catch (error) {
console.error("Error getting editor context:", error)
return null
}
}
}

View File

@@ -128,6 +128,7 @@ jest.mock("vscode", () => {
visibleTextEditors: [mockTextEditor],
tabGroups: {
all: [mockTabGroup],
onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })),
},
},
workspace: {
@@ -730,25 +731,19 @@ describe("Cline", () => {
const iterator = cline.attemptApiRequest(0)
await iterator.next()
// Calculate expected delay for first retry
const baseDelay = 3 // from requestDelaySeconds
// Verify countdown messages
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining("Retrying in 3 seconds"),
undefined,
true,
)
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining("Retrying in 2 seconds"),
undefined,
true,
)
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining("Retrying in 1 seconds"),
undefined,
true,
)
for (let i = baseDelay; i > 0; i--) {
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining(`Retrying in ${i} seconds`),
undefined,
true,
)
}
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining("Retrying now"),
@@ -756,13 +751,18 @@ describe("Cline", () => {
false,
)
// Verify delay was called correctly
expect(mockDelay).toHaveBeenCalledTimes(3)
// Calculate expected delay calls based on exponential backoff
const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, 1)) // retryAttempt = 1
const rateLimitDelay = baseDelay // Initial rate limit delay
const totalExpectedDelays = exponentialDelay + rateLimitDelay
expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays)
expect(mockDelay).toHaveBeenCalledWith(1000)
// Verify error message content
const errorMessage = saySpy.mock.calls.find((call) => call[1]?.includes(mockError.message))?.[1]
expect(errorMessage).toBe(`${mockError.message}\n\nRetrying in 3 seconds...`)
expect(errorMessage).toBe(
`${mockError.message}\n\nRetry attempt 1\nRetrying in ${baseDelay} seconds...`,
)
})
describe("loadContext", () => {

View File

@@ -1,5 +1,6 @@
import * as vscode from "vscode"
import { CodeActionProvider, ACTION_NAMES } from "../CodeActionProvider"
import { EditorUtils } from "../EditorUtils"
// Mock VSCode API
jest.mock("vscode", () => ({
@@ -16,13 +17,6 @@ jest.mock("vscode", () => ({
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
})),
Position: jest.fn().mockImplementation((line, character) => ({
line,
character,
})),
workspace: {
getWorkspaceFolder: jest.fn(),
},
DiagnosticSeverity: {
Error: 0,
Warning: 1,
@@ -31,6 +25,16 @@ jest.mock("vscode", () => ({
},
}))
// Mock EditorUtils
jest.mock("../EditorUtils", () => ({
EditorUtils: {
getEffectiveRange: jest.fn(),
getFilePath: jest.fn(),
hasIntersectingRange: jest.fn(),
createDiagnosticData: jest.fn(),
},
}))
describe("CodeActionProvider", () => {
let provider: CodeActionProvider
let mockDocument: any
@@ -55,68 +59,32 @@ describe("CodeActionProvider", () => {
mockContext = {
diagnostics: [],
}
})
describe("getEffectiveRange", () => {
it("should return selected text when available", () => {
mockDocument.getText.mockReturnValue("selected text")
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
expect(result).toEqual({
range: mockRange,
text: "selected text",
})
})
it("should return null for empty line", () => {
mockDocument.getText.mockReturnValue("")
mockDocument.lineAt.mockReturnValue({ text: "", lineNumber: 0 })
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
expect(result).toBeNull()
})
})
describe("getFilePath", () => {
it("should return relative path when in workspace", () => {
const mockWorkspaceFolder = {
uri: { fsPath: "/test" },
}
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(mockWorkspaceFolder)
const result = (provider as any).getFilePath(mockDocument)
expect(result).toBe("file.ts")
})
it("should return absolute path when not in workspace", () => {
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(null)
const result = (provider as any).getFilePath(mockDocument)
expect(result).toBe("/test/file.ts")
// Setup default EditorUtils mocks
;(EditorUtils.getEffectiveRange as jest.Mock).mockReturnValue({
range: mockRange,
text: "test code",
})
;(EditorUtils.getFilePath as jest.Mock).mockReturnValue("/test/file.ts")
;(EditorUtils.hasIntersectingRange as jest.Mock).mockReturnValue(true)
;(EditorUtils.createDiagnosticData as jest.Mock).mockImplementation((d) => d)
})
describe("provideCodeActions", () => {
beforeEach(() => {
mockDocument.getText.mockReturnValue("test code")
mockDocument.lineAt.mockReturnValue({ text: "test code", lineNumber: 0 })
})
it("should provide explain and improve actions by default", () => {
it("should provide explain, improve, fix logic, and add to context actions by default", () => {
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toHaveLength(4)
expect(actions).toHaveLength(7) // 2 explain + 2 fix logic + 2 improve + 1 add to context
expect((actions as any)[0].title).toBe(`${ACTION_NAMES.EXPLAIN} in New Task`)
expect((actions as any)[1].title).toBe(`${ACTION_NAMES.EXPLAIN} in Current Task`)
expect((actions as any)[2].title).toBe(`${ACTION_NAMES.IMPROVE} in New Task`)
expect((actions as any)[3].title).toBe(`${ACTION_NAMES.IMPROVE} in Current Task`)
expect((actions as any)[2].title).toBe(`${ACTION_NAMES.FIX_LOGIC} in New Task`)
expect((actions as any)[3].title).toBe(`${ACTION_NAMES.FIX_LOGIC} in Current Task`)
expect((actions as any)[4].title).toBe(`${ACTION_NAMES.IMPROVE} in New Task`)
expect((actions as any)[5].title).toBe(`${ACTION_NAMES.IMPROVE} in Current Task`)
expect((actions as any)[6].title).toBe(ACTION_NAMES.ADD_TO_CONTEXT)
})
it("should provide fix action when diagnostics exist", () => {
it("should provide fix action instead of fix logic when diagnostics exist", () => {
mockContext.diagnostics = [
{
message: "test error",
@@ -127,22 +95,33 @@ describe("CodeActionProvider", () => {
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toHaveLength(6)
expect(actions).toHaveLength(7) // 2 explain + 2 fix + 2 improve + 1 add to context
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX} in New Task`)).toBe(true)
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX} in Current Task`)).toBe(true)
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX_LOGIC} in New Task`)).toBe(false)
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX_LOGIC} in Current Task`)).toBe(
false,
)
})
it("should handle errors gracefully", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
mockDocument.getText.mockImplementation(() => {
throw new Error("Test error")
})
mockDocument.lineAt.mockReturnValue({ text: "test", lineNumber: 0 })
it("should return empty array when no effective range", () => {
;(EditorUtils.getEffectiveRange as jest.Mock).mockReturnValue(null)
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toEqual([])
expect(consoleErrorSpy).toHaveBeenCalledWith("Error getting effective range:", expect.any(Error))
})
it("should handle errors gracefully", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
;(EditorUtils.getEffectiveRange as jest.Mock).mockImplementation(() => {
throw new Error("Test error")
})
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toEqual([])
expect(consoleErrorSpy).toHaveBeenCalledWith("Error providing code actions:", expect.any(Error))
consoleErrorSpy.mockRestore()
})

View File

@@ -0,0 +1,75 @@
import * as vscode from "vscode"
import { EditorUtils } from "../EditorUtils"
// Mock VSCode API
jest.mock("vscode", () => ({
Range: jest.fn().mockImplementation((startLine, startChar, endLine, endChar) => ({
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
})),
Position: jest.fn().mockImplementation((line, character) => ({
line,
character,
})),
workspace: {
getWorkspaceFolder: jest.fn(),
},
}))
describe("EditorUtils", () => {
let mockDocument: any
beforeEach(() => {
mockDocument = {
getText: jest.fn(),
lineAt: jest.fn(),
lineCount: 10,
uri: { fsPath: "/test/file.ts" },
}
})
describe("getEffectiveRange", () => {
it("should return selected text when available", () => {
const mockRange = new vscode.Range(0, 0, 0, 10)
mockDocument.getText.mockReturnValue("selected text")
const result = EditorUtils.getEffectiveRange(mockDocument, mockRange)
expect(result).toEqual({
range: mockRange,
text: "selected text",
})
})
it("should return null for empty line", () => {
const mockRange = new vscode.Range(0, 0, 0, 10)
mockDocument.getText.mockReturnValue("")
mockDocument.lineAt.mockReturnValue({ text: "", lineNumber: 0 })
const result = EditorUtils.getEffectiveRange(mockDocument, mockRange)
expect(result).toBeNull()
})
})
describe("getFilePath", () => {
it("should return relative path when in workspace", () => {
const mockWorkspaceFolder = {
uri: { fsPath: "/test" },
}
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(mockWorkspaceFolder)
const result = EditorUtils.getFilePath(mockDocument)
expect(result).toBe("file.ts")
})
it("should return absolute path when not in workspace", () => {
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(null)
const result = EditorUtils.getFilePath(mockDocument)
expect(result).toBe("/test/file.ts")
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -174,6 +174,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toMatchSnapshot()
@@ -194,6 +195,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toMatchSnapshot()
@@ -216,6 +218,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toMatchSnapshot()
@@ -236,6 +239,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toMatchSnapshot()
@@ -256,6 +260,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toMatchSnapshot()
@@ -276,6 +281,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
true, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toContain("apply_diff")
@@ -297,6 +303,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
false, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).not.toContain("apply_diff")
@@ -318,6 +325,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).not.toContain("apply_diff")
@@ -339,6 +347,7 @@ describe("SYSTEM_PROMPT", () => {
"Spanish", // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toContain("Language Preference:")
@@ -371,6 +380,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
// Role definition should be at the top
@@ -406,6 +416,7 @@ describe("SYSTEM_PROMPT", () => {
undefined,
undefined,
experiments,
true, // enableMcpServerCreation
)
// Role definition from promptComponent should be at the top
@@ -436,6 +447,7 @@ describe("SYSTEM_PROMPT", () => {
undefined,
undefined,
experiments,
true, // enableMcpServerCreation
)
// Should use the default mode's role definition
@@ -458,6 +470,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments, // experiments - undefined should disable all experimental tools
true, // enableMcpServerCreation
)
// Verify experimental tools are not included in the prompt
@@ -485,6 +498,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
// Verify experimental tools are included in the prompt when enabled
@@ -512,6 +526,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
// Verify only enabled experimental tools are included
@@ -539,6 +554,7 @@ describe("SYSTEM_PROMPT", () => {
undefined,
true, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
// Verify base instruction lists all available tools
@@ -568,6 +584,7 @@ describe("SYSTEM_PROMPT", () => {
undefined,
true,
experiments,
true, // enableMcpServerCreation
)
// Verify detailed instructions for each tool
@@ -623,6 +640,7 @@ describe("addCustomInstructions", () => {
undefined,
undefined,
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toMatchSnapshot()
@@ -643,11 +661,60 @@ describe("addCustomInstructions", () => {
undefined,
undefined,
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toMatchSnapshot()
})
it("should include MCP server creation info when enabled", async () => {
const mockMcpHub = createMockMcpHub()
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false, // supportsComputerUse
mockMcpHub, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes,
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
)
expect(prompt).toContain("Creating an MCP Server")
expect(prompt).toMatchSnapshot()
})
it("should exclude MCP server creation info when disabled", async () => {
const mockMcpHub = createMockMcpHub()
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false, // supportsComputerUse
mockMcpHub, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes,
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
false, // enableMcpServerCreation
)
expect(prompt).not.toContain("Creating an MCP Server")
expect(prompt).toMatchSnapshot()
})
it("should prioritize mode-specific rules for code mode", async () => {
const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug)
expect(instructions).toMatchSnapshot()

View File

@@ -8,6 +8,9 @@ export const formatResponse = {
toolDeniedWithFeedback: (feedback?: string) =>
`The user denied this operation and provided the following feedback:\n<feedback>\n${feedback}\n</feedback>`,
toolApprovedWithFeedback: (feedback?: string) =>
`The user approved this operation and provided the following context:\n<feedback>\n${feedback}\n</feedback>`,
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
noToolsUsed: () =>

View File

@@ -1,7 +1,11 @@
import { DiffStrategy } from "../../diff/DiffStrategy"
import { McpHub } from "../../../services/mcp/McpHub"
export async function getMcpServersSection(mcpHub?: McpHub, diffStrategy?: DiffStrategy): Promise<string> {
export async function getMcpServersSection(
mcpHub?: McpHub,
diffStrategy?: DiffStrategy,
enableMcpServerCreation?: boolean,
): Promise<string> {
if (!mcpHub) {
return ""
}
@@ -43,7 +47,7 @@ export async function getMcpServersSection(mcpHub?: McpHub, diffStrategy?: DiffS
.join("\n\n")}`
: "(No MCP servers currently connected)"
return `MCP SERVERS
const baseSection = `MCP SERVERS
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
@@ -51,7 +55,15 @@ The Model Context Protocol (MCP) enables communication between the system and lo
When a server is connected, you can use the server's tools via the \`use_mcp_tool\` tool, and access the server's resources via the \`access_mcp_resource\` tool.
${connectedServers}
${connectedServers}`
if (!enableMcpServerCreation) {
return baseSection
}
return (
baseSection +
`
## Creating an MCP Server
@@ -398,11 +410,11 @@ IMPORTANT: Regardless of what else you see in the MCP settings file, you must de
## Editing MCP Servers
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' above: ${
mcpHub
.getServers()
.map((server) => server.name)
.join(", ") || "(None running currently)"
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file${diffStrategy ? " or apply_diff" : ""} to make changes to the files.
mcpHub
.getServers()
.map((server) => server.name)
.join(", ") || "(None running currently)"
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file${diffStrategy ? " or apply_diff" : ""} to make changes to the files.
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
@@ -411,4 +423,5 @@ However some MCP servers may be running from installed packages rather than a lo
The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that...").
Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.`
)
}

View File

@@ -16,13 +16,17 @@ MODES
${modes.map((mode: ModeConfig) => ` * "${mode.name}" mode - ${mode.roleDefinition.split(".")[0]}`).join("\n")}
Custom modes will be referred to by their configured name property.
- Custom modes can be configured by creating or editing the custom modes file at '${customModesPath}'. The following fields are required and must not be empty:
- Custom modes can be configured by editing the custom modes file at '${customModesPath}'. The file gets created automatically on startup and should always exist. Make sure to read the latest contents before writing to it to avoid overwriting existing modes.
- The following fields are required and must not be empty:
* slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better.
* name: The display name for the mode
* roleDefinition: A detailed description of the mode's role and capabilities
* groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }] to only allow editing markdown files)
The customInstructions field is optional.
- The customInstructions field is optional.
- For multi-line text, include newline characters in the string like "This is the first line.\nThis is the next line.\n\nThis is a double line break."
The file should follow this structure:
{

View File

@@ -40,6 +40,7 @@ async function generatePrompt(
preferredLanguage?: string,
diffEnabled?: boolean,
experiments?: Record<string, boolean>,
enableMcpServerCreation?: boolean,
): Promise<string> {
if (!context) {
throw new Error("Extension context is required for generating system prompt")
@@ -49,7 +50,7 @@ async function generatePrompt(
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
const [mcpServersSection, modesSection] = await Promise.all([
getMcpServersSection(mcpHub, effectiveDiffStrategy),
getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation),
getModesSection(context),
])
@@ -105,6 +106,7 @@ export const SYSTEM_PROMPT = async (
preferredLanguage?: string,
diffEnabled?: boolean,
experiments?: Record<string, boolean>,
enableMcpServerCreation?: boolean,
): Promise<string> => {
if (!context) {
throw new Error("Extension context is required for generating system prompt")
@@ -139,5 +141,6 @@ export const SYSTEM_PROMPT = async (
preferredLanguage,
diffEnabled,
experiments,
enableMcpServerCreation,
)
}

View File

@@ -19,15 +19,7 @@ import { findLast } from "../../shared/array"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem"
import { WebviewMessage } from "../../shared/WebviewMessage"
import {
Mode,
modes,
CustomModePrompts,
PromptComponent,
ModeConfig,
defaultModeSlug,
getModeBySlug,
} from "../../shared/modes"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
import { SYSTEM_PROMPT } from "../prompts/system"
import { fileExistsAtPath } from "../../utils/fs"
import { Cline } from "../Cline"
@@ -37,7 +29,7 @@ import { getUri } from "./getUri"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
import { searchCommits } from "../../utils/git"
import { ConfigManager } from "../config/ConfigManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
@@ -110,8 +102,10 @@ type GlobalStateKey =
| "writeDelayMs"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "enableMcpServerCreation"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "rateLimitSeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
| "vsCodeLmModelSelector"
@@ -139,6 +133,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private static activeInstances: Set<ClineProvider> = new Set()
private disposables: vscode.Disposable[] = []
private view?: vscode.WebviewView | vscode.WebviewPanel
private isViewLaunched = false
private cline?: Cline
private workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
@@ -238,6 +233,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const prompt = supportPrompt.create(promptType, params, customSupportPrompts)
if (command.endsWith("addToContext")) {
await visibleProvider.postMessageToWebview({
type: "invoke",
invoke: "setChatBoxMessage",
text: prompt,
})
return
}
if (visibleProvider.cline && command.endsWith("InCurrentTask")) {
await visibleProvider.postMessageToWebview({
type: "invoke",
@@ -269,7 +274,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
}
webviewView.webview.html = this.getHtmlContent(webviewView.webview)
webviewView.webview.html =
this.context.extensionMode === vscode.ExtensionMode.Development
? this.getHMRHtmlContent(webviewView.webview)
: this.getHtmlContent(webviewView.webview)
// Sets up an event listener to listen for messages passed from the webview view context
// and executes code based on the message that is recieved
@@ -393,6 +402,62 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.view?.webview.postMessage(message)
}
private getHMRHtmlContent(webview: vscode.Webview): string {
const nonce = getNonce()
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, this.context.extensionUri, [
"node_modules",
"@vscode",
"codicons",
"dist",
"codicon.css",
])
const file = "src/index.tsx"
const localPort = "5173"
const localServerUrl = `localhost:${localPort}`
const scriptUri = `http://${localServerUrl}/${file}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://localhost:${localPort}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${webview.cspSource}`,
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${webview.cspSource} data:`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Roo Code</title>
</head>
<body>
<div id="root"></div>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Defines and returns the HTML that should be rendered within the webview panel.
*
@@ -409,15 +474,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = getUri(webview, this.context.extensionUri, [
"webview-ui",
"build",
"static",
"css",
"main.css",
])
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
// The JS file from the React build output
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "static", "js", "main.js"])
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"])
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
@@ -554,7 +613,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
let currentConfigName = (await this.getGlobalState("currentApiConfigName")) as string
const currentConfigName = (await this.getGlobalState("currentApiConfigName")) as string
if (currentConfigName) {
if (!(await this.configManager.hasConfig(currentConfigName))) {
@@ -587,6 +646,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
),
)
this.isViewLaunched = true
break
case "newTask":
// Code that should run in response to the hello message command
@@ -782,6 +842,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("mcpEnabled", mcpEnabled)
await this.postStateToWebview()
break
case "enableMcpServerCreation":
await this.updateGlobalState("enableMcpServerCreation", message.bool ?? true)
await this.postStateToWebview()
break
case "playSound":
if (message.audioType) {
const soundPath = path.join(this.context.extensionPath, "audio", `${message.audioType}.wav`)
@@ -822,6 +886,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("requestDelaySeconds", message.value ?? 5)
await this.postStateToWebview()
break
case "rateLimitSeconds":
await this.updateGlobalState("rateLimitSeconds", message.value ?? 0)
await this.postStateToWebview()
break
case "preferredLanguage":
await this.updateGlobalState("preferredLanguage", message.text)
await this.postStateToWebview()
@@ -1066,6 +1134,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled,
fuzzyMatchThreshold,
experiments,
enableMcpServerCreation,
} = await this.getState()
// Create diffStrategy based on current model and settings
@@ -1094,6 +1163,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
preferredLanguage,
diffEnabled,
experiments,
enableMcpServerCreation,
)
await this.postMessageToWebview({
@@ -1130,7 +1200,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (message.text && message.apiConfiguration) {
try {
await this.configManager.saveConfig(message.text, message.apiConfiguration)
let listApiConfig = await this.configManager.listConfig()
const listApiConfig = await this.configManager.listConfig()
await Promise.all([
this.updateGlobalState("listApiConfigMeta", listApiConfig),
@@ -1152,10 +1222,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
try {
const { oldName, newName } = message.values
if (oldName === newName) {
break
}
await this.configManager.saveConfig(newName, message.apiConfiguration)
await this.configManager.deleteConfig(oldName)
let listApiConfig = await this.configManager.listConfig()
const listApiConfig = await this.configManager.listConfig()
const config = listApiConfig?.find((c) => c.name === newName)
// Update listApiConfigMeta first to ensure UI has latest data
@@ -1213,7 +1287,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
// If this was the current config, switch to first available
let currentApiConfigName = await this.getGlobalState("currentApiConfigName")
const currentApiConfigName = await this.getGlobalState("currentApiConfigName")
if (message.text === currentApiConfigName && listApiConfig?.[0]?.name) {
const apiConfig = await this.configManager.loadConfig(listApiConfig[0].name)
await Promise.all([
@@ -1233,7 +1307,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "getListApiConfiguration":
try {
let listApiConfig = await this.configManager.listConfig()
const listApiConfig = await this.configManager.listConfig()
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
} catch (error) {
@@ -1273,7 +1347,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine(
`Failed to update timeout for ${message.serverName}: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(`Failed to update server timeout`)
vscode.window.showErrorMessage("Failed to update server timeout")
}
}
break
@@ -1626,7 +1700,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
async refreshGlamaModels() {
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
let models: Record<string, ModelInfo> = {}
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
/*
@@ -1716,7 +1790,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
GlobalFileNames.openRouterModels,
)
let models: Record<string, ModelInfo> = {}
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
/*
@@ -1931,8 +2005,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
terminalOutputLineLimit,
fuzzyMatchThreshold,
mcpEnabled,
enableMcpServerCreation,
alwaysApproveResubmit,
requestDelaySeconds,
rateLimitSeconds,
currentApiConfigName,
listApiConfigMeta,
mode,
@@ -1972,8 +2048,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
mcpEnabled: mcpEnabled ?? true,
enableMcpServerCreation: enableMcpServerCreation ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 10,
rateLimitSeconds: rateLimitSeconds ?? 0,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
mode: mode ?? defaultModeSlug,
@@ -2095,8 +2173,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
screenshotQuality,
terminalOutputLineLimit,
mcpEnabled,
enableMcpServerCreation,
alwaysApproveResubmit,
requestDelaySeconds,
rateLimitSeconds,
currentApiConfigName,
listApiConfigMeta,
vsCodeLmModelSelector,
@@ -2167,8 +2247,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("screenshotQuality") as Promise<number | undefined>,
this.getGlobalState("terminalOutputLineLimit") as Promise<number | undefined>,
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
this.getGlobalState("enableMcpServerCreation") as Promise<boolean | undefined>,
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
this.getGlobalState("rateLimitSeconds") as Promise<number | undefined>,
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
this.getGlobalState("vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
@@ -2289,8 +2371,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return langMap[vscodeLang.split("-")[0]] ?? "English"
})(),
mcpEnabled: mcpEnabled ?? true,
enableMcpServerCreation: enableMcpServerCreation ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: Math.max(5, requestDelaySeconds ?? 10),
rateLimitSeconds: rateLimitSeconds ?? 0,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
modeApiConfigs: modeApiConfigs ?? ({} as Record<Mode, string>),
@@ -2348,7 +2432,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// secrets
private async storeSecret(key: SecretKey, value?: string) {
public async storeSecret(key: SecretKey, value?: string) {
if (value) {
await this.context.secrets.store(key, value)
} else {
@@ -2402,4 +2486,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postStateToWebview()
await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
}
// integration tests
get viewLaunched() {
return this.isViewLaunched
}
get messages() {
return this.cline?.clineMessages || []
}
}

View File

@@ -108,6 +108,11 @@ jest.mock("vscode", () => ({
uriScheme: "vscode",
language: "en",
},
ExtensionMode: {
Production: 1,
Development: 2,
Test: 3,
},
}))
// Mock sound utility
@@ -318,7 +323,9 @@ describe("ClineProvider", () => {
browserViewportSize: "900x600",
fuzzyMatchThreshold: 1.0,
mcpEnabled: true,
enableMcpServerCreation: false,
requestDelaySeconds: 5,
rateLimitSeconds: 0,
mode: defaultModeSlug,
customModes: [],
experiments: experimentDefault,
@@ -889,6 +896,7 @@ describe("ClineProvider", () => {
},
},
mcpEnabled: true,
enableMcpServerCreation: false,
mode: "code" as const,
experiments: experimentDefault,
} as any)
@@ -921,6 +929,7 @@ describe("ClineProvider", () => {
},
},
mcpEnabled: false,
enableMcpServerCreation: false,
mode: "code" as const,
experiments: experimentDefault,
} as any)
@@ -985,6 +994,7 @@ describe("ClineProvider", () => {
},
customModePrompts: {},
mode: "code",
enableMcpServerCreation: true,
mcpEnabled: false,
browserViewportSize: "900x600",
experimentalDiffStrategy: true,
@@ -1019,6 +1029,7 @@ describe("ClineProvider", () => {
undefined, // preferredLanguage
true, // diffEnabled
experimentDefault,
true,
)
// Run the test again to verify it's consistent
@@ -1042,6 +1053,7 @@ describe("ClineProvider", () => {
diffEnabled: false,
fuzzyMatchThreshold: 0.8,
experiments: experimentDefault,
enableMcpServerCreation: true,
} as any)
// Mock SYSTEM_PROMPT to verify diffEnabled is passed as false
@@ -1070,6 +1082,7 @@ describe("ClineProvider", () => {
undefined, // preferredLanguage
false, // diffEnabled
experimentDefault,
true,
)
})
@@ -1084,6 +1097,7 @@ describe("ClineProvider", () => {
architect: { customInstructions: "Architect mode instructions" },
},
mode: "architect",
enableMcpServerCreation: false,
mcpEnabled: false,
browserViewportSize: "900x600",
experiments: experimentDefault,

View File

@@ -1,36 +1,33 @@
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import delay from "delay"
import * as vscode from "vscode"
import { ClineProvider } from "./core/webview/ClineProvider"
import { createClineAPI } from "./exports"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { ACTION_NAMES, CodeActionProvider } from "./core/CodeActionProvider"
import "./utils/path" // Necessary to have access to String.prototype.toPosix.
import { CodeActionProvider } from "./core/CodeActionProvider"
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import { handleUri, registerCommands, registerCodeActions } from "./activate"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
Inspired by
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/default/weather-webview
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/frameworks/hello-world-react-cra
*/
/**
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
*
* Inspired by:
* - https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/default/weather-webview
* - https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/frameworks/hello-world-react-cra
*/
let outputChannel: vscode.OutputChannel
// This method is called when your extension is activated
// Your extension is activated the very first time the command is executed
// This method is called when your extension is activated.
// Your extension is activated the very first time the command is executed.
export function activate(context: vscode.ExtensionContext) {
outputChannel = vscode.window.createOutputChannel("Roo-Code")
context.subscriptions.push(outputChannel)
outputChannel.appendLine("Roo-Code extension activated")
// Get default commands from configuration
// Get default commands from configuration.
const defaultCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
// Initialize global state if not already set
// Initialize global state if not already set.
if (!context.globalState.get("allowedCommands")) {
context.globalState.update("allowedCommands", defaultCommands)
}
@@ -43,208 +40,49 @@ export function activate(context: vscode.ExtensionContext) {
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("roo-cline.plusButtonClicked", async () => {
outputChannel.appendLine("Plus button Clicked")
await sidebarProvider.clearTask()
await sidebarProvider.postStateToWebview()
await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
}),
)
registerCommands({ context, outputChannel, provider: sidebarProvider })
context.subscriptions.push(
vscode.commands.registerCommand("roo-cline.mcpButtonClicked", () => {
sidebarProvider.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("roo-cline.promptsButtonClicked", () => {
sidebarProvider.postMessageToWebview({ type: "action", action: "promptsButtonClicked" })
}),
)
const openClineInNewTab = async () => {
outputChannel.appendLine("Opening Roo Code in new tab")
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
const tabProvider = new ClineProvider(context, outputChannel)
//const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
// Check if there are any visible text editors, otherwise open a new group to the right
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
if (!hasVisibleEditors) {
await vscode.commands.executeCommand("workbench.action.newGroupRight")
}
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
})
// TODO: use better svg icon with light and dark variants (see https://stackoverflow.com/questions/58365687/vscode-extension-iconpath)
panel.iconPath = {
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
}
tabProvider.resolveWebviewView(panel)
// Lock the editor group so clicking on files doesn't open them over the panel
await delay(100)
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
}
context.subscriptions.push(vscode.commands.registerCommand("roo-cline.popoutButtonClicked", openClineInNewTab))
context.subscriptions.push(vscode.commands.registerCommand("roo-cline.openInNewTab", openClineInNewTab))
context.subscriptions.push(
vscode.commands.registerCommand("roo-cline.settingsButtonClicked", () => {
//vscode.window.showInformationMessage(message)
sidebarProvider.postMessageToWebview({ type: "action", action: "settingsButtonClicked" })
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("roo-cline.historyButtonClicked", () => {
sidebarProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" })
}),
)
/*
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
- This API allows you to create readonly documents in VSCode from arbitrary sources, and works by claiming an uri-scheme for which your provider then returns text contents. The scheme must be provided when registering a provider and cannot change afterwards.
- Note how the provider doesn't create uris for virtual documents - its role is to provide contents given such an uri. In return, content providers are wired into the open document logic so that providers are always considered.
https://code.visualstudio.com/api/extension-guides/virtual-documents
*/
/**
* We use the text document content provider API to show the left side for diff
* view by creating a virtual document for the original content. This makes it
* readonly so users know to edit the right side if they want to keep their changes.
*
* This API allows you to create readonly documents in VSCode from arbitrary
* sources, and works by claiming an uri-scheme for which your provider then
* returns text contents. The scheme must be provided when registering a
* provider and cannot change afterwards.
*
* Note how the provider doesn't create uris for virtual documents - its role
* is to provide contents given such an uri. In return, content providers are
* wired into the open document logic so that providers are always considered.
*
* https://code.visualstudio.com/api/extension-guides/virtual-documents
*/
const diffContentProvider = new (class implements vscode.TextDocumentContentProvider {
provideTextDocumentContent(uri: vscode.Uri): string {
return Buffer.from(uri.query, "base64").toString("utf-8")
}
})()
context.subscriptions.push(
vscode.workspace.registerTextDocumentContentProvider(DIFF_VIEW_URI_SCHEME, diffContentProvider),
)
// URI Handler
const handleUri = async (uri: vscode.Uri) => {
const path = uri.path
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
const visibleProvider = ClineProvider.getVisibleInstance()
if (!visibleProvider) {
return
}
switch (path) {
case "/glama": {
const code = query.get("code")
if (code) {
await visibleProvider.handleGlamaCallback(code)
}
break
}
case "/openrouter": {
const code = query.get("code")
if (code) {
await visibleProvider.handleOpenRouterCallback(code)
}
break
}
default:
break
}
}
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
// Register code actions provider
// Register code actions provider.
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider({ pattern: "**/*" }, new CodeActionProvider(), {
providedCodeActionKinds: CodeActionProvider.providedCodeActionKinds,
}),
)
// Helper function to handle code actions
const registerCodeAction = (
context: vscode.ExtensionContext,
command: string,
promptType: keyof typeof ACTION_NAMES,
inNewTask: boolean,
inputPrompt?: string,
inputPlaceholder?: string,
) => {
let userInput: string | undefined
context.subscriptions.push(
vscode.commands.registerCommand(
command,
async (filePath: string, selectedText: string, diagnostics?: any[]) => {
if (inputPrompt) {
userInput = await vscode.window.showInputBox({
prompt: inputPrompt,
placeHolder: inputPlaceholder,
})
}
const params = {
filePath,
selectedText,
...(diagnostics ? { diagnostics } : {}),
...(userInput ? { userInput } : {}),
}
await ClineProvider.handleCodeAction(command, promptType, params)
},
),
)
}
// Helper function to register both versions of a code action
const registerCodeActionPair = (
context: vscode.ExtensionContext,
baseCommand: string,
promptType: keyof typeof ACTION_NAMES,
inputPrompt?: string,
inputPlaceholder?: string,
) => {
// Register new task version
registerCodeAction(context, baseCommand, promptType, true, inputPrompt, inputPlaceholder)
// Register current task version
registerCodeAction(context, `${baseCommand}InCurrentTask`, promptType, false, inputPrompt, inputPlaceholder)
}
// Register code action commands
registerCodeActionPair(
context,
"roo-cline.explainCode",
"EXPLAIN",
"What would you like Roo to explain?",
"E.g. How does the error handling work?",
)
registerCodeActionPair(
context,
"roo-cline.fixCode",
"FIX",
"What would you like Roo to fix?",
"E.g. Maintain backward compatibility",
)
registerCodeActionPair(
context,
"roo-cline.improveCode",
"IMPROVE",
"What would you like Roo to improve?",
"E.g. Focus on performance optimization",
)
registerCodeActions(context)
return createClineAPI(outputChannel, sidebarProvider)
}
// This method is called when your extension is deactivated
// This method is called when your extension is deactivated.
export function deactivate() {
outputChannel.appendLine("Roo-Code extension deactivated")
}

View File

@@ -2,6 +2,7 @@ import * as vscode from "vscode"
import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { toRelativePath } from "../../utils/path"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const MAX_INITIAL_FILES = 1_000
@@ -48,6 +49,23 @@ class WorkspaceTracker {
)
this.disposables.push(watcher)
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
}
private getOpenedTabsInfo() {
return vscode.window.tabGroups.all.flatMap((group) =>
group.tabs
.filter((tab) => tab.input instanceof vscode.TabInputText)
.map((tab) => {
const path = (tab.input as vscode.TabInputText).uri.fsPath
return {
label: tab.label,
isActive: tab.isActive,
path: toRelativePath(path, cwd || ""),
}
}),
)
}
private workspaceDidUpdate() {
@@ -59,12 +77,12 @@ class WorkspaceTracker {
if (!cwd) {
return
}
const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
this.providerRef.deref()?.postMessageToWebview({
type: "workspaceUpdated",
filePaths: Array.from(this.filePaths).map((file) => {
const relativePath = path.relative(cwd, file).toPosix()
return file.endsWith("/") ? relativePath + "/" : relativePath
}),
filePaths: relativeFilePaths,
openedTabs: this.getOpenedTabsInfo(),
})
this.updateTimer = null
}, 300) // Debounce for 300ms

View File

@@ -16,6 +16,12 @@ const mockWatcher = {
}
jest.mock("vscode", () => ({
window: {
tabGroups: {
onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })),
all: [],
},
},
workspace: {
workspaceFolders: [
{
@@ -61,6 +67,7 @@ describe("WorkspaceTracker", () => {
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: expect.arrayContaining(["file1.ts", "file2.ts"]),
openedTabs: [],
})
expect((mockProvider.postMessageToWebview as jest.Mock).mock.calls[0][0].filePaths).toHaveLength(2)
})
@@ -74,6 +81,7 @@ describe("WorkspaceTracker", () => {
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: ["newfile.ts"],
openedTabs: [],
})
})
@@ -92,6 +100,7 @@ describe("WorkspaceTracker", () => {
expect(mockProvider.postMessageToWebview).toHaveBeenLastCalledWith({
type: "workspaceUpdated",
filePaths: [],
openedTabs: [],
})
})
@@ -106,6 +115,7 @@ describe("WorkspaceTracker", () => {
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: expect.arrayContaining(["newdir"]),
openedTabs: [],
})
const lastCall = (mockProvider.postMessageToWebview as jest.Mock).mock.calls.slice(-1)[0]
expect(lastCall[0].filePaths).toHaveLength(1)
@@ -126,6 +136,7 @@ describe("WorkspaceTracker", () => {
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: expect.arrayContaining(expectedFiles),
openedTabs: [],
})
expect(calls[0][0].filePaths).toHaveLength(1000)

View File

@@ -50,13 +50,18 @@ export interface ExtensionMessage {
| "historyButtonClicked"
| "promptsButtonClicked"
| "didBecomeVisible"
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
state?: ExtensionState
images?: string[]
ollamaModels?: string[]
lmStudioModels?: string[]
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
filePaths?: string[]
openedTabs?: Array<{
label: string
isActive: boolean
path?: string
}>
partialMessage?: ClineMessage
glamaModels?: Record<string, ModelInfo>
openRouterModels?: Record<string, ModelInfo>
@@ -94,6 +99,7 @@ export interface ExtensionState {
alwaysApproveResubmit?: boolean
alwaysAllowModeSwitch?: boolean
requestDelaySeconds: number
rateLimitSeconds: number // Minimum time between successive requests (0 = disabled)
uriScheme?: string
allowedCommands?: string[]
soundEnabled?: boolean
@@ -106,6 +112,7 @@ export interface ExtensionState {
writeDelayMs: number
terminalOutputLineLimit?: number
mcpEnabled: boolean
enableMcpServerCreation: boolean
mode: Mode
modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string

View File

@@ -62,10 +62,12 @@ export interface WebviewMessage {
| "deleteMessage"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "enableMcpServerCreation"
| "searchCommits"
| "refreshGlamaModels"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "rateLimitSeconds"
| "setApiConfigPassword"
| "requestVsCodeLmModels"
| "mode"

View File

@@ -80,6 +80,7 @@ export interface ModelInfo {
cacheWritesPrice?: number
cacheReadsPrice?: number
description?: string
reasoningEffort?: "low" | "medium" | "high"
}
// Anthropic
@@ -510,6 +511,33 @@ export type OpenAiNativeModelId = keyof typeof openAiNativeModels
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-4o"
export const openAiNativeModels = {
// don't support tool use yet
"o3-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.1,
outputPrice: 4.4,
reasoningEffort: "medium",
},
"o3-mini-high": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.1,
outputPrice: 4.4,
reasoningEffort: "high",
},
"o3-mini-low": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.1,
outputPrice: 4.4,
reasoningEffort: "low",
},
o1: {
maxTokens: 100_000,
contextWindow: 200_000,
@@ -531,8 +559,8 @@ export const openAiNativeModels = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3,
outputPrice: 12,
inputPrice: 1.1,
outputPrice: 4.4,
},
"gpt-4o": {
maxTokens: 4_096,

View File

@@ -18,8 +18,8 @@ export const createPrompt = (template: string, params: PromptParams): string =>
}
}
// Replace any remaining user_input placeholders with empty string
result = result.replaceAll("${userInput}", "")
// Replace any remaining placeholders with empty strings
result = result.replaceAll(/\${[^}]*}/g, "")
return result
}
@@ -42,7 +42,7 @@ const supportPromptConfigs: Record<string, SupportPromptConfig> = {
EXPLAIN: {
label: "Explain Code",
description:
"Get detailed explanations of code snippets, functions, or entire files. Useful for understanding complex code or learning new patterns. Available in the editor context menu (right-click on selected code).",
"Get detailed explanations of code snippets, functions, or entire files. Useful for understanding complex code or learning new patterns. Available in code actions (lightbulb icon in the editor) and the editor context menu (right-click on selected code).",
template: `Explain the following code from file path @/\${filePath}:
\${userInput}
@@ -58,7 +58,7 @@ Please provide a clear and concise explanation of what this code does, including
FIX: {
label: "Fix Issues",
description:
"Get help identifying and resolving bugs, errors, or code quality issues. Provides step-by-step guidance for fixing problems. Available in the editor context menu (right-click on selected code).",
"Get help identifying and resolving bugs, errors, or code quality issues. Provides step-by-step guidance for fixing problems. Available in code actions (lightbulb icon in the editor) and the editor context menu (right-click on selected code).",
template: `Fix any issues in the following code from file path @/\${filePath}
\${diagnosticText}
\${userInput}
@@ -76,7 +76,7 @@ Please:
IMPROVE: {
label: "Improve Code",
description:
"Receive suggestions for code optimization, better practices, and architectural improvements while maintaining functionality. Available in the editor context menu (right-click on selected code).",
"Receive suggestions for code optimization, better practices, and architectural improvements while maintaining functionality. Available in code actions (lightbulb icon in the editor) and the editor context menu (right-click on selected code).",
template: `Improve the following code from file path @/\${filePath}:
\${userInput}
@@ -92,6 +92,15 @@ Please suggest improvements for:
Provide the improved code along with explanations for each enhancement.`,
},
ADD_TO_CONTEXT: {
label: "Add to Context",
description:
"Add context to your current task or conversation. Useful for providing additional information or clarifications. Available in code actions (lightbulb icon in the editor). and the editor context menu (right-click on selected code).",
template: `@/\${filePath}:
\`\`\`
\${selectedText}
\`\`\``,
},
} as const
type SupportPromptType = keyof typeof supportPromptConfigs

View File

@@ -1,121 +1,18 @@
const assert = require("assert")
const vscode = require("vscode")
const path = require("path")
const fs = require("fs")
const dotenv = require("dotenv")
import * as assert from "assert"
import * as vscode from "vscode"
// Load test environment variables
const testEnvPath = path.join(__dirname, ".test_env")
dotenv.config({ path: testEnvPath })
suite("Roo Code Extension Test Suite", () => {
vscode.window.showInformationMessage("Starting Roo Code extension tests.")
test("Extension should be present", () => {
const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline")
assert.notStrictEqual(extension, undefined)
})
test("Extension should activate", async () => {
const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline")
if (!extension) {
assert.fail("Extension not found")
suite("Roo Code Extension", () => {
test("OPENROUTER_API_KEY environment variable is set", () => {
if (!process.env.OPENROUTER_API_KEY) {
assert.fail("OPENROUTER_API_KEY environment variable is not set")
}
await extension.activate()
assert.strictEqual(extension.isActive, true)
})
test("OpenRouter API key and models should be configured correctly", function (done) {
// @ts-ignore
this.timeout(60000) // Increase timeout to 60s for network requests
;(async () => {
try {
// Get extension instance
const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline")
if (!extension) {
done(new Error("Extension not found"))
return
}
// Verify API key is set and valid
const apiKey = process.env.OPEN_ROUTER_API_KEY
if (!apiKey) {
done(new Error("OPEN_ROUTER_API_KEY environment variable is not set"))
return
}
if (!apiKey.startsWith("sk-or-v1-")) {
done(new Error("OpenRouter API key should have correct format"))
return
}
// Activate extension and get provider
const api = await extension.activate()
if (!api) {
done(new Error("Extension API not found"))
return
}
// Get the provider from the extension's exports
const provider = api.sidebarProvider
if (!provider) {
done(new Error("Provider not found"))
return
}
// Set up the API configuration
await provider.updateGlobalState("apiProvider", "openrouter")
await provider.storeSecret("openRouterApiKey", apiKey)
// Set up timeout to fail test if models don't load
const timeout = setTimeout(() => {
done(new Error("Timeout waiting for models to load"))
}, 30000)
// Wait for models to be loaded
const checkModels = setInterval(async () => {
try {
const models = await provider.readOpenRouterModels()
if (!models) {
return
}
clearInterval(checkModels)
clearTimeout(timeout)
// Verify expected Claude models are available
const expectedModels = [
"anthropic/claude-3.5-sonnet:beta",
"anthropic/claude-3-sonnet:beta",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-sonnet-20240620",
"anthropic/claude-3.5-sonnet-20240620:beta",
"anthropic/claude-3.5-haiku:beta",
]
for (const modelId of expectedModels) {
assert.strictEqual(modelId in models, true, `Model ${modelId} should be available`)
}
done()
} catch (error) {
clearInterval(checkModels)
clearTimeout(timeout)
done(error)
}
}, 1000)
// Trigger model loading
await provider.refreshOpenRouterModels()
} catch (error) {
done(error)
}
})()
})
test("Commands should be registered", async () => {
const commands = await vscode.commands.getCommands(true)
const timeout = 10 * 1_000
const interval = 1_000
const startTime = Date.now()
// Test core commands are registered
const expectedCommands = [
"roo-cline.plusButtonClicked",
"roo-cline.mcpButtonClicked",
@@ -128,204 +25,39 @@ suite("Roo Code Extension Test Suite", () => {
"roo-cline.improveCode",
]
while (Date.now() - startTime < timeout) {
const commands = await vscode.commands.getCommands(true)
const missingCommands = []
for (const cmd of expectedCommands) {
if (!commands.includes(cmd)) {
missingCommands.push(cmd)
}
}
if (missingCommands.length === 0) {
break
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
const commands = await vscode.commands.getCommands(true)
for (const cmd of expectedCommands) {
assert.strictEqual(commands.includes(cmd), true, `Command ${cmd} should be registered`)
assert.ok(commands.includes(cmd), `Command ${cmd} should be registered`)
}
})
test("Views should be registered", () => {
test("Webview panel can be created", () => {
const view = vscode.window.createWebviewPanel(
"roo-cline.SidebarProvider",
"Roo Code",
vscode.ViewColumn.One,
{},
)
assert.notStrictEqual(view, undefined)
assert.ok(view, "Failed to create webview panel")
view.dispose()
})
test("Should handle prompt and response correctly", async function () {
// @ts-ignore
this.timeout(60000) // Increase timeout for API request
const timeout = 30000
const interval = 1000
// Get extension instance
const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline")
if (!extension) {
assert.fail("Extension not found")
return
}
// Activate extension and get API
const api = await extension.activate()
if (!api) {
assert.fail("Extension API not found")
return
}
// Get provider
const provider = api.sidebarProvider
if (!provider) {
assert.fail("Provider not found")
return
}
// Set up API configuration
await provider.updateGlobalState("apiProvider", "openrouter")
await provider.updateGlobalState("openRouterModelId", "anthropic/claude-3.5-sonnet")
const apiKey = process.env.OPEN_ROUTER_API_KEY
if (!apiKey) {
assert.fail("OPEN_ROUTER_API_KEY environment variable is not set")
return
}
await provider.storeSecret("openRouterApiKey", apiKey)
// Create webview panel with development options
const extensionUri = extension.extensionUri
const panel = vscode.window.createWebviewPanel("roo-cline.SidebarProvider", "Roo Code", vscode.ViewColumn.One, {
enableScripts: true,
enableCommandUris: true,
retainContextWhenHidden: true,
localResourceRoots: [extensionUri],
})
try {
// Initialize webview with development context
panel.webview.options = {
enableScripts: true,
enableCommandUris: true,
localResourceRoots: [extensionUri],
}
// Initialize provider with panel
provider.resolveWebviewView(panel)
// Set up message tracking
let webviewReady = false
let messagesReceived = false
const originalPostMessage = provider.postMessageToWebview.bind(provider)
// @ts-ignore
provider.postMessageToWebview = async (message) => {
if (message.type === "state") {
webviewReady = true
console.log("Webview state received:", message)
if (message.state?.clineMessages?.length > 0) {
messagesReceived = true
console.log("Messages in state:", message.state.clineMessages)
}
}
await originalPostMessage(message)
}
// Wait for webview to launch and receive initial state
let startTime = Date.now()
while (Date.now() - startTime < timeout) {
if (webviewReady) {
// Wait an additional second for webview to fully initialize
await new Promise((resolve) => setTimeout(resolve, 1000))
break
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
if (!webviewReady) {
throw new Error("Timeout waiting for webview to be ready")
}
// Send webviewDidLaunch to initialize chat
await provider.postMessageToWebview({ type: "webviewDidLaunch" })
console.log("Sent webviewDidLaunch")
// Wait for webview to fully initialize
await new Promise((resolve) => setTimeout(resolve, 2000))
// Restore original postMessage
provider.postMessageToWebview = originalPostMessage
// Wait for OpenRouter models to be fully loaded
startTime = Date.now()
while (Date.now() - startTime < timeout) {
const models = await provider.readOpenRouterModels()
if (models && Object.keys(models).length > 0) {
console.log("OpenRouter models loaded")
break
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
// Send prompt
const prompt = "Hello world, what is your name?"
console.log("Sending prompt:", prompt)
// Start task
try {
await api.startNewTask(prompt)
console.log("Task started")
} catch (error) {
console.error("Error starting task:", error)
throw error
}
// Wait for task to appear in history with tokens
startTime = Date.now()
while (Date.now() - startTime < timeout) {
const state = await provider.getState()
const task = state.taskHistory?.[0]
if (task && task.tokensOut > 0) {
console.log("Task completed with tokens:", task)
break
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
// Wait for messages to be processed
startTime = Date.now()
let responseReceived = false
while (Date.now() - startTime < timeout) {
// Check provider.clineMessages
const messages = provider.clineMessages
if (messages && messages.length > 0) {
console.log("Provider messages:", JSON.stringify(messages, null, 2))
// @ts-ignore
const hasResponse = messages.some(
(m: { type: string; text: string }) =>
m.type === "say" && m.text && m.text.toLowerCase().includes("cline"),
)
if (hasResponse) {
console.log('Found response containing "Cline" in provider messages')
responseReceived = true
break
}
}
// Check provider.cline.clineMessages
const clineMessages = provider.cline?.clineMessages
if (clineMessages && clineMessages.length > 0) {
console.log("Cline messages:", JSON.stringify(clineMessages, null, 2))
// @ts-ignore
const hasResponse = clineMessages.some(
(m: { type: string; text: string }) =>
m.type === "say" && m.text && m.text.toLowerCase().includes("cline"),
)
if (hasResponse) {
console.log('Found response containing "Cline" in cline messages')
responseReceived = true
break
}
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
if (!responseReceived) {
console.log("Final provider state:", await provider.getState())
console.log("Final cline messages:", provider.cline?.clineMessages)
throw new Error('Did not receive expected response containing "Cline"')
}
} finally {
panel.dispose()
}
})
})

77
src/test/task.test.ts Normal file
View File

@@ -0,0 +1,77 @@
import * as assert from "assert"
import * as vscode from "vscode"
import { ClineAPI } from "../exports/cline"
import { ClineProvider } from "../core/webview/ClineProvider"
suite("Roo Code Task", () => {
test("Should handle prompt and response correctly", async function () {
const timeout = 30000
const interval = 1000
const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline")
if (!extension) {
assert.fail("Extension not found")
}
const api: ClineAPI = await extension.activate()
const provider = api.sidebarProvider as ClineProvider
await provider.updateGlobalState("apiProvider", "openrouter")
await provider.updateGlobalState("openRouterModelId", "anthropic/claude-3.5-sonnet")
await provider.storeSecret("openRouterApiKey", process.env.OPENROUTER_API_KEY || "sk-or-v1-fake-api-key")
// Create webview panel with development options.
const panel = vscode.window.createWebviewPanel("roo-cline.SidebarProvider", "Roo Code", vscode.ViewColumn.One, {
enableScripts: true,
enableCommandUris: true,
retainContextWhenHidden: true,
localResourceRoots: [extension.extensionUri],
})
try {
// Initialize provider with panel.
provider.resolveWebviewView(panel)
// Wait for webview to launch.
let startTime = Date.now()
while (Date.now() - startTime < timeout) {
if (provider.viewLaunched) {
break
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
await api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'")
// Wait for task to appear in history with tokens.
startTime = Date.now()
while (Date.now() - startTime < timeout) {
const state = await provider.getState()
const task = state.taskHistory?.[0]
if (task && task.tokensOut > 0) {
break
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
if (provider.messages.length === 0) {
assert.fail("No messages received")
}
// console.log("Provider messages:", JSON.stringify(provider.messages, null, 2))
assert.ok(
provider.messages.some(({ type, text }) => type === "say" && text?.includes("My name is Roo")),
"Did not receive expected response containing 'My name is Roo'",
)
} finally {
panel.dispose()
}
})
})

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"lib": ["ES2020"],
"sourceMap": true,
"rootDir": "../..",
"strict": false,
"noImplicitAny": false,
"noImplicitThis": false,
"alwaysStrict": false,
"skipLibCheck": true,
"baseUrl": "../..",
"paths": {
"*": ["*", "src/*"]
}
},
"exclude": ["node_modules", ".vscode-test"]
}

View File

@@ -99,3 +99,8 @@ export function getReadablePath(cwd: string, relPath?: string): string {
}
}
}
export const toRelativePath = (filePath: string, cwd: string) => {
const relativePath = path.relative(cwd, filePath).toPosix()
return filePath.endsWith("/") ? relativePath + "/" : relativePath
}