Merge remote-tracking branch 'origin/main' into feature/add_sse_mcp

This commit is contained in:
Matt Rubens 2025-03-10 10:47:26 -04:00
commit 89b8b10bba
59 changed files with 829 additions and 292 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Update GitHub Actions workflow to automatically create and push git tags during release

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
App tab layout fixes

View file

@ -1,5 +0,0 @@
---
"roo-cline": patch
---
Add o3-mini support to openai compatible

View file

@ -1,2 +1 @@
# PostHog API Keys for telemetry
POSTHOG_API_KEY=key-goes-here
POSTHOG_API_KEY=key-goes-here

View file

@ -10,6 +10,8 @@ env:
jobs:
publish-extension:
runs-on: ubuntu-latest
permissions:
contents: write # Required for pushing tags
if: >
( github.event_name == 'pull_request' &&
github.event.pull_request.base.ref == 'main' &&
@ -23,24 +25,24 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 18
- run: |
git config user.name github-actions
git config user.email github-actions@github.com
- name: Install Dependencies
run: |
npm install -g vsce ovsx
npm run install:ci
- name: Create .env file
run: |
echo "# PostHog API Keys for telemetry" > .env
echo "POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}" >> .env
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
- name: Package Extension
run: |
current_package_version=$(node -p "require('./package.json').version")
npm run vsix
package=$(unzip -l bin/roo-cline-${current_package_version}.vsix)
echo "$package"
@ -49,5 +51,18 @@ jobs:
echo "$package" | grep -q "extension/node_modules/@vscode/codicons/dist/codicon.ttf" || exit 1
echo "$package" | grep -q ".env" || exit 1
- name: Create and Push Git Tag
run: |
current_package_version=$(node -p "require('./package.json').version")
git tag -a "v${current_package_version}" -m "Release v${current_package_version}"
git push origin "v${current_package_version}"
echo "Successfully created and pushed git tag v${current_package_version}"
- name: Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
current_package_version=$(node -p "require('./package.json').version")
npm run publish:marketplace
echo "Successfully published version $current_package_version to VS Code Marketplace"

View file

@ -1,5 +1,22 @@
# Roo Code Changelog
## [3.8.4] - 2025-03-09
- Roll back multi-diff progress indicator temporarily to fix a double-confirmation in saving edits
- Add an option in the prompts tab to save tokens by disabling the ability to ask Roo to create/edit custom modes for you (thanks @hannesrudolph!)
## [3.8.3] - 2025-03-09
- Fix VS Code LM API model picker truncation issue
## [3.8.2] - 2025-03-08
- Create an auto-approval toggle for subtask creation and completion (thanks @shaybc!)
- Show a progress indicator when using the multi-diff editing strategy (thanks @qdaxb!)
- Add o3-mini support to the OpenAI-compatible provider (thanks @yt3trees!)
- Fix encoding issue where unreadable characters were sometimes getting added to the beginning of files
- Fix issue where settings dropdowns were getting truncated in some cases
## [3.8.1] - 2025-03-07
- Show the reserved output tokens in the context window visualization

View file

@ -52,6 +52,7 @@ const copyWasmFiles = {
"java",
"php",
"swift",
"kotlin",
]
languages.forEach((lang) => {

View file

@ -30,9 +30,10 @@ module.exports = {
"^strip-ansi$": "<rootDir>/src/__mocks__/strip-ansi.js",
"^default-shell$": "<rootDir>/src/__mocks__/default-shell.js",
"^os-name$": "<rootDir>/src/__mocks__/os-name.js",
"^strip-bom$": "<rootDir>/src/__mocks__/strip-bom.js",
},
transformIgnorePatterns: [
"node_modules/(?!(@modelcontextprotocol|delay|p-wait-for|globby|serialize-error|strip-ansi|default-shell|os-name)/)",
"node_modules/(?!(@modelcontextprotocol|delay|p-wait-for|globby|serialize-error|strip-ansi|default-shell|os-name|strip-bom)/)",
],
roots: ["<rootDir>/src", "<rootDir>/webview-ui/src"],
modulePathIgnorePatterns: [".vscode-test"],

27
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "roo-cline",
"version": "3.8.1",
"version": "3.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
"version": "3.8.1",
"version": "3.8.4",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.37.0",
@ -52,6 +52,7 @@
"sound-play": "^1.1.0",
"string-similarity": "^4.0.4",
"strip-ansi": "^7.1.0",
"strip-bom": "^5.0.0",
"tmp": "^0.2.3",
"tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0",
@ -11971,6 +11972,15 @@
"node": ">=8"
}
},
"node_modules/jest-runtime/node_modules/strip-bom": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
"integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/jest-simple-dot-reporter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/jest-simple-dot-reporter/-/jest-simple-dot-reporter-1.0.5.tgz",
@ -15653,13 +15663,14 @@
}
},
"node_modules/strip-bom": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
"integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
"dev": true,
"license": "MIT",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-5.0.0.tgz",
"integrity": "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==",
"engines": {
"node": ">=8"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-final-newline": {

View file

@ -3,7 +3,7 @@
"displayName": "Roo Code (prev. Roo Cline)",
"description": "A whole dev team of AI agents in your editor.",
"publisher": "RooVeterinaryInc",
"version": "3.8.1",
"version": "3.8.4",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",
@ -305,6 +305,7 @@
"sound-play": "^1.1.0",
"string-similarity": "^4.0.4",
"strip-ansi": "^7.1.0",
"strip-bom": "^5.0.0",
"tmp": "^0.2.3",
"tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0",

View file

@ -0,0 +1,13 @@
// Mock implementation of strip-bom
module.exports = function stripBom(string) {
if (typeof string !== "string") {
throw new TypeError("Expected a string")
}
// Removes UTF-8 BOM
if (string.charCodeAt(0) === 0xfeff) {
return string.slice(1)
}
return string
}

View file

@ -22,8 +22,10 @@ describe("RequestyHandler", () => {
contextWindow: 4000,
supportsPromptCache: false,
supportsImages: true,
inputPrice: 0,
outputPrice: 0,
inputPrice: 1,
outputPrice: 10,
cacheReadsPrice: 0.1,
cacheWritesPrice: 1.5,
},
openAiStreamingEnabled: true,
includeMaxTokens: true, // Add this to match the implementation
@ -83,8 +85,12 @@ describe("RequestyHandler", () => {
yield {
choices: [{ delta: { content: " world" } }],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
prompt_tokens: 30,
completion_tokens: 10,
prompt_tokens_details: {
cached_tokens: 15,
caching_tokens: 5,
},
},
}
},
@ -105,10 +111,11 @@ describe("RequestyHandler", () => {
{ type: "text", text: " world" },
{
type: "usage",
inputTokens: 10,
outputTokens: 5,
cacheWriteTokens: undefined,
cacheReadTokens: undefined,
inputTokens: 30,
outputTokens: 10,
cacheWriteTokens: 5,
cacheReadTokens: 15,
totalCost: 0.000119, // (10 * 1 / 1,000,000) + (5 * 1.5 / 1,000,000) + (15 * 0.1 / 1,000,000) + (10 * 10 / 1,000,000)
},
])
@ -182,6 +189,9 @@ describe("RequestyHandler", () => {
type: "usage",
inputTokens: 10,
outputTokens: 5,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: 0.00006, // (10 * 1 / 1,000,000) + (5 * 10 / 1,000,000)
},
])

View file

@ -116,7 +116,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
}
if (chunk.usage) {
yield this.processUsageMetrics(chunk.usage)
yield this.processUsageMetrics(chunk.usage, modelInfo)
}
}
} else {
@ -139,11 +139,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
type: "text",
text: response.choices[0]?.message.content || "",
}
yield this.processUsageMetrics(response.usage)
yield this.processUsageMetrics(response.usage, modelInfo)
}
}
protected processUsageMetrics(usage: any): ApiStreamUsageChunk {
protected processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk {
return {
type: "usage",
inputTokens: usage?.prompt_tokens || 0,

View file

@ -1,9 +1,20 @@
import axios from "axios"
import { ModelInfo, requestyModelInfoSaneDefaults, requestyDefaultModelId } from "../../shared/api"
import { parseApiPrice } from "../../utils/cost"
import { calculateApiCostOpenAI, parseApiPrice } from "../../utils/cost"
import { ApiStreamUsageChunk } from "../transform/stream"
import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
import OpenAI from "openai"
// Requesty usage includes an extra field for Anthropic use cases.
// Safely cast the prompt token details section to the appropriate structure.
interface RequestyUsage extends OpenAI.CompletionUsage {
prompt_tokens_details?: {
caching_tokens?: number
cached_tokens?: number
}
total_cost?: number
}
export class RequestyHandler extends OpenAiHandler {
constructor(options: OpenAiHandlerOptions) {
@ -27,13 +38,22 @@ export class RequestyHandler extends OpenAiHandler {
}
}
protected override processUsageMetrics(usage: any): ApiStreamUsageChunk {
protected override processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk {
const requestyUsage = usage as RequestyUsage
const inputTokens = requestyUsage?.prompt_tokens || 0
const outputTokens = requestyUsage?.completion_tokens || 0
const cacheWriteTokens = requestyUsage?.prompt_tokens_details?.caching_tokens || 0
const cacheReadTokens = requestyUsage?.prompt_tokens_details?.cached_tokens || 0
const totalCost = modelInfo
? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
: 0
return {
type: "usage",
inputTokens: usage?.prompt_tokens || 0,
outputTokens: usage?.completion_tokens || 0,
cacheWriteTokens: usage?.cache_creation_input_tokens,
cacheReadTokens: usage?.cache_read_input_tokens,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
}

View file

@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { SingleCompletionHandler } from "../"
import { calculateApiCost } from "../../utils/cost"
import { calculateApiCostAnthropic } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"
import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
@ -462,7 +462,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
totalCost: calculateApiCost(this.getModel().info, totalInputTokens, totalOutputTokens),
totalCost: calculateApiCostAnthropic(this.getModel().info, totalInputTokens, totalOutputTokens),
}
} catch (error: unknown) {
this.ensureCleanState()

View file

@ -55,7 +55,7 @@ import { ClineAskResponse } from "../shared/WebviewMessage"
import { GlobalFileNames } from "../shared/globalFileNames"
import { defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments"
import { calculateApiCost } from "../utils/cost"
import { calculateApiCostAnthropic } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path"
import { parseMentions } from "./mentions"
@ -875,7 +875,7 @@ export class Cline {
// The way this agentic loop works is that cline will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task.
// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Cline is prompted to finish the task as efficiently as he can.
//const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
//const totalCost = this.calculateApiCostAnthropic(totalInputTokens, totalOutputTokens)
if (didEndLoop) {
// For now a task never 'completes'. This will only happen if the user hits max requests and denies resetting the count.
//this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
@ -1417,6 +1417,18 @@ export class Cline {
return true
}
const askFinishSubTaskApproval = async () => {
// ask the user to approve this task has completed, and he has reviewd it, and we can declare task is finished
// and return control to the parent task to continue running the rest of the sub-tasks
const toolMessage = JSON.stringify({
tool: "finishTask",
content:
"Subtask completed! You can review the results and suggest any corrections or next steps. If everything looks good, confirm to return the result to the parent task.",
})
return await askApproval("tool", toolMessage)
}
const handleError = async (action: string, error: Error) => {
const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}`
await this.say(
@ -2945,13 +2957,6 @@ export class Cline {
// havent sent a command message yet so first send completion_result then command
await this.say("completion_result", result, undefined, false)
telemetryService.captureTaskCompleted(this.taskId)
if (this.isSubTask) {
// tell the provider to remove the current subtask and resume the previous task in the stack
await this.providerRef
.deref()
?.finishSubTask(`Task complete: ${lastMessage?.text}`)
break
}
}
// complete command message
@ -2970,13 +2975,17 @@ export class Cline {
} else {
await this.say("completion_result", result, undefined, false)
telemetryService.captureTaskCompleted(this.taskId)
if (this.isSubTask) {
// tell the provider to remove the current subtask and resume the previous task in the stack
await this.providerRef
.deref()
?.finishSubTask(`Task complete: ${lastMessage?.text}`)
}
if (this.isSubTask) {
const didApprove = await askFinishSubTaskApproval()
if (!didApprove) {
break
}
// tell the provider to remove the current subtask and resume the previous task in the stack
await this.providerRef.deref()?.finishSubTask(`Task complete: ${lastMessage?.text}`)
break
}
// we already sent completion_result says, an empty string asks relinquishes control over button and field
@ -3164,7 +3173,7 @@ export class Cline {
cacheReads: cacheReadTokens,
cost:
totalCost ??
calculateApiCost(
calculateApiCostAnthropic(
this.api.getModel().info,
inputTokens,
outputTokens,
@ -3789,6 +3798,8 @@ export class Cline {
return
}
telemetryService.captureCheckpointDiffed(this.taskId)
if (!previousCommitHash && mode === "checkpoint") {
const previousCheckpoint = this.clineMessages
.filter(({ say }) => say === "checkpoint_saved")
@ -3840,6 +3851,8 @@ export class Cline {
return
}
telemetryService.captureCheckpointCreated(this.taskId)
// Start the checkpoint process in the background.
service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`).catch((err) => {
console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
@ -3871,6 +3884,8 @@ export class Cline {
try {
await service.restoreCheckpoint(commitHash)
telemetryService.captureCheckpointRestored(this.taskId)
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
if (mode === "restore") {

View file

@ -198,9 +198,9 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
}
}
function getWorkspaceProblems(cwd: string): string {
async function getWorkspaceProblems(cwd: string): Promise<string> {
const diagnostics = vscode.languages.getDiagnostics()
const result = diagnosticsToProblemsString(
const result = await diagnosticsToProblemsString(
diagnostics,
[vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning],
cwd,

View file

@ -11,12 +11,19 @@ export async function getModesSection(context: vscode.ExtensionContext): Promise
// Get all modes with their overrides from extension state
const allModes = await getAllModesWithPrompts(context)
return `====
// Get enableCustomModeCreation setting from extension state
const shouldEnableCustomModeCreation = (await context.globalState.get<boolean>("enableCustomModeCreation")) ?? true
let modesContent = `====
MODES
- These are the currently available modes:
${allModes.map((mode: ModeConfig) => ` * "${mode.name}" mode (${mode.slug}) - ${mode.roleDefinition.split(".")[0]}`).join("\n")}
${allModes.map((mode: ModeConfig) => ` * "${mode.name}" mode (${mode.slug}) - ${mode.roleDefinition.split(".")[0]}`).join("\n")}`
// Only include custom modes documentation if the feature is enabled
if (shouldEnableCustomModeCreation) {
modesContent += `
- Custom modes can be configured in two ways:
1. Globally via '${customModesPath}' (created automatically on startup)
@ -56,4 +63,7 @@ Both files should follow this structure:
}
]
}`
}
return modesContent
}

View file

@ -984,6 +984,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("alwaysAllowModeSwitch", message.bool)
await this.postStateToWebview()
break
case "alwaysAllowSubtasks":
await this.updateGlobalState("alwaysAllowSubtasks", message.bool)
await this.postStateToWebview()
break
case "askResponse":
this.getCurrentCline()?.handleWebviewAskResponse(
message.askResponse!,
@ -993,9 +997,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "clearTask":
// clear task resets the current session and allows for a new task to be started, if this session is a subtask - it allows the parent task to be resumed
await this.finishSubTask(
`new_task finished with an error!, it was stopped and canceled by the user.`,
)
await this.finishSubTask(`Task error: It was stopped and canceled by the user.`)
await this.postStateToWebview()
break
case "didShowAnnouncement":
@ -1476,6 +1478,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("enhancementApiConfigId", message.text)
await this.postStateToWebview()
break
case "enableCustomModeCreation":
await this.updateGlobalState("enableCustomModeCreation", message.bool ?? true)
await this.postStateToWebview()
break
case "autoApprovalEnabled":
await this.updateGlobalState("autoApprovalEnabled", message.bool ?? false)
await this.postStateToWebview()
@ -2177,6 +2183,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowBrowser,
alwaysAllowMcp,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
soundEnabled,
diffEnabled,
enableCheckpoints,
@ -2224,6 +2231,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
alwaysAllowMcp: alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.getCurrentCline()?.taskId
? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId)
@ -2385,6 +2393,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false,
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false,
taskHistory: stateValues.taskHistory,
allowedCommands: stateValues.allowedCommands,
soundEnabled: stateValues.soundEnabled ?? false,
@ -2558,6 +2567,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
properties.apiProvider = apiConfiguration.apiProvider
}
// Add model ID if available
const currentCline = this.getCurrentCline()
if (currentCline?.api) {
const { id: modelId } = currentCline.api.getModel()
if (modelId) {
properties.modelId = modelId
}
}
return properties
}
}

View file

@ -1652,3 +1652,62 @@ describe("ContextProxy integration", () => {
expect(mockContextProxy.setValues).toBeDefined()
})
})
describe("getTelemetryProperties", () => {
let provider: ClineProvider
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockCline: any
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Setup basic mocks
mockContext = {
globalState: {
get: jest.fn().mockImplementation((key: string) => {
if (key === "mode") return "code"
if (key === "apiProvider") return "anthropic"
return undefined
}),
update: jest.fn(),
keys: jest.fn().mockReturnValue([]),
},
secrets: { get: jest.fn(), store: jest.fn(), delete: jest.fn() },
extensionUri: {} as vscode.Uri,
globalStorageUri: { fsPath: "/test/path" },
extension: { packageJSON: { version: "1.0.0" } },
} as unknown as vscode.ExtensionContext
mockOutputChannel = { appendLine: jest.fn() } as unknown as vscode.OutputChannel
provider = new ClineProvider(mockContext, mockOutputChannel)
// Setup Cline instance with mocked getModel method
const { Cline } = require("../../Cline")
mockCline = new Cline()
mockCline.api = {
getModel: jest.fn().mockReturnValue({
id: "claude-3-7-sonnet-20250219",
info: { contextWindow: 200000 },
}),
}
})
test("includes basic properties in telemetry", async () => {
const properties = await provider.getTelemetryProperties()
expect(properties).toHaveProperty("vscodeVersion")
expect(properties).toHaveProperty("platform")
expect(properties).toHaveProperty("appVersion", "1.0.0")
})
test("includes model ID from current Cline instance if available", async () => {
// Add mock Cline to stack
await provider.addClineToStack(mockCline)
const properties = await provider.getTelemetryProperties()
expect(properties).toHaveProperty("modelId", "claude-3-7-sonnet-20250219")
})
})

View file

@ -70,11 +70,12 @@ export function getNewDiagnostics(
// // - New error in file3 (1:1)
// will return empty string if no problems with the given severity are found
export function diagnosticsToProblemsString(
export async function diagnosticsToProblemsString(
diagnostics: [vscode.Uri, vscode.Diagnostic[]][],
severities: vscode.DiagnosticSeverity[],
cwd: string,
): string {
): Promise<string> {
const documents = new Map<vscode.Uri, vscode.TextDocument>()
let result = ""
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
@ -100,7 +101,10 @@ export function diagnosticsToProblemsString(
}
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
const document = documents.get(uri) || (await vscode.workspace.openTextDocument(uri))
documents.set(uri, document)
const lineContent = document.lineAt(diagnostic.range.start.line).text
result += `\n- [${source}${label}] ${line} | ${lineContent} : ${diagnostic.message}`
}
}
}

View file

@ -7,6 +7,7 @@ import { formatResponse } from "../../core/prompts/responses"
import { DecorationController } from "./DecorationController"
import * as diff from "diff"
import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics"
import stripBom from "strip-bom"
export const DIFF_VIEW_URI_SCHEME = "cline-diff"
@ -104,7 +105,7 @@ export class DiffViewProvider {
const edit = new vscode.WorkspaceEdit()
const rangeToReplace = new vscode.Range(0, 0, endLine + 1, 0)
const contentToReplace = accumulatedLines.slice(0, endLine + 1).join("\n") + "\n"
edit.replace(document.uri, rangeToReplace, contentToReplace)
edit.replace(document.uri, rangeToReplace, this.stripAllBOMs(contentToReplace))
await vscode.workspace.applyEdit(edit)
// Update decorations
this.activeLineController.setActiveLine(endLine)
@ -128,7 +129,11 @@ export class DiffViewProvider {
}
// Apply the final content
const finalEdit = new vscode.WorkspaceEdit()
finalEdit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), accumulatedContent)
finalEdit.replace(
document.uri,
new vscode.Range(0, 0, document.lineCount, 0),
this.stripAllBOMs(accumulatedContent),
)
await vscode.workspace.applyEdit(finalEdit)
// Clear all decorations at the end (after applying final edit)
this.fadedOverlayController.clear()
@ -172,7 +177,7 @@ export class DiffViewProvider {
initial fix is usually correct and it may just take time for linters to catch up.
*/
const postDiagnostics = vscode.languages.getDiagnostics()
const newProblems = diagnosticsToProblemsString(
const newProblems = await diagnosticsToProblemsString(
getNewDiagnostics(this.preDiagnostics, postDiagnostics),
[
vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
@ -336,6 +341,16 @@ export class DiffViewProvider {
}
}
private stripAllBOMs(input: string): string {
let result = input
let previous
do {
previous = result
result = stripBom(result)
} while (result !== previous)
return result
}
// close editor if open?
async reset() {
this.editType = undefined

View file

@ -34,7 +34,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
"pkg",
"Pods",
".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories.
].map((dir) => `**/${dir}/**`)
].map((dir) => `${dirPath}/**/${dir}/**`)
const options = {
cwd: dirPath,

View file

@ -22,6 +22,9 @@ class PostHogClient {
CONVERSATION_MESSAGE: "Conversation Message",
MODE_SWITCH: "Mode Switched",
TOOL_USED: "Tool Used",
CHECKPOINT_CREATED: "Checkpoint Created",
CHECKPOINT_RESTORED: "Checkpoint Restored",
CHECKPOINT_DIFFED: "Checkpoint Diffed",
},
}
@ -246,6 +249,18 @@ class TelemetryService {
})
}
public captureCheckpointCreated(taskId: string): void {
this.captureEvent(PostHogClient.EVENTS.TASK.CHECKPOINT_CREATED, { taskId })
}
public captureCheckpointDiffed(taskId: string): void {
this.captureEvent(PostHogClient.EVENTS.TASK.CHECKPOINT_DIFFED, { taskId })
}
public captureCheckpointRestored(taskId: string): void {
this.captureEvent(PostHogClient.EVENTS.TASK.CHECKPOINT_RESTORED, { taskId })
}
/**
* Checks if telemetry is currently enabled
* @returns Whether telemetry is enabled

View file

@ -169,6 +169,8 @@ describe("Tree-sitter Service", () => {
"/test/path/main.rs",
"/test/path/program.cpp",
"/test/path/code.go",
"/test/path/app.kt",
"/test/path/script.kts",
]
;(listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()])
@ -197,6 +199,8 @@ describe("Tree-sitter Service", () => {
rs: { parser: mockParser, query: mockQuery },
cpp: { parser: mockParser, query: mockQuery },
go: { parser: mockParser, query: mockQuery },
kt: { parser: mockParser, query: mockQuery },
kts: { parser: mockParser, query: mockQuery },
})
;(fs.readFile as jest.Mock).mockResolvedValue("function test() {}")
@ -207,6 +211,8 @@ describe("Tree-sitter Service", () => {
expect(result).toContain("main.rs")
expect(result).toContain("program.cpp")
expect(result).toContain("code.go")
expect(result).toContain("app.kt")
expect(result).toContain("script.kts")
})
it("should normalize paths in output", async () => {

View file

@ -92,6 +92,17 @@ describe("Language Parser", () => {
expect(parsers.hpp).toBeDefined()
})
it("should handle Kotlin files correctly", async () => {
const files = ["test.kt", "test.kts"]
const parsers = await loadRequiredLanguageParsers(files)
expect(ParserMock.Language.load).toHaveBeenCalledWith(expect.stringContaining("tree-sitter-kotlin.wasm"))
expect(parsers.kt).toBeDefined()
expect(parsers.kts).toBeDefined()
expect(parsers.kt.query).toBeDefined()
expect(parsers.kts.query).toBeDefined()
})
it("should throw error for unsupported file extensions", async () => {
const files = ["test.unsupported"]

View file

@ -80,6 +80,9 @@ function separateFiles(allFiles: string[]): { filesToParse: string[]; remainingF
"java",
"php",
"swift",
// Kotlin
"kt",
"kts",
].map((e) => `.${e}`)
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))

View file

@ -13,6 +13,7 @@ import {
javaQuery,
phpQuery,
swiftQuery,
kotlinQuery,
} from "./queries"
export interface LanguageParser {
@ -120,6 +121,11 @@ export async function loadRequiredLanguageParsers(filesToParse: string[]): Promi
language = await loadLanguage("swift")
query = language.query(swiftQuery)
break
case "kt":
case "kts":
language = await loadLanguage("kotlin")
query = language.query(kotlinQuery)
break
default:
throw new Error(`Unsupported language: ${ext}`)
}

View file

@ -10,3 +10,4 @@ export { default as cQuery } from "./c"
export { default as csharpQuery } from "./c-sharp"
export { default as goQuery } from "./go"
export { default as swiftQuery } from "./swift"
export { default as kotlinQuery } from "./kotlin"

View file

@ -0,0 +1,28 @@
/*
- class declarations (including interfaces)
- function declarations
- object declarations
- property declarations
- type alias declarations
*/
export default `
(class_declaration
(type_identifier) @name.definition.class
) @definition.class
(function_declaration
(simple_identifier) @name.definition.function
) @definition.function
(object_declaration
(type_identifier) @name.definition.object
) @definition.object
(property_declaration
(simple_identifier) @name.definition.property
) @definition.property
(type_alias
(type_identifier) @name.definition.type
) @definition.type
`

View file

@ -109,6 +109,7 @@ export interface ExtensionState {
alwaysAllowMcp?: boolean
alwaysApproveResubmit?: boolean
alwaysAllowModeSwitch?: boolean
alwaysAllowSubtasks?: boolean
browserToolEnabled?: boolean
requestDelaySeconds: number
rateLimitSeconds: number // Minimum time between successive requests (0 = disabled)
@ -128,6 +129,7 @@ export interface ExtensionState {
terminalOutputLimit?: number
mcpEnabled: boolean
enableMcpServerCreation: boolean
enableCustomModeCreation?: boolean
mode: Mode
modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string
@ -168,6 +170,7 @@ export type ClineAsk =
| "mistake_limit_reached"
| "browser_action_launch"
| "use_mcp_server"
| "finishTask"
export type ClineSay =
| "task"
@ -207,6 +210,7 @@ export interface ClineSayTool {
| "searchFiles"
| "switchMode"
| "newTask"
| "finishTask"
path?: string
diff?: string
content?: string

View file

@ -48,6 +48,7 @@ export interface WebviewMessage {
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
| "playSound"
| "soundEnabled"
| "soundVolume"
@ -71,6 +72,7 @@ export interface WebviewMessage {
| "terminalOutputLimit"
| "mcpEnabled"
| "enableMcpServerCreation"
| "enableCustomModeCreation"
| "searchCommits"
| "alwaysApproveResubmit"
| "requestDelaySeconds"

View file

@ -40,6 +40,7 @@ export const GLOBAL_STATE_KEYS = [
"alwaysAllowBrowser",
"alwaysAllowMcp",
"alwaysAllowModeSwitch",
"alwaysAllowSubtasks",
"taskHistory",
"openAiBaseUrl",
"openAiModelId",
@ -84,6 +85,7 @@ export const GLOBAL_STATE_KEYS = [
"enhancementApiConfigId",
"experiments", // Map of experiment IDs to their enabled state
"autoApprovalEnabled",
"enableCustomModeCreation", // Enable the ability for Roo to create custom modes
"customModes", // Array of custom modes
"unboundModelId",
"requestyModelId",

View file

@ -1,8 +1,8 @@
import { calculateApiCost } from "../cost"
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../cost"
import { ModelInfo } from "../../shared/api"
describe("Cost Utility", () => {
describe("calculateApiCost", () => {
describe("calculateApiCostAnthropic", () => {
const mockModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
@ -14,7 +14,7 @@ describe("Cost Utility", () => {
}
it("should calculate basic input/output costs correctly", () => {
const cost = calculateApiCost(mockModelInfo, 1000, 500)
const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500)
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@ -23,7 +23,7 @@ describe("Cost Utility", () => {
})
it("should handle cache writes cost", () => {
const cost = calculateApiCost(mockModelInfo, 1000, 500, 2000)
const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500, 2000)
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@ -33,7 +33,7 @@ describe("Cost Utility", () => {
})
it("should handle cache reads cost", () => {
const cost = calculateApiCost(mockModelInfo, 1000, 500, undefined, 3000)
const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500, undefined, 3000)
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@ -43,7 +43,7 @@ describe("Cost Utility", () => {
})
it("should handle all cost components together", () => {
const cost = calculateApiCost(mockModelInfo, 1000, 500, 2000, 3000)
const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500, 2000, 3000)
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@ -60,17 +60,17 @@ describe("Cost Utility", () => {
supportsPromptCache: true,
}
const cost = calculateApiCost(modelWithoutPrices, 1000, 500, 2000, 3000)
const cost = calculateApiCostAnthropic(modelWithoutPrices, 1000, 500, 2000, 3000)
expect(cost).toBe(0)
})
it("should handle zero tokens", () => {
const cost = calculateApiCost(mockModelInfo, 0, 0, 0, 0)
const cost = calculateApiCostAnthropic(mockModelInfo, 0, 0, 0, 0)
expect(cost).toBe(0)
})
it("should handle undefined cache values", () => {
const cost = calculateApiCost(mockModelInfo, 1000, 500)
const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500)
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@ -85,7 +85,7 @@ describe("Cost Utility", () => {
cacheReadsPrice: undefined,
}
const cost = calculateApiCost(modelWithoutCachePrices, 1000, 500, 2000, 3000)
const cost = calculateApiCostAnthropic(modelWithoutCachePrices, 1000, 500, 2000, 3000)
// Should only include input and output costs
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
@ -94,4 +94,97 @@ describe("Cost Utility", () => {
expect(cost).toBe(0.0105)
})
})
describe("calculateApiCostOpenAI", () => {
const mockModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsPromptCache: true,
inputPrice: 3.0, // $3 per million tokens
outputPrice: 15.0, // $15 per million tokens
cacheWritesPrice: 3.75, // $3.75 per million tokens
cacheReadsPrice: 0.3, // $0.30 per million tokens
}
it("should calculate basic input/output costs correctly", () => {
const cost = calculateApiCostOpenAI(mockModelInfo, 1000, 500)
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
// Total: 0.003 + 0.0075 = 0.0105
expect(cost).toBe(0.0105)
})
it("should handle cache writes cost", () => {
const cost = calculateApiCostOpenAI(mockModelInfo, 3000, 500, 2000)
// Input cost: (3.0 / 1_000_000) * (3000 - 2000) = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
// Cache writes: (3.75 / 1_000_000) * 2000 = 0.0075
// Total: 0.003 + 0.0075 + 0.0075 = 0.018
expect(cost).toBeCloseTo(0.018, 6)
})
it("should handle cache reads cost", () => {
const cost = calculateApiCostOpenAI(mockModelInfo, 4000, 500, undefined, 3000)
// Input cost: (3.0 / 1_000_000) * (4000 - 3000) = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
// Cache reads: (0.3 / 1_000_000) * 3000 = 0.0009
// Total: 0.003 + 0.0075 + 0.0009 = 0.0114
expect(cost).toBe(0.0114)
})
it("should handle all cost components together", () => {
const cost = calculateApiCostOpenAI(mockModelInfo, 6000, 500, 2000, 3000)
// Input cost: (3.0 / 1_000_000) * (6000 - 2000 - 3000) = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
// Cache writes: (3.75 / 1_000_000) * 2000 = 0.0075
// Cache reads: (0.3 / 1_000_000) * 3000 = 0.0009
// Total: 0.003 + 0.0075 + 0.0075 + 0.0009 = 0.0189
expect(cost).toBe(0.0189)
})
it("should handle missing prices gracefully", () => {
const modelWithoutPrices: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsPromptCache: true,
}
const cost = calculateApiCostOpenAI(modelWithoutPrices, 1000, 500, 2000, 3000)
expect(cost).toBe(0)
})
it("should handle zero tokens", () => {
const cost = calculateApiCostOpenAI(mockModelInfo, 0, 0, 0, 0)
expect(cost).toBe(0)
})
it("should handle undefined cache values", () => {
const cost = calculateApiCostOpenAI(mockModelInfo, 1000, 500)
// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
// Total: 0.003 + 0.0075 = 0.0105
expect(cost).toBe(0.0105)
})
it("should handle missing cache prices", () => {
const modelWithoutCachePrices: ModelInfo = {
...mockModelInfo,
cacheWritesPrice: undefined,
cacheReadsPrice: undefined,
}
const cost = calculateApiCostOpenAI(modelWithoutCachePrices, 6000, 500, 2000, 3000)
// Should only include input and output costs
// Input cost: (3.0 / 1_000_000) * (6000 - 2000 - 3000) = 0.003
// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
// Total: 0.003 + 0.0075 = 0.0105
expect(cost).toBe(0.0105)
})
})
})

View file

@ -1,26 +1,57 @@
import { ModelInfo } from "../shared/api"
export function calculateApiCost(
function calculateApiCostInternal(
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
cacheCreationInputTokens?: number,
cacheReadInputTokens?: number,
cacheCreationInputTokens: number,
cacheReadInputTokens: number,
): number {
const modelCacheWritesPrice = modelInfo.cacheWritesPrice
let cacheWritesCost = 0
if (cacheCreationInputTokens && modelCacheWritesPrice) {
cacheWritesCost = (modelCacheWritesPrice / 1_000_000) * cacheCreationInputTokens
}
const modelCacheReadsPrice = modelInfo.cacheReadsPrice
let cacheReadsCost = 0
if (cacheReadInputTokens && modelCacheReadsPrice) {
cacheReadsCost = (modelCacheReadsPrice / 1_000_000) * cacheReadInputTokens
}
const cacheWritesCost = ((modelInfo.cacheWritesPrice || 0) / 1_000_000) * cacheCreationInputTokens
const cacheReadsCost = ((modelInfo.cacheReadsPrice || 0) / 1_000_000) * cacheReadInputTokens
const baseInputCost = ((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
const outputCost = ((modelInfo.outputPrice || 0) / 1_000_000) * outputTokens
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
return totalCost
}
// For Anthropic compliant usage, the input tokens count does NOT include the cached tokens
export function calculateApiCostAnthropic(
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
cacheCreationInputTokens?: number,
cacheReadInputTokens?: number,
): number {
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
const cacheReadInputTokensNum = cacheReadInputTokens || 0
return calculateApiCostInternal(
modelInfo,
inputTokens,
outputTokens,
cacheCreationInputTokensNum,
cacheReadInputTokensNum,
)
}
// For OpenAI compliant usage, the input tokens count INCLUDES the cached tokens
export function calculateApiCostOpenAI(
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
cacheCreationInputTokens?: number,
cacheReadInputTokens?: number,
): number {
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
const cacheReadInputTokensNum = cacheReadInputTokens || 0
const nonCachedInputTokens = Math.max(0, inputTokens - cacheCreationInputTokensNum - cacheReadInputTokensNum)
return calculateApiCostInternal(
modelInfo,
nonCachedInputTokens,
outputTokens,
cacheCreationInputTokensNum,
cacheReadInputTokensNum,
)
}
export const parseApiPrice = (price: any) => (price ? parseFloat(price) * 1_000_000 : undefined)

View file

@ -17,6 +17,12 @@ import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat"
type HumanRelayDialogState = {
isOpen: boolean
requestId: string
promptText: string
}
const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
chatButtonClicked: "chat",
settingsButtonClicked: "settings",
@ -24,24 +30,21 @@ const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]
mcpButtonClicked: "mcp",
historyButtonClicked: "history",
}
const App = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement, telemetrySetting, telemetryKey, machineId } =
useExtensionState()
const [showAnnouncement, setShowAnnouncement] = useState(false)
const [tab, setTab] = useState<Tab>("chat")
const settingsRef = useRef<SettingsViewRef>(null)
// Human Relay Dialog Status
const [humanRelayDialogState, setHumanRelayDialogState] = useState<{
isOpen: boolean
requestId: string
promptText: string
}>({
const [humanRelayDialogState, setHumanRelayDialogState] = useState<HumanRelayDialogState>({
isOpen: false,
requestId: "",
promptText: "",
})
const settingsRef = useRef<SettingsViewRef>(null)
const switchTab = useCallback((newTab: Tab) => {
if (settingsRef.current?.checkUnsaveChanges) {
settingsRef.current.checkUnsaveChanges(() => setTab(newTab))
@ -74,23 +77,6 @@ const App = () => {
[switchTab],
)
// Processing Human Relay Dialog Submission
const handleHumanRelaySubmit = (requestId: string, text: string) => {
vscode.postMessage({
type: "humanRelayResponse",
requestId,
text,
})
}
// Handle Human Relay dialog box cancel
const handleHumanRelayCancel = (requestId: string) => {
vscode.postMessage({
type: "humanRelayCancel",
requestId,
})
}
useEvent("message", onMessage)
useEffect(() => {
@ -106,7 +92,7 @@ const App = () => {
}
}, [telemetrySetting, telemetryKey, machineId, didHydrateState])
// Tell Extension that we are ready to receive messages
// Tell the extension that we are ready to receive messages.
useEffect(() => {
vscode.postMessage({ type: "webviewDidLaunch" })
}, [])
@ -121,24 +107,23 @@ const App = () => {
<WelcomeView />
) : (
<>
{tab === "settings" && <SettingsView ref={settingsRef} onDone={() => setTab("chat")} />}
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
{tab === "prompts" && <PromptsView onDone={() => switchTab("chat")} />}
{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
{tab === "settings" && <SettingsView ref={settingsRef} onDone={() => setTab("chat")} />}
<ChatView
isHidden={tab !== "chat"}
showAnnouncement={showAnnouncement}
hideAnnouncement={() => setShowAnnouncement(false)}
showHistoryView={() => switchTab("history")}
/>
{/* Human Relay Dialog */}
<HumanRelayDialog
isOpen={humanRelayDialogState.isOpen}
requestId={humanRelayDialogState.requestId}
promptText={humanRelayDialogState.promptText}
onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))}
onSubmit={handleHumanRelaySubmit}
onCancel={handleHumanRelayCancel}
onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })}
onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })}
/>
</>
)

View file

@ -33,7 +33,7 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
</p>
<h3 style={{ margin: "12px 0 8px" }}>What's New</h3>
<p style={{ margin: "5px 0px" }}>
<div style={{ margin: "5px 0px" }}>
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
<li>• Faster asynchronous checkpoints</li>
<li>• Support for .rooignore files</li>
@ -44,7 +44,7 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
<li>• Updated DeepSeek provider</li>
<li>• New "Human Relay" provider</li>
</ul>
</p>
</div>
<p style={{ margin: "10px 0px 0px" }}>
Get more details and discuss in{" "}

View file

@ -30,6 +30,8 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
setAlwaysAllowMcp,
alwaysAllowModeSwitch,
setAlwaysAllowModeSwitch,
alwaysAllowSubtasks,
setAlwaysAllowSubtasks,
alwaysApproveResubmit,
setAlwaysApproveResubmit,
autoApprovalEnabled,
@ -75,11 +77,17 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
},
{
id: "switchModes",
label: "Switch modes & create tasks",
label: "Switch modes",
shortName: "Modes",
enabled: alwaysAllowModeSwitch ?? false,
description:
"Allows automatic switching between different AI modes and creating new tasks without requiring approval.",
description: "Allows automatic switching between different modes without requiring approval.",
},
{
id: "subtasks",
label: "Create & complete subtasks",
shortName: "Subtasks",
enabled: alwaysAllowSubtasks ?? false,
description: "Allow creation and completion of subtasks without requiring approval.",
},
{
id: "retryRequests",
@ -136,6 +144,12 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: newValue })
}, [alwaysAllowModeSwitch, setAlwaysAllowModeSwitch])
const handleSubtasksChange = useCallback(() => {
const newValue = !(alwaysAllowSubtasks ?? false)
setAlwaysAllowSubtasks(newValue)
vscode.postMessage({ type: "alwaysAllowSubtasks", bool: newValue })
}, [alwaysAllowSubtasks, setAlwaysAllowSubtasks])
const handleRetryChange = useCallback(() => {
const newValue = !(alwaysApproveResubmit ?? false)
setAlwaysApproveResubmit(newValue)
@ -150,6 +164,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
useBrowser: handleBrowserChange,
useMcp: handleMcpChange,
switchModes: handleModeSwitchChange,
subtasks: handleSubtasksChange,
retryRequests: handleRetryChange,
}

View file

@ -451,7 +451,7 @@ export const ChatRowContent = ({
<div style={headerStyle}>
{toolIcon("new-file")}
<span style={{ fontWeight: "bold" }}>
Roo wants to create a new task in <code>{tool.mode}</code> mode:
Roo wants to create a new subtask in <code>{tool.mode}</code> mode:
</span>
</div>
<div style={{ paddingLeft: "26px", marginTop: "4px" }}>
@ -459,6 +459,18 @@ export const ChatRowContent = ({
</div>
</>
)
case "finishTask":
return (
<>
<div style={headerStyle}>
{toolIcon("checklist")}
<span style={{ fontWeight: "bold" }}>Roo wants to finish this subtask</span>
</div>
<div style={{ paddingLeft: "26px", marginTop: "4px" }}>
<code>{tool.content}</code>
</div>
</>
)
default:
return null
}

View file

@ -1,22 +1,25 @@
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import DynamicTextArea from "react-textarea-autosize"
import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
import { Mode, getAllModes } from "../../../../src/shared/modes"
import { vscode } from "@/utils/vscode"
import {
ContextMenuOptionType,
getContextMenuOptions,
insertMention,
removeMention,
shouldShowContextMenu,
} from "../../utils/context-mentions"
} from "@/utils/context-mentions"
import { SelectDropdown, DropdownOptionType } from "@/components/ui"
import { useExtensionState } from "../../context/ExtensionStateContext"
import Thumbnails from "../common/Thumbnails"
import { convertToMentionPath } from "../../utils/path-mentions"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import Thumbnails from "../common/Thumbnails"
import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
import { Mode, getAllModes } from "../../../../src/shared/modes"
import { convertToMentionPath } from "../../utils/path-mentions"
import { SelectDropdown, DropdownOptionType } from "../ui"
interface ChatTextAreaProps {
inputValue: string

View file

@ -61,6 +61,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setMode,
autoApprovalEnabled,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
customModes,
telemetrySetting,
} = useExtensionState()
@ -148,6 +149,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setPrimaryButtonText("Save")
setSecondaryButtonText("Reject")
break
case "finishTask":
setPrimaryButtonText("Complete Subtask and Return")
setSecondaryButtonText(undefined)
break
default:
setPrimaryButtonText("Approve")
setSecondaryButtonText("Reject")
@ -641,8 +646,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message)) ||
(alwaysAllowModeSwitch &&
message.ask === "tool" &&
(JSON.parse(message.text || "{}")?.tool === "switchMode" ||
JSON.parse(message.text || "{}")?.tool === "newTask"))
JSON.parse(message.text || "{}")?.tool === "switchMode") ||
(alwaysAllowSubtasks &&
message.ask === "tool" &&
["newTask", "finishTask"].includes(JSON.parse(message.text || "{}")?.tool))
)
},
[
@ -657,6 +664,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
alwaysAllowMcp,
isMcpToolAlwaysAllowed,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
],
)
@ -1267,7 +1275,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
modeShortcutText={modeShortcutText}
/>
<div id="chat-view-portal" />
<div id="roo-portal" />
</div>
)
}

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from "react"
import { useState, useCallback } from "react"
import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons"
import { Button, Popover, PopoverContent, PopoverTrigger } from "@/components/ui"
import { useRooPortal } from "@/components/ui/hooks"
import { vscode } from "../../../utils/vscode"
import { Checkpoint } from "./schema"
@ -14,9 +15,9 @@ type CheckpointMenuProps = {
}
export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: CheckpointMenuProps) => {
const [portalContainer, setPortalContainer] = useState<HTMLElement>()
const [isOpen, setIsOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
const portalContainer = useRooPortal("roo-portal")
const isCurrent = currentHash === commitHash
const isFirst = checkpoint.isFirst
@ -42,15 +43,6 @@ export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: Chec
setIsOpen(false)
}, [ts, commitHash])
useEffect(() => {
// The dropdown menu uses a portal from @shadcn/ui which by default renders
// at the document root. This causes the menu to remain visible even when
// the parent ChatView component is hidden (during settings/history view).
// By moving the portal inside ChatView, the menu will properly hide when
// its parent is hidden.
setPortalContainer(document.getElementById("chat-view-portal") || undefined)
}, [])
return (
<div className="flex flex-row gap-1">
{isDiffAvailable && (

View file

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
import { HTMLAttributes } from "react"
type AlertProps = HTMLAttributes<HTMLDivElement>
export const Alert = ({ className, children, ...props }: AlertProps) => (
<div
className={cn(
"text-vscode-inputValidation-infoForeground bg-vscode-inputValidation-infoBackground border border-vscode-inputValidation-infoBorder rounded-xs p-2",
className,
)}
{...props}>
{children}
</div>
)

View file

@ -0,0 +1,23 @@
import { HTMLAttributes } from "react"
import { cn } from "@/lib/utils"
type TabProps = HTMLAttributes<HTMLDivElement>
export const Tab = ({ className, children, ...props }: TabProps) => (
<div className={cn("fixed inset-0 flex flex-col overflow-hidden", className)} {...props}>
{children}
</div>
)
export const TabHeader = ({ className, children, ...props }: TabProps) => (
<div className={cn("px-5 py-2.5 border-b border-vscode-panel-border", className)} {...props}>
{children}
</div>
)
export const TabContent = ({ className, children, ...props }: TabProps) => (
<div className={cn("flex-1 overflow-auto p-5", className)} {...props}>
{children}
</div>
)

View file

@ -9,6 +9,7 @@ import { formatLargeNumber, formatDate } from "@/utils/format"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { useTaskSearch } from "./useTaskSearch"
import { ExportButton } from "./ExportButton"
import { CopyButton } from "./CopyButton"
@ -25,8 +26,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
return (
<div className="fixed inset-0 flex flex-col">
<div className="flex flex-col gap-2 px-5 py-2.5 border-b border-vscode-panel-border">
<Tab>
<TabHeader className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<h3 className="text-vscode-foreground m-0">History</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
@ -81,8 +82,9 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</VSCodeRadio>
</VSCodeRadioGroup>
</div>
</div>
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
</TabHeader>
<TabContent className="p-0">
<Virtuoso
style={{
flexGrow: 1,
@ -312,11 +314,12 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</div>
)}
/>
</div>
</TabContent>
{deleteTaskId && (
<DeleteTaskDialog taskId={deleteTaskId} onOpenChange={(open) => !open && setDeleteTaskId(null)} open />
)}
</div>
</Tab>
)
}

View file

@ -1,3 +1,4 @@
import { useState } from "react"
import {
VSCodeButton,
VSCodeCheckbox,
@ -6,14 +7,17 @@ import {
VSCodePanelTab,
VSCodePanelView,
} from "@vscode/webview-ui-toolkit/react"
import { useState } from "react"
import { vscode } from "../../utils/vscode"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { McpServer } from "../../../../src/shared/mcp"
import { vscode } from "@/utils/vscode"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import McpToolRow from "./McpToolRow"
import McpResourceRow from "./McpResourceRow"
import McpEnabledToggle from "./McpEnabledToggle"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"
type McpViewProps = {
onDone: () => void
@ -29,12 +33,13 @@ const McpView = ({ onDone }: McpViewProps) => {
} = useExtensionState()
return (
<div className="fixed inset-0 flex flex-col">
<div className="flex justify-between items-center px-5 py-2.5 border-b border-vscode-panel-border">
<Tab>
<TabHeader className="flex justify-between items-center">
<h3 className="text-vscode-foreground m-0">MCP Servers</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div className="flex-1 overflow-auto p-5">
</TabHeader>
<TabContent>
<div
style={{
color: "var(--vscode-foreground)",
@ -103,12 +108,11 @@ const McpView = ({ onDone }: McpViewProps) => {
</div>
</>
)}
</div>
</div>
</TabContent>
</Tab>
)
}
// Server Row Component
const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowMcp?: boolean }) => {
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)

View file

@ -42,6 +42,7 @@ import {
import { TOOL_GROUPS, GROUP_DISPLAY_NAMES, ToolGroup } from "../../../../src/shared/tool-groups"
import { vscode } from "../../utils/vscode"
import { Tab, TabContent, TabHeader } from "../common/Tab"
// Get all available groups that should show in prompts view
const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
@ -71,6 +72,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
preferredLanguage,
setPreferredLanguage,
customModes,
enableCustomModeCreation,
setEnableCustomModeCreation,
} = useExtensionState()
// Memoize modes to preserve array order
@ -341,6 +344,17 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
return () => document.removeEventListener("click", handleClickOutside)
}, [showConfigMenu])
// Add effect to sync enableCustomModeCreation with backend
useEffect(() => {
if (enableCustomModeCreation !== undefined) {
// Send the value to the extension's global state
vscode.postMessage({
type: "enableCustomModeCreation", // Using dedicated message type
bool: enableCustomModeCreation,
})
}
}, [enableCustomModeCreation])
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data
@ -406,12 +420,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
}
return (
<div className="fixed inset-0 flex flex-col">
<div className="flex justify-between items-center px-5 py-2.5 border-b border-vscode-panel-border">
<Tab>
<TabHeader className="flex justify-between items-center">
<h3 className="text-vscode-foreground m-0">Prompts</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div className="flex-1 overflow-auto p-5">
</TabHeader>
<TabContent>
<div className="pb-5 border-b border-vscode-input-border">
<div className="mb-5">
<div className="font-bold mb-1">Preferred Language</div>
@ -541,7 +556,6 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
in your workspace.
</div>
</div>
<div className="mt-5">
<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
<h3 className="text-vscode-foreground m-0">Modes</h3>
@ -934,6 +948,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
</div>
</div>
<div
style={{
paddingBottom: "40px",
@ -1010,6 +1025,35 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
)}
</div>
{/*
NOTE: This setting is placed in PromptsView rather than SettingsView since it
directly affects the functionality related to modes and custom mode creation,
which are managed in this component. This is an intentional deviation from
the standard pattern described in cline_docs/settings.md.
*/}
<div className="mb-4 mt-4">
<VSCodeCheckbox
checked={enableCustomModeCreation ?? true}
onChange={(e: any) => {
// Just update the local state through React context
// The React context will update the global state
setEnableCustomModeCreation(e.target.checked)
}}>
<span style={{ fontWeight: "500" }}>Enable Custom Mode Creation Through Prompts</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Roo allows you to create custom modes using prompts like ‘Make me a custom
mode that…’. Disabling this reduces your system prompt by about 700 tokens when this feature
isn’t needed. When disabled you can still manually create custom modes using the + button
above or by editing the related config JSON.
</p>
</div>
</div>
<div
@ -1172,7 +1216,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
)}
</div>
</div>
</div>
</TabContent>
{isCreateModeDialogOpen && (
<div
style={{
@ -1396,6 +1441,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
</div>
)}
{isDialogOpen && (
<div
style={{
@ -1463,6 +1509,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
</div>
)}
{isCustomLanguage && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--vscode-editor-background)] p-6 rounded-lg w-96">
@ -1497,7 +1544,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
</div>
)}
</div>
</Tab>
)
}

View file

@ -1076,27 +1076,31 @@ const ApiOptions = ({
Language Model
</label>
{vsCodeLmModels.length > 0 ? (
<Dropdown
id="vscode-lm-model"
<Select
value={
apiConfiguration?.vsCodeLmModelSelector
? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}`
: ""
}
onChange={handleInputChange("vsCodeLmModelSelector", (e) => {
const valueStr = (e as DropdownOption)?.value
onValueChange={handleInputChange("vsCodeLmModelSelector", (valueStr) => {
const [vendor, family] = valueStr.split("/")
return { vendor, family }
})}
options={[
{ value: "", label: "Select a model..." },
...vsCodeLmModels.map((model) => ({
value: `${model.vendor}/${model.family}`,
label: `${model.vendor} - ${model.family}`,
})),
]}
className="w-full"
/>
})}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{vsCodeLmModels.map((model) => (
<SelectItem
key={`${model.vendor}/${model.family}`}
value={`${model.vendor}/${model.family}`}>
{`${model.vendor} - ${model.family}`}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<div className="text-sm text-vscode-descriptionForeground">
The VS Code Language Model API allows you to run models provided by other VS Code

View file

@ -18,6 +18,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
requestDelaySeconds: number
alwaysAllowMcp?: boolean
alwaysAllowModeSwitch?: boolean
alwaysAllowSubtasks?: boolean
alwaysAllowExecute?: boolean
allowedCommands?: string[]
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
@ -32,6 +33,7 @@ export const AutoApproveSettings = ({
requestDelaySeconds,
alwaysAllowMcp,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
alwaysAllowExecute,
allowedCommands,
setCachedStateField,
@ -173,10 +175,21 @@ export const AutoApproveSettings = ({
<VSCodeCheckbox
checked={alwaysAllowModeSwitch}
onChange={(e: any) => setCachedStateField("alwaysAllowModeSwitch", e.target.checked)}>
<span className="font-medium">Always approve mode switching & task creation</span>
<span className="font-medium">Always approve mode switching</span>
</VSCodeCheckbox>
<p className="text-vscode-descriptionForeground text-sm mt-0">
Automatically switch between different AI modes and create new tasks without requiring approval
Automatically switch between different modes without requiring approval
</p>
</div>
<div>
<VSCodeCheckbox
checked={alwaysAllowSubtasks}
onChange={(e: any) => setCachedStateField("alwaysAllowSubtasks", e.target.checked)}>
<span className="font-medium">Always approve creation & completion of subtasks</span>
</VSCodeCheckbox>
<p className="text-vscode-descriptionForeground text-sm mt-0">
Allow creation and completion of subtasks without requiring approval
</p>
</div>

View file

@ -22,6 +22,7 @@ import {
Button,
} from "@/components/ui"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { SetCachedStateField, SetExperimentEnabled } from "./types"
import { SectionHeader } from "./SectionHeader"
import ApiConfigManager from "./ApiConfigManager"
@ -63,6 +64,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
alwaysAllowExecute,
alwaysAllowMcp,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
alwaysAllowWrite,
alwaysApproveResubmit,
browserToolEnabled,
@ -184,6 +186,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({ type: "updateExperimental", values: experiments })
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
vscode.postMessage({ type: "alwaysAllowSubtasks", bool: alwaysAllowSubtasks })
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
setChangeDetected(false)
@ -261,53 +264,41 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => ref.current?.scrollIntoView()
return (
<div className="fixed inset-0 flex flex-col overflow-hidden">
<div className="px-5 py-2.5 border-b border-vscode-panel-border">
<div className="flex flex-col">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h3 className="text-vscode-foreground m-0">Settings</h3>
<div className="hidden [@media(min-width:400px)]:flex items-center">
{sections.map(({ id, icon: Icon, ref }) => (
<Button
key={id}
variant="ghost"
onClick={() => scrollToSection(ref)}
className={cn("w-6 h-6", activeSection === id ? "opacity-100" : "opacity-40")}>
<Icon />
</Button>
))}
</div>
</div>
<div className="flex gap-2">
<VSCodeButton
appearance={isSettingValid ? "primary" : "secondary"}
className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
title={
!isSettingValid
? errorMessage
: isChangeDetected
? "Save changes"
: "Nothing changed"
}
onClick={handleSubmit}
disabled={!isChangeDetected || !isSettingValid}>
Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
title="Discard unsaved changes and close settings panel"
onClick={() => checkUnsaveChanges(onDone)}>
Done
</VSCodeButton>
</div>
<Tab>
<TabHeader className="flex justify-between items-center gap-2">
<div className="flex items-center gap-2">
<h3 className="text-vscode-foreground m-0">Settings</h3>
<div className="hidden [@media(min-width:400px)]:flex items-center">
{sections.map(({ id, icon: Icon, ref }) => (
<Button
key={id}
variant="ghost"
onClick={() => scrollToSection(ref)}
className={cn("w-6 h-6", activeSection === id ? "opacity-100" : "opacity-40")}>
<Icon />
</Button>
))}
</div>
</div>
</div>
<div className="flex gap-2">
<VSCodeButton
appearance={isSettingValid ? "primary" : "secondary"}
className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
title={!isSettingValid ? errorMessage : isChangeDetected ? "Save changes" : "Nothing changed"}
onClick={handleSubmit}
disabled={!isChangeDetected || !isSettingValid}>
Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
title="Discard unsaved changes and close settings panel"
onClick={() => checkUnsaveChanges(onDone)}>
Done
</VSCodeButton>
</div>
</TabHeader>
<div
className="flex flex-col flex-1 overflow-auto divide-y divide-vscode-sideBar-background"
onScroll={handleScroll}>
<TabContent className="p-0 divide-y divide-vscode-sideBar-background" onScroll={handleScroll}>
<div ref={providersRef}>
<SectionHeader>
<div className="flex items-center gap-2">
@ -364,6 +355,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
requestDelaySeconds={requestDelaySeconds}
alwaysAllowMcp={alwaysAllowMcp}
alwaysAllowModeSwitch={alwaysAllowModeSwitch}
alwaysAllowSubtasks={alwaysAllowSubtasks}
alwaysAllowExecute={alwaysAllowExecute}
allowedCommands={allowedCommands}
setCachedStateField={setCachedStateField}
@ -422,7 +414,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
telemetrySetting={telemetrySetting}
setTelemetrySetting={setTelemetrySetting}
/>
</div>
</TabContent>
<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
<AlertDialogContent>
@ -439,7 +431,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</Tab>
)
})

View file

@ -1,5 +1,6 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { PortalProps } from "@radix-ui/react-portal"
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
@ -53,9 +54,7 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
container?: HTMLElement
}
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & Pick<PortalProps, "container">
>(({ className, sideOffset = 4, container, ...props }, ref) => (
<DropdownMenuPrimitive.Portal container={container}>
<DropdownMenuPrimitive.Content

View file

@ -1 +1,2 @@
export * from "./useClipboard"
export * from "./useRooPortal"

View file

@ -0,0 +1,10 @@
import { useState } from "react"
import { useMount } from "react-use"
export const useRooPortal = (id: string) => {
const [container, setContainer] = useState<HTMLElement>()
useMount(() => setContainer(document.getElementById(id) ?? undefined))
return container
}

View file

@ -1,4 +1,5 @@
import * as React from "react"
import { PortalProps } from "@radix-ui/react-portal"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
@ -11,9 +12,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
container?: HTMLElement
}
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & Pick<PortalProps, "container">
>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content

View file

@ -1,4 +1,8 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { useRooPortal } from "./hooks/useRooPortal"
import {
DropdownMenu,
DropdownMenuContent,
@ -6,10 +10,7 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "./dropdown-menu"
import { cn } from "@/lib/utils"
import { useEffect, useState } from "react"
// Constants for option types
export enum DropdownOptionType {
ITEM = "item",
SEPARATOR = "separator",
@ -20,7 +21,7 @@ export interface DropdownOption {
value: string
label: string
disabled?: boolean
type?: DropdownOptionType // Optional type to specify special behaviors
type?: DropdownOptionType
}
export interface SelectDropdownProps {
@ -58,34 +59,19 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
},
ref,
) => {
// Track open state
const [open, setOpen] = React.useState(false)
const [portalContainer, setPortalContainer] = useState<HTMLElement>()
const portalContainer = useRooPortal("roo-portal")
useEffect(() => {
// The dropdown menu uses a portal from @shadcn/ui which by default renders
// at the document root. This causes the menu to remain visible even when
// the parent ChatView component is hidden (during settings/history view).
// By moving the portal inside ChatView, the menu will properly hide when
// its parent is hidden.
setPortalContainer(document.getElementById("chat-view-portal") || undefined)
}, [])
// Find the selected option label
const selectedOption = options.find((option) => option.value === value)
const displayText = selectedOption?.label || placeholder || ""
// Handle menu item click
const handleSelect = (option: DropdownOption) => {
// Check if this is an action option by its explicit type
if (option.type === DropdownOptionType.ACTION) {
window.postMessage({
type: "action",
action: option.value,
})
window.postMessage({ type: "action", action: option.value })
setOpen(false)
return
}
onChange(option.value)
setOpen(false)
}
@ -103,7 +89,7 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
triggerClassName,
)}
style={{
width: "100%", // Take full width of parent
width: "100%", // Take full width of parent.
minWidth: "0",
maxWidth: "100%",
}}>
@ -136,12 +122,10 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
contentClassName,
)}>
{options.map((option, index) => {
// Handle separator type
if (option.type === DropdownOptionType.SEPARATOR) {
return <DropdownMenuSeparator key={`sep-${index}`} />
}
// Handle shortcut text type (disabled label for keyboard shortcuts)
if (
option.type === DropdownOptionType.SHORTCUT ||
(option.disabled && shortcutText && option.label.includes(shortcutText))
@ -153,7 +137,6 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
)
}
// Regular menu items
return (
<DropdownMenuItem
key={`item-${option.value}`}

View file

@ -1,4 +1,5 @@
import * as React from "react"
import { PortalProps } from "@radix-ui/react-portal"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
@ -37,10 +38,11 @@ function SelectContent({
className,
children,
position = "popper",
container,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
}: React.ComponentProps<typeof SelectPrimitive.Content> & Pick<PortalProps, "container">) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Portal container={container}>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(

View file

@ -1,9 +1,12 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useState } from "react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import ApiOptions from "../settings/ApiOptions"
import { Tab, TabContent } from "../common/Tab"
import { Alert } from "../common/Alert"
const WelcomeView = () => {
const { apiConfiguration, currentApiConfigName, setApiConfiguration, uriScheme } = useExtensionState()
@ -23,18 +26,16 @@ const WelcomeView = () => {
}, [apiConfiguration, currentApiConfigName])
return (
<div className="flex flex-col min-h-screen px-0 pb-5">
<h2>Hi, I'm Roo!</h2>
<p>
I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities and access
to tools that let me create & edit files, explore complex projects, use the browser, and execute
terminal commands (with your permission, of course). I can even use MCP to create new tools and extend
my own capabilities.
</p>
<b>To get started, this extension needs an API provider.</b>
<div className="mt-3">
<Tab>
<TabContent className="flex flex-col gap-5">
<h2 className="m-0 p-0">Hi, I'm Roo!</h2>
<div>
I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities and
access to tools that let me create & edit files, explore complex projects, use the browser, and
execute terminal commands (with your permission, of course). I can even use MCP to create new tools
and extend my own capabilities.
</div>
<Alert className="font-bold text-sm">To get started, this extension needs an API provider.</Alert>
<ApiOptions
fromWelcomeView
apiConfiguration={apiConfiguration || {}}
@ -43,15 +44,14 @@ const WelcomeView = () => {
errorMessage={errorMessage}
setErrorMessage={setErrorMessage}
/>
</div>
<div className="sticky bottom-0 bg-[var(--vscode-sideBar-background)] py-3">
<div className="flex flex-col gap-1.5">
</TabContent>
<div className="sticky bottom-0 bg-vscode-sideBar-background p-5">
<div className="flex flex-col gap-1">
<VSCodeButton onClick={handleSubmit}>Let's go!</VSCodeButton>
{errorMessage && <span className="text-destructive">{errorMessage}</span>}
{errorMessage && <div className="text-vscode-errorForeground">{errorMessage}</div>}
</div>
</div>
</div>
</Tab>
)
}

View file

@ -31,6 +31,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowBrowser: (value: boolean) => void
setAlwaysAllowMcp: (value: boolean) => void
setAlwaysAllowModeSwitch: (value: boolean) => void
setAlwaysAllowSubtasks: (value: boolean) => void
setBrowserToolEnabled: (value: boolean) => void
setShowRooIgnoredFiles: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
@ -52,6 +53,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setMcpEnabled: (value: boolean) => void
enableMcpServerCreation: boolean
setEnableMcpServerCreation: (value: boolean) => void
enableCustomModeCreation?: boolean
setEnableCustomModeCreation: (value: boolean) => void
alwaysApproveResubmit?: boolean
setAlwaysApproveResubmit: (value: boolean) => void
requestDelaySeconds: number
@ -117,6 +120,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
checkpointStorage: "task",
fuzzyMatchThreshold: 1.0,
preferredLanguage: "English",
enableCustomModeCreation: true,
writeDelayMs: 1000,
browserViewportSize: "900x600",
screenshotQuality: 75,
@ -247,6 +251,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setAlwaysAllowBrowser: (value) => setState((prevState) => ({ ...prevState, alwaysAllowBrowser: value })),
setAlwaysAllowMcp: (value) => setState((prevState) => ({ ...prevState, alwaysAllowMcp: value })),
setAlwaysAllowModeSwitch: (value) => setState((prevState) => ({ ...prevState, alwaysAllowModeSwitch: value })),
setAlwaysAllowSubtasks: (value) => setState((prevState) => ({ ...prevState, alwaysAllowSubtasks: value })),
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
@ -273,6 +278,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setCustomSupportPrompts: (value) => setState((prevState) => ({ ...prevState, customSupportPrompts: value })),
setEnhancementApiConfigId: (value) =>
setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
setEnableCustomModeCreation: (value) =>
setState((prevState) => ({ ...prevState, enableCustomModeCreation: value })),
setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })),
setMaxOpenTabsContext: (value) => setState((prevState) => ({ ...prevState, maxOpenTabsContext: value })),

View file

@ -111,6 +111,10 @@
--color-vscode-charts-green: var(--vscode-charts-green);
--color-vscode-charts-yellow: var(--vscode-charts-yellow);
--color-vscode-inputValidation-infoForeground: var(--vscode-inputValidation-infoForeground);
--color-vscode-inputValidation-infoBackground: var(--vscode-inputValidation-infoBackground);
--color-vscode-inputValidation-infoBorder: var(--vscode-inputValidation-infoBorder);
}
@layer base {