Merge remote-tracking branch 'origin/main' into sbc_add_subtasks

This commit is contained in:
Matt Rubens 2025-03-05 12:16:37 -05:00
commit 279df6e7a4
22 changed files with 1310 additions and 540 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Improved observability of openai compatible APIs, by sending x-title and http-referer headers, as per Open Router standard.

View file

@ -3,6 +3,34 @@ import delay from "delay"
import { ClineProvider } from "../core/webview/ClineProvider"
// Store panel references in both modes
let sidebarPanel: vscode.WebviewView | undefined = undefined
let tabPanel: vscode.WebviewPanel | undefined = undefined
/**
* Get the currently active panel
* @returns WebviewPanel或WebviewView
*/
export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined {
return tabPanel || sidebarPanel
}
/**
* Set panel references
*/
export function setPanel(
newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined,
type: "sidebar" | "tab",
): void {
if (type === "sidebar") {
sidebarPanel = newPanel as vscode.WebviewView
tabPanel = undefined
} else {
tabPanel = newPanel as vscode.WebviewPanel
sidebarPanel = undefined
}
}
export type RegisterCommandOptions = {
context: vscode.ExtensionContext
outputChannel: vscode.OutputChannel
@ -15,6 +43,22 @@ export const registerCommands = (options: RegisterCommandOptions) => {
for (const [command, callback] of Object.entries(getCommandsMap(options))) {
context.subscriptions.push(vscode.commands.registerCommand(command, callback))
}
// Human Relay Dialog Command
context.subscriptions.push(
vscode.commands.registerCommand(
"roo-cline.showHumanRelayDialog",
(params: { requestId: string; promptText: string }) => {
if (getPanel()) {
getPanel()?.webview.postMessage({
type: "showHumanRelayDialog",
requestId: params.requestId,
promptText: params.promptText,
})
}
},
),
)
}
const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => {
@ -65,20 +109,28 @@ const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterComman
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
})
// Save as tab type panel
setPanel(newPanel, "tab")
// TODO: use better svg icon with light and dark variants (see
// https://stackoverflow.com/questions/58365687/vscode-extension-iconpath).
panel.iconPath = {
newPanel.iconPath = {
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
}
await tabProvider.resolveWebviewView(panel)
await tabProvider.resolveWebviewView(newPanel)
// Handle panel closing events
newPanel.onDidDispose(() => {
setPanel(undefined, "tab")
})
// Lock the editor group so clicking on files doesn't open them over the panel
await delay(100)

View file

@ -19,6 +19,7 @@ import { VsCodeLmHandler } from "./providers/vscode-lm"
import { ApiStream } from "./transform/stream"
import { UnboundHandler } from "./providers/unbound"
import { RequestyHandler } from "./providers/requesty"
import { HumanRelayHandler } from "./providers/human-relay"
export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string>
@ -72,6 +73,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new UnboundHandler(options)
case "requesty":
return new RequestyHandler(options)
case "human-relay":
return new HumanRelayHandler(options)
default:
return new AnthropicHandler(options)
}

View file

@ -90,6 +90,20 @@ describe("OpenAiHandler", () => {
})
expect(handlerWithCustomUrl).toBeInstanceOf(OpenAiHandler)
})
it("should set default headers correctly", () => {
// Get the mock constructor from the jest mock system
const openAiMock = jest.requireMock("openai").default
expect(openAiMock).toHaveBeenCalledWith({
baseURL: expect.any(String),
apiKey: expect.any(String),
defaultHeaders: {
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
"X-Title": "Roo Code",
},
})
})
})
describe("createMessage", () => {

View file

@ -0,0 +1,139 @@
// filepath: e:\Project\Roo-Code\src\api\providers\human-relay.ts
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandlerOptions, ModelInfo } from "../../shared/api"
import { ApiHandler, SingleCompletionHandler } from "../index"
import { ApiStream } from "../transform/stream"
import * as vscode from "vscode"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { getPanel } from "../../activate/registerCommands" // Import the getPanel function
/**
* Human Relay API processor
* This processor does not directly call the API, but interacts with the model through human operations copy and paste.
*/
export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler {
private options: ApiHandlerOptions
constructor(options: ApiHandlerOptions) {
this.options = options
}
countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
return Promise.resolve(0)
}
/**
* Create a message processing flow, display a dialog box to request human assistance
* @param systemPrompt System prompt words
* @param messages Message list
*/
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// Get the most recent user message
const latestMessage = messages[messages.length - 1]
if (!latestMessage) {
throw new Error("No message to relay")
}
// If it is the first message, splice the system prompt word with the user message
let promptText = ""
if (messages.length === 1) {
promptText = `${systemPrompt}\n\n${getMessageContent(latestMessage)}`
} else {
promptText = getMessageContent(latestMessage)
}
// Copy to clipboard
await vscode.env.clipboard.writeText(promptText)
// A dialog box pops up to request user action
const response = await showHumanRelayDialog(promptText)
if (!response) {
// The user canceled the operation
throw new Error("Human relay operation cancelled")
}
// Return to the user input reply
yield { type: "text", text: response }
}
/**
* Get model information
*/
getModel(): { id: string; info: ModelInfo } {
// Human relay does not depend on a specific model, here is a default configuration
return {
id: "human-relay",
info: {
maxTokens: 16384,
contextWindow: 100000,
supportsImages: true,
supportsPromptCache: false,
supportsComputerUse: true,
inputPrice: 0,
outputPrice: 0,
description: "Calling web-side AI model through human relay",
},
}
}
/**
* Implementation of a single prompt
* @param prompt Prompt content
*/
async completePrompt(prompt: string): Promise<string> {
// Copy to clipboard
await vscode.env.clipboard.writeText(prompt)
// A dialog box pops up to request user action
const response = await showHumanRelayDialog(prompt)
if (!response) {
throw new Error("Human relay operation cancelled")
}
return response
}
}
/**
* Extract text content from message object
* @param message
*/
function getMessageContent(message: Anthropic.Messages.MessageParam): string {
if (typeof message.content === "string") {
return message.content
} else if (Array.isArray(message.content)) {
return message.content
.filter((item) => item.type === "text")
.map((item) => (item.type === "text" ? item.text : ""))
.join("\n")
}
return ""
}
/**
* Displays the human relay dialog and waits for user response.
* @param promptText The prompt text that needs to be copied.
* @returns The user's input response or undefined (if canceled).
*/
async function showHumanRelayDialog(promptText: string): Promise<string | undefined> {
return new Promise<string | undefined>((resolve) => {
// Create a unique request ID
const requestId = Date.now().toString()
// Register a global callback function
vscode.commands.executeCommand(
"roo-cline.registerHumanRelayCallback",
requestId,
(response: string | undefined) => {
resolve(response)
},
)
// Open the dialog box directly using the current panel
vscode.commands.executeCommand("roo-cline.showHumanRelayDialog", {
requestId,
promptText,
})
})
}

View file

@ -16,10 +16,14 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { BaseProvider } from "./base-provider"
const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.6
export interface OpenAiHandlerOptions extends ApiHandlerOptions {
defaultHeaders?: Record<string, string>
export const defaultHeaders = {
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
"X-Title": "Roo Code",
}
export interface OpenAiHandlerOptions extends ApiHandlerOptions {}
export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler {
protected options: OpenAiHandlerOptions
private client: OpenAI
@ -47,9 +51,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
baseURL,
apiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders,
})
} else {
this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: this.options.defaultHeaders })
this.client = new OpenAI({ baseURL, apiKey, defaultHeaders })
}
}

View file

@ -13,6 +13,7 @@ import { convertToR1Format } from "../transform/r1-format"
import { DEEP_SEEK_DEFAULT_TEMPERATURE } from "./constants"
import { getModelParams, SingleCompletionHandler } from ".."
import { BaseProvider } from "./base-provider"
import { defaultHeaders } from "./openai"
// Add custom interface for OpenRouter params.
type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
@ -37,11 +38,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1"
const apiKey = this.options.openRouterApiKey ?? "not-provided"
const defaultHeaders = {
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
"X-Title": "Roo Code",
}
this.client = new OpenAI({ baseURL, apiKey, defaultHeaders })
}

View file

@ -16,10 +16,6 @@ export class RequestyHandler extends OpenAiHandler {
openAiModelId: options.requestyModelId ?? requestyDefaultModelId,
openAiBaseUrl: "https://router.requesty.ai/v1",
openAiCustomModelInfo: options.requestyModelInfo ?? requestyModelInfoSaneDefaults,
defaultHeaders: {
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
"X-Title": "Roo Code",
},
})
}

View file

@ -0,0 +1,258 @@
import * as vscode from "vscode"
import { ContextProxy } from "../contextProxy"
import { logger } from "../../utils/logging"
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState"
// Mock shared/globalState
jest.mock("../../shared/globalState", () => ({
GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"],
SECRET_KEYS: ["apiKey", "openAiApiKey"],
}))
// Mock VSCode API
jest.mock("vscode", () => ({
Uri: {
file: jest.fn((path) => ({ path })),
},
ExtensionMode: {
Development: 1,
Production: 2,
Test: 3,
},
}))
describe("ContextProxy", () => {
let proxy: ContextProxy
let mockContext: any
let mockGlobalState: any
let mockSecrets: any
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Mock globalState
mockGlobalState = {
get: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
}
// Mock secrets
mockSecrets = {
get: jest.fn().mockResolvedValue("test-secret"),
store: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
}
// Mock the extension context
mockContext = {
globalState: mockGlobalState,
secrets: mockSecrets,
extensionUri: { path: "/test/extension" },
extensionPath: "/test/extension",
globalStorageUri: { path: "/test/storage" },
logUri: { path: "/test/logs" },
extension: { packageJSON: { version: "1.0.0" } },
extensionMode: vscode.ExtensionMode.Development,
}
// Create proxy instance
proxy = new ContextProxy(mockContext)
})
describe("read-only pass-through properties", () => {
it("should return extension properties from the original context", () => {
expect(proxy.extensionUri).toBe(mockContext.extensionUri)
expect(proxy.extensionPath).toBe(mockContext.extensionPath)
expect(proxy.globalStorageUri).toBe(mockContext.globalStorageUri)
expect(proxy.logUri).toBe(mockContext.logUri)
expect(proxy.extension).toBe(mockContext.extension)
expect(proxy.extensionMode).toBe(mockContext.extensionMode)
})
})
describe("constructor", () => {
it("should initialize state cache with all global state keys", () => {
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length)
for (const key of GLOBAL_STATE_KEYS) {
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
}
})
it("should initialize secret cache with all secret keys", () => {
expect(mockSecrets.get).toHaveBeenCalledTimes(SECRET_KEYS.length)
for (const key of SECRET_KEYS) {
expect(mockSecrets.get).toHaveBeenCalledWith(key)
}
})
})
describe("getGlobalState", () => {
it("should return value from cache when it exists", async () => {
// Manually set a value in the cache
await proxy.updateGlobalState("test-key", "cached-value")
// Should return the cached value
const result = proxy.getGlobalState("test-key")
expect(result).toBe("cached-value")
// Original context should be called once during updateGlobalState
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length) // Only from initialization
})
it("should handle default values correctly", async () => {
// No value in cache
const result = proxy.getGlobalState("unknown-key", "default-value")
expect(result).toBe("default-value")
})
})
describe("updateGlobalState", () => {
it("should update state directly in original context", async () => {
await proxy.updateGlobalState("test-key", "new-value")
// Should have called original context
expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value")
// Should have stored the value in cache
const storedValue = await proxy.getGlobalState("test-key")
expect(storedValue).toBe("new-value")
})
})
describe("getSecret", () => {
it("should return value from cache when it exists", async () => {
// Manually set a value in the cache
await proxy.storeSecret("api-key", "cached-secret")
// Should return the cached value
const result = proxy.getSecret("api-key")
expect(result).toBe("cached-secret")
})
})
describe("storeSecret", () => {
it("should store secret directly in original context", async () => {
await proxy.storeSecret("api-key", "new-secret")
// Should have called original context
expect(mockSecrets.store).toHaveBeenCalledWith("api-key", "new-secret")
// Should have stored the value in cache
const storedValue = await proxy.getSecret("api-key")
expect(storedValue).toBe("new-secret")
})
it("should handle undefined value for secret deletion", async () => {
await proxy.storeSecret("api-key", undefined)
// Should have called delete on original context
expect(mockSecrets.delete).toHaveBeenCalledWith("api-key")
// Should have stored undefined in cache
const storedValue = await proxy.getSecret("api-key")
expect(storedValue).toBeUndefined()
})
})
describe("setValue", () => {
it("should route secret keys to storeSecret", async () => {
// Spy on storeSecret
const storeSecretSpy = jest.spyOn(proxy, "storeSecret")
// Test with a known secret key
await proxy.setValue("openAiApiKey", "test-api-key")
// Should have called storeSecret
expect(storeSecretSpy).toHaveBeenCalledWith("openAiApiKey", "test-api-key")
// Should have stored the value in secret cache
const storedValue = proxy.getSecret("openAiApiKey")
expect(storedValue).toBe("test-api-key")
})
it("should route global state keys to updateGlobalState", async () => {
// Spy on updateGlobalState
const updateGlobalStateSpy = jest.spyOn(proxy, "updateGlobalState")
// Test with a known global state key
await proxy.setValue("apiModelId", "gpt-4")
// Should have called updateGlobalState
expect(updateGlobalStateSpy).toHaveBeenCalledWith("apiModelId", "gpt-4")
// Should have stored the value in state cache
const storedValue = proxy.getGlobalState("apiModelId")
expect(storedValue).toBe("gpt-4")
})
it("should handle unknown keys as global state with warning", async () => {
// Spy on the logger
const warnSpy = jest.spyOn(logger, "warn")
// Spy on updateGlobalState
const updateGlobalStateSpy = jest.spyOn(proxy, "updateGlobalState")
// Test with an unknown key
await proxy.setValue("unknownKey", "some-value")
// Should have logged a warning
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown key: unknownKey"))
// Should have called updateGlobalState
expect(updateGlobalStateSpy).toHaveBeenCalledWith("unknownKey", "some-value")
// Should have stored the value in state cache
const storedValue = proxy.getGlobalState("unknownKey")
expect(storedValue).toBe("some-value")
})
})
describe("setValues", () => {
it("should process multiple values correctly", async () => {
// Spy on setValue
const setValueSpy = jest.spyOn(proxy, "setValue")
// Test with multiple values
await proxy.setValues({
apiModelId: "gpt-4",
apiProvider: "openai",
mode: "test-mode",
})
// Should have called setValue for each key
expect(setValueSpy).toHaveBeenCalledTimes(3)
expect(setValueSpy).toHaveBeenCalledWith("apiModelId", "gpt-4")
expect(setValueSpy).toHaveBeenCalledWith("apiProvider", "openai")
expect(setValueSpy).toHaveBeenCalledWith("mode", "test-mode")
// Should have stored all values in state cache
expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4")
expect(proxy.getGlobalState("apiProvider")).toBe("openai")
expect(proxy.getGlobalState("mode")).toBe("test-mode")
})
it("should handle both secret and global state keys", async () => {
// Spy on storeSecret and updateGlobalState
const storeSecretSpy = jest.spyOn(proxy, "storeSecret")
const updateGlobalStateSpy = jest.spyOn(proxy, "updateGlobalState")
// Test with mixed keys
await proxy.setValues({
apiModelId: "gpt-4", // global state
openAiApiKey: "test-api-key", // secret
unknownKey: "some-value", // unknown
})
// Should have called appropriate methods
expect(storeSecretSpy).toHaveBeenCalledWith("openAiApiKey", "test-api-key")
expect(updateGlobalStateSpy).toHaveBeenCalledWith("apiModelId", "gpt-4")
expect(updateGlobalStateSpy).toHaveBeenCalledWith("unknownKey", "some-value")
// Should have stored values in appropriate caches
expect(proxy.getSecret("openAiApiKey")).toBe("test-api-key")
expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4")
expect(proxy.getGlobalState("unknownKey")).toBe("some-value")
})
})
})

132
src/core/contextProxy.ts Normal file
View file

@ -0,0 +1,132 @@
import * as vscode from "vscode"
import { logger } from "../utils/logging"
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState"
export class ContextProxy {
private readonly originalContext: vscode.ExtensionContext
private stateCache: Map<string, any>
private secretCache: Map<string, string | undefined>
constructor(context: vscode.ExtensionContext) {
// Initialize properties first
this.originalContext = context
this.stateCache = new Map()
this.secretCache = new Map()
// Initialize state cache with all defined global state keys
this.initializeStateCache()
// Initialize secret cache with all defined secret keys
this.initializeSecretCache()
logger.debug("ContextProxy created")
}
// Helper method to initialize state cache
private initializeStateCache(): void {
for (const key of GLOBAL_STATE_KEYS) {
try {
const value = this.originalContext.globalState.get(key)
this.stateCache.set(key, value)
} catch (error) {
logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
// Helper method to initialize secret cache
private initializeSecretCache(): void {
for (const key of SECRET_KEYS) {
// Get actual value and update cache when promise resolves
;(this.originalContext.secrets.get(key) as Promise<string | undefined>)
.then((value) => {
this.secretCache.set(key, value)
})
.catch((error: Error) => {
logger.error(`Error loading secret ${key}: ${error.message}`)
})
}
}
get extensionUri(): vscode.Uri {
return this.originalContext.extensionUri
}
get extensionPath(): string {
return this.originalContext.extensionPath
}
get globalStorageUri(): vscode.Uri {
return this.originalContext.globalStorageUri
}
get logUri(): vscode.Uri {
return this.originalContext.logUri
}
get extension(): vscode.Extension<any> | undefined {
return this.originalContext.extension
}
get extensionMode(): vscode.ExtensionMode {
return this.originalContext.extensionMode
}
getGlobalState<T>(key: string): T | undefined
getGlobalState<T>(key: string, defaultValue: T): T
getGlobalState<T>(key: string, defaultValue?: T): T | undefined {
const value = this.stateCache.get(key) as T | undefined
return value !== undefined ? value : (defaultValue as T | undefined)
}
updateGlobalState<T>(key: string, value: T): Thenable<void> {
this.stateCache.set(key, value)
return this.originalContext.globalState.update(key, value)
}
getSecret(key: string): string | undefined {
return this.secretCache.get(key)
}
storeSecret(key: string, value?: string): Thenable<void> {
// Update cache
this.secretCache.set(key, value)
// Write directly to context
if (value === undefined) {
return this.originalContext.secrets.delete(key)
} else {
return this.originalContext.secrets.store(key, value)
}
}
/**
* Set a value in either secrets or global state based on key type.
* If the key is in SECRET_KEYS, it will be stored as a secret.
* If the key is in GLOBAL_STATE_KEYS or unknown, it will be stored in global state.
* @param key The key to set
* @param value The value to set
* @returns A promise that resolves when the operation completes
*/
setValue(key: string, value: any): Thenable<void> {
if (SECRET_KEYS.includes(key as any)) {
return this.storeSecret(key, value)
}
if (GLOBAL_STATE_KEYS.includes(key as any)) {
return this.updateGlobalState(key, value)
}
logger.warn(`Unknown key: ${key}. Storing as global state.`)
return this.updateGlobalState(key, value)
}
/**
* Set multiple values at once. Each key will be routed to either
* secrets or global state based on its type.
* @param values An object containing key-value pairs to set
* @returns A promise that resolves when all operations complete
*/
async setValues(values: Record<string, any>): Promise<void[]> {
const promises: Thenable<void>[] = []
for (const [key, value] of Object.entries(values)) {
promises.push(this.setValue(key, value))
}
return Promise.all(promises)
}
}

View file

@ -7,17 +7,18 @@ import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import simpleGit from "simple-git"
import { setPanel } from "../../activate/registerCommands"
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api"
import { CheckpointStorage } from "../../shared/checkpoints"
import { findLast } from "../../shared/array"
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import type { SecretKey, GlobalStateKey } from "../../shared/globalState"
import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../../shared/globalState"
import { HistoryItem } from "../../shared/HistoryItem"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
import { downloadTask } from "../../integrations/misc/export-markdown"
@ -35,6 +36,7 @@ import { getDiffStrategy } from "../diff/DiffStrategy"
import { SYSTEM_PROMPT } from "../prompts/system"
import { ConfigManager } from "../config/ConfigManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { ContextProxy } from "../contextProxy"
import { buildApiHandler } from "../../api"
import { getOpenRouterModels } from "../../api/providers/openrouter"
import { getGlamaModels } from "../../api/providers/glama"
@ -66,6 +68,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private workspaceTracker?: WorkspaceTracker
protected mcpHub?: McpHub // Change from private to protected
private latestAnnouncementId = "feb-27-2025-automatic-checkpoints" // update to some unique identifier when we add a new announcement
private contextProxy: ContextProxy
configManager: ConfigManager
customModesManager: CustomModesManager
private lastTaskNumber = -1
@ -75,6 +78,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private readonly outputChannel: vscode.OutputChannel,
) {
this.outputChannel.appendLine("ClineProvider instantiated")
this.contextProxy = new ContextProxy(context)
ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(this)
this.configManager = new ConfigManager(this.context)
@ -376,6 +380,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Resolving webview view")
this.view = webviewView
// Set panel reference according to webview type
if ("onDidChangeViewState" in webviewView) {
// Tag page type
setPanel(webviewView, "tab")
} else if ("onDidChangeVisibility" in webviewView) {
// Sidebar Type
setPanel(webviewView, "sidebar")
}
// Initialize sound enabled state
this.getState().then(({ soundEnabled }) => {
setSoundEnabled(soundEnabled ?? false)
@ -384,11 +397,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
webviewView.webview.options = {
// Allow scripts in the webview
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
localResourceRoots: [this.contextProxy.extensionUri],
}
webviewView.webview.html =
this.context.extensionMode === vscode.ExtensionMode.Development
this.contextProxy.extensionMode === vscode.ExtensionMode.Development
? await this.getHMRHtmlContent(webviewView.webview)
: this.getHtmlContent(webviewView.webview)
@ -552,8 +565,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
const nonce = getNonce()
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, this.context.extensionUri, [
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
"webview-ui",
"build",
"assets",
"index.css",
])
const codiconsUri = getUri(webview, this.contextProxy.extensionUri, [
"node_modules",
"@vscode",
"codicons",
@ -619,15 +637,20 @@ 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", "assets", "index.css"])
const stylesUri = getUri(webview, this.contextProxy.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", "assets", "index.js"])
const scriptUri = getUri(webview, this.contextProxy.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
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = getUri(webview, this.context.extensionUri, [
const codiconsUri = getUri(webview, this.contextProxy.extensionUri, [
"node_modules",
"@vscode",
"codicons",
@ -1431,7 +1454,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Try to get enhancement config first, fall back to current config
let configToUse: ApiConfiguration = apiConfiguration
if (enhancementApiConfigId) {
const config = listApiConfigMeta?.find((c) => c.id === enhancementApiConfigId)
const config = listApiConfigMeta?.find(
(c: ApiConfigMeta) => c.id === enhancementApiConfigId,
)
if (config?.name) {
const loadedConfig = await this.configManager.loadConfig(config.name)
if (loadedConfig.apiProvider) {
@ -1709,6 +1734,25 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("mode", defaultModeSlug)
await this.postStateToWebview()
}
break
case "humanRelayResponse":
if (message.requestId && message.text) {
vscode.commands.executeCommand("roo-cline.handleHumanRelayResponse", {
requestId: message.requestId,
text: message.text,
cancelled: false,
})
}
break
case "humanRelayCancel":
if (message.requestId) {
vscode.commands.executeCommand("roo-cline.handleHumanRelayResponse", {
requestId: message.requestId,
cancelled: true,
})
}
break
}
},
null,
@ -1810,108 +1854,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
const {
apiProvider,
apiModelId,
apiKey,
glamaModelId,
glamaModelInfo,
glamaApiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiCustomModelInfo,
openAiUseAzure,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterBaseUrl,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
vsCodeLmModelSelector,
mistralApiKey,
mistralCodestralUrl,
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
lmStudioDraftModelId,
lmStudioSpeculativeDecodingEnabled,
} = apiConfiguration
await Promise.all([
this.updateGlobalState("apiProvider", apiProvider),
this.updateGlobalState("apiModelId", apiModelId),
this.storeSecret("apiKey", apiKey),
this.updateGlobalState("glamaModelId", glamaModelId),
this.updateGlobalState("glamaModelInfo", glamaModelInfo),
this.storeSecret("glamaApiKey", glamaApiKey),
this.storeSecret("openRouterApiKey", openRouterApiKey),
this.storeSecret("awsAccessKey", awsAccessKey),
this.storeSecret("awsSecretKey", awsSecretKey),
this.storeSecret("awsSessionToken", awsSessionToken),
this.updateGlobalState("awsRegion", awsRegion),
this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference),
this.updateGlobalState("awsProfile", awsProfile),
this.updateGlobalState("awsUseProfile", awsUseProfile),
this.updateGlobalState("vertexProjectId", vertexProjectId),
this.updateGlobalState("vertexRegion", vertexRegion),
this.updateGlobalState("openAiBaseUrl", openAiBaseUrl),
this.storeSecret("openAiApiKey", openAiApiKey),
this.updateGlobalState("openAiModelId", openAiModelId),
this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo),
this.updateGlobalState("openAiUseAzure", openAiUseAzure),
this.updateGlobalState("ollamaModelId", ollamaModelId),
this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl),
this.updateGlobalState("lmStudioModelId", lmStudioModelId),
this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
this.storeSecret("geminiApiKey", geminiApiKey),
this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
this.storeSecret("deepSeekApiKey", deepSeekApiKey),
this.updateGlobalState("azureApiVersion", azureApiVersion),
this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled),
this.updateGlobalState("openRouterModelId", openRouterModelId),
this.updateGlobalState("openRouterModelInfo", openRouterModelInfo),
this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl),
this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform),
this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector),
this.storeSecret("mistralApiKey", mistralApiKey),
this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl),
this.storeSecret("unboundApiKey", unboundApiKey),
this.updateGlobalState("unboundModelId", unboundModelId),
this.updateGlobalState("unboundModelInfo", unboundModelInfo),
this.storeSecret("requestyApiKey", requestyApiKey),
this.updateGlobalState("requestyModelId", requestyModelId),
this.updateGlobalState("requestyModelInfo", requestyModelInfo),
this.updateGlobalState("modelTemperature", modelTemperature),
this.updateGlobalState("modelMaxTokens", modelMaxTokens),
this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens),
this.updateGlobalState("lmStudioDraftModelId", lmStudioDraftModelId),
this.updateGlobalState("lmStudioSpeculativeDecodingEnabled", lmStudioSpeculativeDecodingEnabled),
])
// Use the new setValues method to handle routing values to secrets or global state
await this.contextProxy.setValues(apiConfiguration)
if (this.getCurrentCline()) {
this.getCurrentCline()!.api = buildApiHandler(apiConfiguration)
}
@ -1972,13 +1917,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
async ensureSettingsDirectoryExists(): Promise<string> {
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
const settingsDir = path.join(this.contextProxy.globalStorageUri.fsPath, "settings")
await fs.mkdir(settingsDir, { recursive: true })
return settingsDir
}
private async ensureCacheDirectoryExists() {
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
const cacheDir = path.join(this.contextProxy.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
@ -2014,8 +1959,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
const openrouter: ApiProvider = "openrouter"
await this.updateGlobalState("apiProvider", openrouter)
await this.storeSecret("openRouterApiKey", apiKey)
await this.contextProxy.setValues({
apiProvider: openrouter,
openRouterApiKey: apiKey,
})
await this.postStateToWebview()
if (this.getCurrentCline()) {
this.getCurrentCline()!.api = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey })
@ -2042,8 +1990,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
const glama: ApiProvider = "glama"
await this.updateGlobalState("apiProvider", glama)
await this.storeSecret("glamaApiKey", apiKey)
await this.contextProxy.setValues({
apiProvider: glama,
glamaApiKey: apiKey,
})
await this.postStateToWebview()
if (this.getCurrentCline()) {
this.getCurrentCline()!.api = buildApiHandler({
@ -2066,7 +2016,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = history.find((item) => item.id === id)
if (historyItem) {
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
const taskDirPath = path.join(this.contextProxy.globalStorageUri.fsPath, "tasks", id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
@ -2215,7 +2165,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.getCurrentCline()?.taskId
? (taskHistory || []).find((item) => item.id === this.getCurrentCline()?.taskId)
? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId)
: undefined,
clineMessages: this.getCurrentCline()?.clineMessages || [],
taskHistory: (taskHistory || [])
@ -2302,191 +2252,41 @@ export class ClineProvider implements vscode.WebviewViewProvider {
*/
async getState() {
const [
storedApiProvider,
apiModelId,
apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiCustomModelInfo,
openAiUseAzure,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
mistralApiKey,
mistralCodestralUrl,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterBaseUrl,
openRouterUseMiddleOutTransform,
lastShownAnnouncementId,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
alwaysAllowMcp,
alwaysAllowModeSwitch,
taskHistory,
allowedCommands,
soundEnabled,
diffEnabled,
enableCheckpoints,
checkpointStorage,
soundVolume,
browserViewportSize,
fuzzyMatchThreshold,
preferredLanguage,
writeDelayMs,
screenshotQuality,
terminalOutputLineLimit,
mcpEnabled,
enableMcpServerCreation,
alwaysApproveResubmit,
requestDelaySeconds,
rateLimitSeconds,
currentApiConfigName,
listApiConfigMeta,
vsCodeLmModelSelector,
mode,
modeApiConfigs,
customModePrompts,
customSupportPrompts,
enhancementApiConfigId,
autoApprovalEnabled,
customModes,
experiments,
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
maxOpenTabsContext,
browserToolEnabled,
lmStudioSpeculativeDecodingEnabled,
lmStudioDraftModelId,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getSecret("glamaApiKey") as Promise<string | undefined>,
this.getGlobalState("glamaModelId") as Promise<string | undefined>,
this.getGlobalState("glamaModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("openRouterApiKey") as Promise<string | undefined>,
this.getSecret("awsAccessKey") as Promise<string | undefined>,
this.getSecret("awsSecretKey") as Promise<string | undefined>,
this.getSecret("awsSessionToken") as Promise<string | undefined>,
this.getGlobalState("awsRegion") as Promise<string | undefined>,
this.getGlobalState("awsUseCrossRegionInference") as Promise<boolean | undefined>,
this.getGlobalState("awsProfile") as Promise<string | undefined>,
this.getGlobalState("awsUseProfile") as Promise<boolean | undefined>,
this.getGlobalState("vertexProjectId") as Promise<string | undefined>,
this.getGlobalState("vertexRegion") as Promise<string | undefined>,
this.getGlobalState("openAiBaseUrl") as Promise<string | undefined>,
this.getSecret("openAiApiKey") as Promise<string | undefined>,
this.getGlobalState("openAiModelId") as Promise<string | undefined>,
this.getGlobalState("openAiCustomModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("openAiUseAzure") as Promise<boolean | undefined>,
this.getGlobalState("ollamaModelId") as Promise<string | undefined>,
this.getGlobalState("ollamaBaseUrl") as Promise<string | undefined>,
this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
this.getGlobalState("lmStudioBaseUrl") as Promise<string | undefined>,
this.getGlobalState("anthropicBaseUrl") as Promise<string | undefined>,
this.getSecret("geminiApiKey") as Promise<string | undefined>,
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
this.getSecret("mistralApiKey") as Promise<string | undefined>,
this.getGlobalState("mistralCodestralUrl") as Promise<string | undefined>,
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
this.getGlobalState("openAiStreamingEnabled") as Promise<boolean | undefined>,
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("openRouterBaseUrl") as Promise<string | undefined>,
this.getGlobalState("openRouterUseMiddleOutTransform") as Promise<boolean | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
this.getGlobalState("customInstructions") as Promise<string | undefined>,
this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowMcp") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowModeSwitch") as Promise<boolean | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("enableCheckpoints") as Promise<boolean | undefined>,
this.getGlobalState("checkpointStorage") as Promise<CheckpointStorage | undefined>,
this.getGlobalState("soundVolume") as Promise<number | undefined>,
this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
this.getGlobalState("preferredLanguage") as Promise<string | undefined>,
this.getGlobalState("writeDelayMs") as Promise<number | undefined>,
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>,
this.getGlobalState("mode") as Promise<Mode | undefined>,
this.getGlobalState("modeApiConfigs") as Promise<Record<Mode, string> | undefined>,
this.getGlobalState("customModePrompts") as Promise<CustomModePrompts | undefined>,
this.getGlobalState("customSupportPrompts") as Promise<CustomSupportPrompts | undefined>,
this.getGlobalState("enhancementApiConfigId") as Promise<string | undefined>,
this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>,
this.customModesManager.getCustomModes(),
this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>,
this.getSecret("unboundApiKey") as Promise<string | undefined>,
this.getGlobalState("unboundModelId") as Promise<string | undefined>,
this.getGlobalState("unboundModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("requestyApiKey") as Promise<string | undefined>,
this.getGlobalState("requestyModelId") as Promise<string | undefined>,
this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("modelTemperature") as Promise<number | undefined>,
this.getGlobalState("modelMaxTokens") as Promise<number | undefined>,
this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
this.getGlobalState("maxOpenTabsContext") as Promise<number | undefined>,
this.getGlobalState("browserToolEnabled") as Promise<boolean | undefined>,
this.getGlobalState("lmStudioSpeculativeDecodingEnabled") as Promise<boolean | undefined>,
this.getGlobalState("lmStudioDraftModelId") as Promise<string | undefined>,
])
// Create an object to store all fetched values
const stateValues: Record<GlobalStateKey | SecretKey, any> = {} as Record<GlobalStateKey | SecretKey, any>
const secretValues: Record<SecretKey, any> = {} as Record<SecretKey, any>
// Create promise arrays for global state and secrets
const statePromises = GLOBAL_STATE_KEYS.map((key) => this.getGlobalState(key))
const secretPromises = SECRET_KEYS.map((key) => this.getSecret(key))
// Add promise for custom modes which is handled separately
const customModesPromise = this.customModesManager.getCustomModes()
let idx = 0
const valuePromises = await Promise.all([...statePromises, ...secretPromises, customModesPromise])
// Populate stateValues and secretValues
GLOBAL_STATE_KEYS.forEach((key, _) => {
stateValues[key] = valuePromises[idx]
idx = idx + 1
})
SECRET_KEYS.forEach((key, index) => {
secretValues[key] = valuePromises[idx]
idx = idx + 1
})
let customModes = valuePromises[idx] as ModeConfig[] | undefined
// Determine apiProvider with the same logic as before
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
if (stateValues.apiProvider) {
apiProvider = stateValues.apiProvider
} else {
// Either new user or legacy user that doesn't have the apiProvider stored in state
// (If they're using OpenRouter or Bedrock, then apiProvider state will exist)
if (apiKey) {
if (secretValues.apiKey) {
apiProvider = "anthropic"
} else {
// New users should default to openrouter
@ -2494,81 +2294,46 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
// Build the apiConfiguration object combining state values and secrets
// Using the dynamic approach with API_CONFIG_KEYS
const apiConfiguration: ApiConfiguration = {
// Dynamically add all API-related keys from stateValues
...Object.fromEntries(API_CONFIG_KEYS.map((key) => [key, stateValues[key]])),
// Add all secrets
...secretValues,
}
// Ensure apiProvider is set properly if not already in state
if (!apiConfiguration.apiProvider) {
apiConfiguration.apiProvider = apiProvider
}
// Return the same structure as before
return {
apiConfiguration: {
apiProvider,
apiModelId,
apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiCustomModelInfo,
openAiUseAzure,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
mistralApiKey,
mistralCodestralUrl,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterBaseUrl,
openRouterUseMiddleOutTransform,
vsCodeLmModelSelector,
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
lmStudioSpeculativeDecodingEnabled,
lmStudioDraftModelId,
},
lastShownAnnouncementId,
customInstructions,
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
alwaysAllowWrite: alwaysAllowWrite ?? false,
alwaysAllowExecute: alwaysAllowExecute ?? false,
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
alwaysAllowMcp: alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
taskHistory,
allowedCommands,
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? true,
enableCheckpoints: enableCheckpoints ?? true,
checkpointStorage: checkpointStorage ?? "task",
soundVolume,
browserViewportSize: browserViewportSize ?? "900x600",
screenshotQuality: screenshotQuality ?? 75,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
writeDelayMs: writeDelayMs ?? 1000,
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
mode: mode ?? defaultModeSlug,
apiConfiguration,
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
customInstructions: stateValues.customInstructions,
alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false,
alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false,
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false,
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
taskHistory: stateValues.taskHistory,
allowedCommands: stateValues.allowedCommands,
soundEnabled: stateValues.soundEnabled ?? false,
diffEnabled: stateValues.diffEnabled ?? true,
enableCheckpoints: stateValues.enableCheckpoints ?? false,
checkpointStorage: stateValues.checkpointStorage ?? "task",
soundVolume: stateValues.soundVolume,
browserViewportSize: stateValues.browserViewportSize ?? "900x600",
screenshotQuality: stateValues.screenshotQuality ?? 75,
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
writeDelayMs: stateValues.writeDelayMs ?? 1000,
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
mode: stateValues.mode ?? defaultModeSlug,
preferredLanguage:
preferredLanguage ??
stateValues.preferredLanguage ??
(() => {
// Get VSCode's locale setting
const vscodeLang = vscode.env.language
@ -2598,23 +2363,23 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Return mapped language or default to English
return langMap[vscodeLang] ?? 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>),
customModePrompts: customModePrompts ?? {},
customSupportPrompts: customSupportPrompts ?? {},
enhancementApiConfigId,
experiments: experiments ?? experimentDefault,
autoApprovalEnabled: autoApprovalEnabled ?? false,
mcpEnabled: stateValues.mcpEnabled ?? true,
enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true,
alwaysApproveResubmit: stateValues.alwaysApproveResubmit ?? false,
requestDelaySeconds: Math.max(5, stateValues.requestDelaySeconds ?? 10),
rateLimitSeconds: stateValues.rateLimitSeconds ?? 0,
currentApiConfigName: stateValues.currentApiConfigName ?? "default",
listApiConfigMeta: stateValues.listApiConfigMeta ?? [],
modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record<Mode, string>),
customModePrompts: stateValues.customModePrompts ?? {},
customSupportPrompts: stateValues.customSupportPrompts ?? {},
enhancementApiConfigId: stateValues.enhancementApiConfigId,
experiments: stateValues.experiments ?? experimentDefault,
autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false,
customModes,
maxOpenTabsContext: maxOpenTabsContext ?? 20,
openRouterUseMiddleOutTransform: openRouterUseMiddleOutTransform ?? true,
browserToolEnabled: browserToolEnabled ?? true,
maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20,
openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform ?? true,
browserToolEnabled: stateValues.browserToolEnabled ?? true,
}
}
@ -2634,25 +2399,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// global
async updateGlobalState(key: GlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
await this.contextProxy.updateGlobalState(key, value)
}
async getGlobalState(key: GlobalStateKey) {
return await this.context.globalState.get(key)
return await this.contextProxy.getGlobalState(key)
}
// secrets
public async storeSecret(key: SecretKey, value?: string) {
if (value) {
await this.context.secrets.store(key, value)
} else {
await this.context.secrets.delete(key)
}
await this.contextProxy.storeSecret(key, value)
}
private async getSecret(key: SecretKey) {
return await this.context.secrets.get(key)
return await this.contextProxy.getSecret(key)
}
// dev
@ -2669,26 +2430,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
for (const key of this.context.globalState.keys()) {
await this.context.globalState.update(key, undefined)
await this.contextProxy.updateGlobalState(key, undefined)
}
const secretKeys: SecretKey[] = [
"apiKey",
"glamaApiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"mistralApiKey",
"unboundApiKey",
"requestyApiKey",
]
for (const key of secretKeys) {
for (const key of SECRET_KEYS) {
await this.storeSecret(key, undefined)
}
await this.configManager.resetAllConfigs()
await this.customModesManager.resetCustomModes()
await this.removeClineFromStack()

View file

@ -5,6 +5,7 @@ import axios from "axios"
import { ClineProvider } from "../ClineProvider"
import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
import { GlobalStateKey, SecretKey } from "../../../shared/globalState"
import { setSoundEnabled } from "../../../utils/sound"
import { defaultModeSlug } from "../../../shared/modes"
import { experimentDefault } from "../../../shared/experiments"
@ -13,6 +14,34 @@ import { Cline } from "../../Cline"
// Mock setup must come before imports
jest.mock("../../prompts/sections/custom-instructions")
// Mock ContextProxy
jest.mock("../../contextProxy", () => {
return {
ContextProxy: jest.fn().mockImplementation((context) => ({
originalContext: context,
extensionUri: context.extensionUri,
extensionPath: context.extensionPath,
globalStorageUri: context.globalStorageUri,
logUri: context.logUri,
extension: context.extension,
extensionMode: context.extensionMode,
getGlobalState: jest
.fn()
.mockImplementation((key, defaultValue) => context.globalState.get(key, defaultValue)),
updateGlobalState: jest.fn().mockImplementation((key, value) => context.globalState.update(key, value)),
getSecret: jest.fn().mockImplementation((key) => context.secrets.get(key)),
storeSecret: jest
.fn()
.mockImplementation((key, value) =>
value ? context.secrets.store(key, value) : context.secrets.delete(key),
),
saveChanges: jest.fn().mockResolvedValue(undefined),
dispose: jest.fn().mockResolvedValue(undefined),
hasPendingChanges: jest.fn().mockReturnValue(false),
})),
}
})
// Mock dependencies
jest.mock("vscode")
jest.mock("delay")
@ -241,6 +270,12 @@ describe("ClineProvider", () => {
let mockOutputChannel: vscode.OutputChannel
let mockWebviewView: vscode.WebviewView
let mockPostMessage: jest.Mock
let mockContextProxy: {
updateGlobalState: jest.Mock
getGlobalState: jest.Mock
storeSecret: jest.Mock
dispose: jest.Mock
}
beforeEach(() => {
// Reset mocks
@ -313,6 +348,8 @@ describe("ClineProvider", () => {
} as unknown as vscode.WebviewView
provider = new ClineProvider(mockContext, mockOutputChannel)
// @ts-ignore - Access private property for testing
mockContextProxy = provider.contextProxy
// @ts-ignore - Accessing private property for testing.
provider.customModesManager = mockCustomModesManager
@ -516,6 +553,7 @@ describe("ClineProvider", () => {
await messageHandler({ type: "writeDelayMs", value: 2000 })
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("writeDelayMs", 2000)
expect(mockContext.globalState.update).toHaveBeenCalledWith("writeDelayMs", 2000)
expect(mockPostMessage).toHaveBeenCalled()
})
@ -529,6 +567,7 @@ describe("ClineProvider", () => {
// Simulate setting sound to enabled
await messageHandler({ type: "soundEnabled", bool: true })
expect(setSoundEnabled).toHaveBeenCalledWith(true)
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("soundEnabled", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
@ -651,6 +690,7 @@ describe("ClineProvider", () => {
// Test alwaysApproveResubmit
await messageHandler({ type: "alwaysApproveResubmit", bool: true })
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("alwaysApproveResubmit", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("alwaysApproveResubmit", true)
expect(mockPostMessage).toHaveBeenCalled()
@ -1503,6 +1543,60 @@ describe("ClineProvider", () => {
expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [
{ name: "test-config", id: "test-id", apiProvider: "anthropic" },
])
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("listApiConfigMeta", [
{ name: "test-config", id: "test-id", apiProvider: "anthropic" },
])
})
})
})
describe("ContextProxy integration", () => {
let provider: ClineProvider
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockContextProxy: any
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Setup basic mocks
mockContext = {
globalState: { get: jest.fn(), 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)
// @ts-ignore - accessing private property for testing
mockContextProxy = provider.contextProxy
})
test("updateGlobalState uses contextProxy", async () => {
await provider.updateGlobalState("currentApiConfigName" as GlobalStateKey, "testValue")
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("currentApiConfigName", "testValue")
})
test("getGlobalState uses contextProxy", async () => {
mockContextProxy.getGlobalState.mockResolvedValueOnce("testValue")
const result = await provider.getGlobalState("currentApiConfigName" as GlobalStateKey)
expect(mockContextProxy.getGlobalState).toHaveBeenCalledWith("currentApiConfigName")
expect(result).toBe("testValue")
})
test("storeSecret uses contextProxy", async () => {
await provider.storeSecret("apiKey" as SecretKey, "test-secret")
expect(mockContextProxy.storeSecret).toHaveBeenCalledWith("apiKey", "test-secret")
})
test("contextProxy methods are available", () => {
// Verify the contextProxy has all the required methods
expect(mockContextProxy.getGlobalState).toBeDefined()
expect(mockContextProxy.updateGlobalState).toBeDefined()
expect(mockContextProxy.storeSecret).toBeDefined()
})
})

View file

@ -19,6 +19,18 @@ import { McpServerManager } from "./services/mcp/McpServerManager"
let outputChannel: vscode.OutputChannel
let extensionContext: vscode.ExtensionContext
// Callback mapping of human relay response
const humanRelayCallbacks = new Map<string, (response: string | undefined) => void>()
/**
* Register a callback function for human relay response
* @param requestId
* @param callback
*/
export function registerHumanRelayCallback(requestId: string, callback: (response: string | undefined) => void): void {
humanRelayCallbacks.set(requestId, callback)
}
// 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) {
@ -45,6 +57,40 @@ export function activate(context: vscode.ExtensionContext) {
registerCommands({ context, outputChannel, provider: sidebarProvider })
// Register human relay callback registration command
context.subscriptions.push(
vscode.commands.registerCommand(
"roo-cline.registerHumanRelayCallback",
(requestId: string, callback: (response: string | undefined) => void) => {
registerHumanRelayCallback(requestId, callback)
},
),
)
// Register human relay response processing command
context.subscriptions.push(
vscode.commands.registerCommand(
"roo-cline.handleHumanRelayResponse",
(response: { requestId: string; text?: string; cancelled?: boolean }) => {
const callback = humanRelayCallbacks.get(response.requestId)
if (callback) {
if (response.cancelled) {
callback(undefined)
} else {
callback(response.text)
}
humanRelayCallbacks.delete(response.requestId)
}
},
),
)
context.subscriptions.push(
vscode.commands.registerCommand("roo-cline.unregisterHumanRelayCallback", (requestId: string) => {
humanRelayCallbacks.delete(requestId)
}),
)
/**
* 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

View file

@ -46,6 +46,9 @@ export interface ExtensionMessage {
| "updateCustomMode"
| "deleteCustomMode"
| "currentCheckpointUpdated"
| "showHumanRelayDialog"
| "humanRelayResponse"
| "humanRelayCancel"
| "browserToolEnabled"
text?: string
action?:
@ -243,4 +246,22 @@ export interface ClineApiReqInfo {
streamingFailedMessage?: string
}
// Human relay related message types
export interface ShowHumanRelayDialogMessage {
type: "showHumanRelayDialog"
requestId: string
promptText: string
}
export interface HumanRelayResponseMessage {
type: "humanRelayResponse"
requestId: string
text: string
}
export interface HumanRelayCancelMessage {
type: "humanRelayCancel"
requestId: string
}
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"

View file

@ -95,6 +95,8 @@ export interface WebviewMessage {
| "checkpointRestore"
| "deleteMcpServer"
| "maxOpenTabsContext"
| "humanRelayResponse"
| "humanRelayCancel"
| "browserToolEnabled"
text?: string
disabled?: boolean
@ -119,6 +121,19 @@ export interface WebviewMessage {
timeout?: number
payload?: WebViewMessagePayload
source?: "global" | "project"
requestId?: string
}
// Human relay related message types
export interface HumanRelayResponseMessage extends WebviewMessage {
type: "humanRelayResponse"
requestId: string
text: string
}
export interface HumanRelayCancelMessage extends WebviewMessage {
type: "humanRelayCancel"
requestId: string
}
export const checkoutDiffPayloadSchema = z.object({

View file

@ -16,6 +16,7 @@ export type ApiProvider =
| "mistral"
| "unbound"
| "requesty"
| "human-relay"
export interface ApiHandlerOptions {
apiModelId?: string
@ -58,7 +59,6 @@ export interface ApiHandlerOptions {
azureApiVersion?: string
openRouterUseMiddleOutTransform?: boolean
openAiStreamingEnabled?: boolean
setAzureApiVersion?: boolean
deepSeekBaseUrl?: string
deepSeekApiKey?: string
includeMaxTokens?: boolean
@ -78,6 +78,52 @@ export type ApiConfiguration = ApiHandlerOptions & {
id?: string // stable unique identifier
}
// Import GlobalStateKey type from globalState.ts
import { GlobalStateKey } from "./globalState"
// Define API configuration keys for dynamic object building
export const API_CONFIG_KEYS: GlobalStateKey[] = [
"apiModelId",
"anthropicBaseUrl",
"vsCodeLmModelSelector",
"glamaModelId",
"glamaModelInfo",
"openRouterModelId",
"openRouterModelInfo",
"openRouterBaseUrl",
"awsRegion",
"awsUseCrossRegionInference",
// "awsUsePromptCache", // NOT exist on GlobalStateKey
// "awspromptCacheId", // NOT exist on GlobalStateKey
"awsProfile",
"awsUseProfile",
"vertexProjectId",
"vertexRegion",
"openAiBaseUrl",
"openAiModelId",
"openAiCustomModelInfo",
"openAiUseAzure",
"ollamaModelId",
"ollamaBaseUrl",
"lmStudioModelId",
"lmStudioBaseUrl",
"lmStudioDraftModelId",
"lmStudioSpeculativeDecodingEnabled",
"mistralCodestralUrl",
"azureApiVersion",
"openRouterUseMiddleOutTransform",
"openAiStreamingEnabled",
// "deepSeekBaseUrl", // not exist on GlobalStateKey
// "includeMaxTokens", // not exist on GlobalStateKey
"unboundModelId",
"unboundModelInfo",
"requestyModelId",
"requestyModelInfo",
"modelTemperature",
"modelMaxTokens",
"modelMaxThinkingTokens",
]
// Models
export interface ModelInfo {

View file

@ -1,23 +1,20 @@
import { ApiConfiguration } from "../shared/api"
import { SECRET_KEYS } from "./globalState"
export function checkExistKey(config: ApiConfiguration | undefined) {
return config
? [
config.apiKey,
config.glamaApiKey,
config.openRouterApiKey,
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
config.ollamaModelId,
config.lmStudioModelId,
config.geminiApiKey,
config.openAiNativeApiKey,
config.deepSeekApiKey,
config.mistralApiKey,
config.vsCodeLmModelSelector,
config.requestyApiKey,
config.unboundApiKey,
].some((key) => key !== undefined)
: false
if (!config) return false
// Check all secret keys from the centralized SECRET_KEYS array
const hasSecretKey = SECRET_KEYS.some((key) => config[key as keyof ApiConfiguration] !== undefined)
// Check additional non-secret configuration properties
const hasOtherConfig = [
config.awsRegion,
config.vertexProjectId,
config.ollamaModelId,
config.lmStudioModelId,
config.vsCodeLmModelSelector,
].some((value) => value !== undefined)
return hasSecretKey || hasOtherConfig
}

View file

@ -1,90 +1,100 @@
export type SecretKey =
| "apiKey"
| "glamaApiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
| "awsSessionToken"
| "openAiApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
| "mistralApiKey"
| "unboundApiKey"
| "requestyApiKey"
// Define the array first with 'as const' to create a readonly tuple type
export const SECRET_KEYS = [
"apiKey",
"glamaApiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"mistralApiKey",
"unboundApiKey",
"requestyApiKey",
] as const
export type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "glamaModelId"
| "glamaModelInfo"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiCustomModelInfo"
| "openAiUseAzure"
| "ollamaModelId"
| "ollamaBaseUrl"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "lmStudioDraftModelId"
| "lmStudioSpeculativeDecodingEnabled"
| "anthropicBaseUrl"
| "azureApiVersion"
| "openAiStreamingEnabled"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterBaseUrl"
| "openRouterUseMiddleOutTransform"
| "allowedCommands"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "enableCheckpoints"
| "checkpointStorage"
| "browserViewportSize"
| "screenshotQuality"
| "fuzzyMatchThreshold"
| "preferredLanguage" // Language setting for Cline's communication
| "writeDelayMs"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "enableMcpServerCreation"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "rateLimitSeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
| "vsCodeLmModelSelector"
| "mode"
| "modeApiConfigs"
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
| "experiments" // Map of experiment IDs to their enabled state
| "autoApprovalEnabled"
| "customModes" // Array of custom modes
| "unboundModelId"
| "requestyModelId"
| "requestyModelInfo"
| "unboundModelInfo"
| "modelTemperature"
| "modelMaxTokens"
| "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`.
| "mistralCodestralUrl"
| "maxOpenTabsContext"
| "browserToolEnabled" // Setting to enable/disable the browser tool
// Derive the type from the array - creates a union of string literals
export type SecretKey = (typeof SECRET_KEYS)[number]
// Define the array first with 'as const' to create a readonly tuple type
export const GLOBAL_STATE_KEYS = [
"apiProvider",
"apiModelId",
"glamaModelId",
"glamaModelInfo",
"awsRegion",
"awsUseCrossRegionInference",
"awsProfile",
"awsUseProfile",
"vertexProjectId",
"vertexRegion",
"lastShownAnnouncementId",
"customInstructions",
"alwaysAllowReadOnly",
"alwaysAllowWrite",
"alwaysAllowExecute",
"alwaysAllowBrowser",
"alwaysAllowMcp",
"alwaysAllowModeSwitch",
"taskHistory",
"openAiBaseUrl",
"openAiModelId",
"openAiCustomModelInfo",
"openAiUseAzure",
"ollamaModelId",
"ollamaBaseUrl",
"lmStudioModelId",
"lmStudioBaseUrl",
"anthropicBaseUrl",
"modelMaxThinkingTokens",
"azureApiVersion",
"openAiStreamingEnabled",
"openRouterModelId",
"openRouterModelInfo",
"openRouterBaseUrl",
"openRouterUseMiddleOutTransform",
"allowedCommands",
"soundEnabled",
"soundVolume",
"diffEnabled",
"enableCheckpoints",
"checkpointStorage",
"browserViewportSize",
"screenshotQuality",
"fuzzyMatchThreshold",
"preferredLanguage", // Language setting for Cline's communication
"writeDelayMs",
"terminalOutputLineLimit",
"mcpEnabled",
"enableMcpServerCreation",
"alwaysApproveResubmit",
"requestDelaySeconds",
"rateLimitSeconds",
"currentApiConfigName",
"listApiConfigMeta",
"vsCodeLmModelSelector",
"mode",
"modeApiConfigs",
"customModePrompts",
"customSupportPrompts",
"enhancementApiConfigId",
"experiments", // Map of experiment IDs to their enabled state
"autoApprovalEnabled",
"customModes", // Array of custom modes
"unboundModelId",
"requestyModelId",
"requestyModelInfo",
"unboundModelInfo",
"modelTemperature",
"modelMaxTokens",
"mistralCodestralUrl",
"maxOpenTabsContext",
"browserToolEnabled",
"lmStudioSpeculativeDecodingEnabled",
"lmStudioDraftModelId",
] as const
// Derive the type from the array - creates a union of string literals
export type GlobalStateKey = (typeof GLOBAL_STATE_KEYS)[number]

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"
import { useEvent } from "react-use"
import { ExtensionMessage } from "../../src/shared/ExtensionMessage"
import { ShowHumanRelayDialogMessage } from "../../src/shared/ExtensionMessage"
import { vscode } from "./utils/vscode"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
@ -11,6 +12,7 @@ import SettingsView, { SettingsViewRef } from "./components/settings/SettingsVie
import WelcomeView from "./components/welcome/WelcomeView"
import McpView from "./components/mcp/McpView"
import PromptsView from "./components/prompts/PromptsView"
import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat"
@ -28,6 +30,17 @@ const App = () => {
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
}>({
isOpen: false,
requestId: "",
promptText: "",
})
const switchTab = useCallback((newTab: Tab) => {
if (settingsRef.current?.checkUnsaveChanges) {
settingsRef.current.checkUnsaveChanges(() => setTab(newTab))
@ -47,10 +60,36 @@ const App = () => {
switchTab(newTab)
}
}
const mes: ShowHumanRelayDialogMessage = message as ShowHumanRelayDialogMessage
// Processing displays human relay dialog messages
if (mes.type === "showHumanRelayDialog" && mes.requestId && mes.promptText) {
setHumanRelayDialogState({
isOpen: true,
requestId: mes.requestId,
promptText: mes.promptText,
})
}
},
[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(() => {
@ -60,6 +99,11 @@ const App = () => {
}
}, [shouldShowAnnouncement])
// Tell Extension that we are ready to receive messages
useEffect(() => {
vscode.postMessage({ type: "webviewDidLaunch" })
}, [])
if (!didHydrateState) {
return null
}
@ -80,6 +124,15 @@ const App = () => {
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}
/>
</>
)
}

View file

@ -418,7 +418,7 @@ const ContextWindowProgress = ({ contextWindow, contextTokens }: { contextWindow
<div className="flex items-center gap-1 flex-shrink-0">
<span className="font-bold">Context Window:</span>
</div>
<div className="flex items-center gap-2 flex-1 whitespace-nowrap">
<div className="flex items-center gap-2 flex-1 whitespace-nowrap px-2">
<div>{formatLargeNumber(contextTokens)}</div>
<div className="flex items-center gap-[3px] flex-1">
<div className="flex-1 h-1 rounded-[2px] overflow-hidden bg-[color-mix(in_srgb,var(--vscode-badge-foreground)_20%,transparent)]">

View file

@ -0,0 +1,113 @@
import * as React from "react"
import { Button } from "../ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"
import { Textarea } from "../ui/textarea"
import { useClipboard } from "../ui/hooks"
import { Check, Copy, X } from "lucide-react"
interface HumanRelayDialogProps {
isOpen: boolean
onClose: () => void
requestId: string
promptText: string
onSubmit: (requestId: string, text: string) => void
onCancel: (requestId: string) => void
}
/**
* Human Relay Dialog Component
* Displays the prompt text that needs to be copied and provides an input box for the user to paste the AI's response.
*/
export const HumanRelayDialog: React.FC<HumanRelayDialogProps> = ({
isOpen,
onClose,
requestId,
promptText,
onSubmit,
onCancel,
}) => {
const [response, setResponse] = React.useState("")
const { copy } = useClipboard()
const [isCopyClicked, setIsCopyClicked] = React.useState(false)
// Listen to isOpen changes, clear the input box when the dialog box is opened
React.useEffect(() => {
if (isOpen) {
setResponse("")
setIsCopyClicked(false)
}
}, [isOpen])
// Copy to clipboard and show a success message
const handleCopy = () => {
copy(promptText)
setIsCopyClicked(true)
setTimeout(() => {
setIsCopyClicked(false)
}, 2000)
}
// Submit the response
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (response.trim()) {
onSubmit(requestId, response)
onClose()
}
}
// Cancel the operation
const handleCancel = () => {
onCancel(requestId)
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Human Relay - Please Help Copy and Paste Information</DialogTitle>
<DialogDescription>
Please copy the text below to the web AI, then paste the AI's response into the input box below.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="relative">
<Textarea
className="min-h-[200px] font-mono text-sm p-4 pr-12 whitespace-pre-wrap"
value={promptText}
readOnly
/>
<Button variant="ghost" size="icon" className="absolute top-2 right-2" onClick={handleCopy}>
{isCopyClicked ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
{isCopyClicked && <div className="text-sm text-emerald-500 font-medium">Copied to clipboard</div>}
<div>
<div className="mb-2 font-medium">Please enter the AI's response:</div>
<Textarea
placeholder="Paste the AI's response here..."
value={response}
onChange={(e) => setResponse(e.target.value)}
className="min-h-[150px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel} className="gap-1">
<X className="h-4 w-4" />
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!response.trim()} className="gap-1">
<Check className="h-4 w-4" />
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -257,6 +257,7 @@ const ApiOptions = ({
{ value: "ollama", label: "Ollama" },
{ value: "unbound", label: "Unbound" },
{ value: "requesty", label: "Requesty" },
{ value: "human-relay", label: "Human Relay" },
]}
/>
</div>
@ -1378,6 +1379,32 @@ const ApiOptions = ({
</div>
)}
{selectedProvider === "human-relay" && (
<div>
<p
style={{
fontSize: "12px",
marginTop: 5,
color: "var(--vscode-descriptionForeground)",
lineHeight: "1.4",
}}>
The API key is not required, but the user needs to help copy and paste the information to the
web chat AI.
</p>
<p
style={{
fontSize: "12px",
marginTop: 10,
color: "var(--vscode-descriptionForeground)",
lineHeight: "1.4",
}}>
During use, a dialog box will pop up and the current message will be copied to the clipboard
automatically. You need to paste these to web versions of AI (such as ChatGPT or Claude), then
copy the AI's reply back to the dialog box and click the confirm button.
</p>
</div>
)}
{selectedProvider === "openrouter" && (
<ModelPicker
apiConfiguration={apiConfiguration}