diff --git a/.changeset/wise-pears-join.md b/.changeset/wise-pears-join.md new file mode 100644 index 0000000000..46c019b92e --- /dev/null +++ b/.changeset/wise-pears-join.md @@ -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. diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 79f57b2509..e1c1ec93c6 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -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 { + setPanel(undefined, "tab") + }) // Lock the editor group so clicking on files doesn't open them over the panel await delay(100) diff --git a/src/api/index.ts b/src/api/index.ts index bcbd710a55..cf8085e289 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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 @@ -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) } diff --git a/src/api/providers/__tests__/openai.test.ts b/src/api/providers/__tests__/openai.test.ts index 5b5da20f51..43634b5862 100644 --- a/src/api/providers/__tests__/openai.test.ts +++ b/src/api/providers/__tests__/openai.test.ts @@ -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", () => { diff --git a/src/api/providers/human-relay.ts b/src/api/providers/human-relay.ts new file mode 100644 index 0000000000..b8bd4c2829 --- /dev/null +++ b/src/api/providers/human-relay.ts @@ -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): Promise { + 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 { + // 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 { + return new Promise((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, + }) + }) +} diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 0fa833e82a..9262f3b75a 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -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 + +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 }) } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index db5c094d02..7d3992caa5 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -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 }) } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 5e570ca2a2..27187c5d33 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -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", - }, }) } diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts new file mode 100644 index 0000000000..0ac98bbc8c --- /dev/null +++ b/src/core/__tests__/contextProxy.test.ts @@ -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") + }) + }) +}) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts new file mode 100644 index 0000000000..f27cc7f65c --- /dev/null +++ b/src/core/contextProxy.ts @@ -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 + private secretCache: Map + + 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) + .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 | undefined { + return this.originalContext.extension + } + get extensionMode(): vscode.ExtensionMode { + return this.originalContext.extensionMode + } + + getGlobalState(key: string): T | undefined + getGlobalState(key: string, defaultValue: T): T + getGlobalState(key: string, defaultValue?: T): T | undefined { + const value = this.stateCache.get(key) as T | undefined + return value !== undefined ? value : (defaultValue as T | undefined) + } + + updateGlobalState(key: string, value: T): Thenable { + 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 { + // 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 { + 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): Promise { + const promises: Thenable[] = [] + + for (const [key, value] of Object.entries(values)) { + promises.push(this.setValue(key, value)) + } + + return Promise.all(promises) + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 55633b2f80..3aba6c3c1e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 { - 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, - this.getGlobalState("apiModelId") as Promise, - this.getSecret("apiKey") as Promise, - this.getSecret("glamaApiKey") as Promise, - this.getGlobalState("glamaModelId") as Promise, - this.getGlobalState("glamaModelInfo") as Promise, - this.getSecret("openRouterApiKey") as Promise, - this.getSecret("awsAccessKey") as Promise, - this.getSecret("awsSecretKey") as Promise, - this.getSecret("awsSessionToken") as Promise, - this.getGlobalState("awsRegion") as Promise, - this.getGlobalState("awsUseCrossRegionInference") as Promise, - this.getGlobalState("awsProfile") as Promise, - this.getGlobalState("awsUseProfile") as Promise, - this.getGlobalState("vertexProjectId") as Promise, - this.getGlobalState("vertexRegion") as Promise, - this.getGlobalState("openAiBaseUrl") as Promise, - this.getSecret("openAiApiKey") as Promise, - this.getGlobalState("openAiModelId") as Promise, - this.getGlobalState("openAiCustomModelInfo") as Promise, - this.getGlobalState("openAiUseAzure") as Promise, - this.getGlobalState("ollamaModelId") as Promise, - this.getGlobalState("ollamaBaseUrl") as Promise, - this.getGlobalState("lmStudioModelId") as Promise, - this.getGlobalState("lmStudioBaseUrl") as Promise, - this.getGlobalState("anthropicBaseUrl") as Promise, - this.getSecret("geminiApiKey") as Promise, - this.getSecret("openAiNativeApiKey") as Promise, - this.getSecret("deepSeekApiKey") as Promise, - this.getSecret("mistralApiKey") as Promise, - this.getGlobalState("mistralCodestralUrl") as Promise, - this.getGlobalState("azureApiVersion") as Promise, - this.getGlobalState("openAiStreamingEnabled") as Promise, - this.getGlobalState("openRouterModelId") as Promise, - this.getGlobalState("openRouterModelInfo") as Promise, - this.getGlobalState("openRouterBaseUrl") as Promise, - this.getGlobalState("openRouterUseMiddleOutTransform") as Promise, - this.getGlobalState("lastShownAnnouncementId") as Promise, - this.getGlobalState("customInstructions") as Promise, - this.getGlobalState("alwaysAllowReadOnly") as Promise, - this.getGlobalState("alwaysAllowWrite") as Promise, - this.getGlobalState("alwaysAllowExecute") as Promise, - this.getGlobalState("alwaysAllowBrowser") as Promise, - this.getGlobalState("alwaysAllowMcp") as Promise, - this.getGlobalState("alwaysAllowModeSwitch") as Promise, - this.getGlobalState("taskHistory") as Promise, - this.getGlobalState("allowedCommands") as Promise, - this.getGlobalState("soundEnabled") as Promise, - this.getGlobalState("diffEnabled") as Promise, - this.getGlobalState("enableCheckpoints") as Promise, - this.getGlobalState("checkpointStorage") as Promise, - this.getGlobalState("soundVolume") as Promise, - this.getGlobalState("browserViewportSize") as Promise, - this.getGlobalState("fuzzyMatchThreshold") as Promise, - this.getGlobalState("preferredLanguage") as Promise, - this.getGlobalState("writeDelayMs") as Promise, - this.getGlobalState("screenshotQuality") as Promise, - this.getGlobalState("terminalOutputLineLimit") as Promise, - this.getGlobalState("mcpEnabled") as Promise, - this.getGlobalState("enableMcpServerCreation") as Promise, - this.getGlobalState("alwaysApproveResubmit") as Promise, - this.getGlobalState("requestDelaySeconds") as Promise, - this.getGlobalState("rateLimitSeconds") as Promise, - this.getGlobalState("currentApiConfigName") as Promise, - this.getGlobalState("listApiConfigMeta") as Promise, - this.getGlobalState("vsCodeLmModelSelector") as Promise, - this.getGlobalState("mode") as Promise, - this.getGlobalState("modeApiConfigs") as Promise | undefined>, - this.getGlobalState("customModePrompts") as Promise, - this.getGlobalState("customSupportPrompts") as Promise, - this.getGlobalState("enhancementApiConfigId") as Promise, - this.getGlobalState("autoApprovalEnabled") as Promise, - this.customModesManager.getCustomModes(), - this.getGlobalState("experiments") as Promise | undefined>, - this.getSecret("unboundApiKey") as Promise, - this.getGlobalState("unboundModelId") as Promise, - this.getGlobalState("unboundModelInfo") as Promise, - this.getSecret("requestyApiKey") as Promise, - this.getGlobalState("requestyModelId") as Promise, - this.getGlobalState("requestyModelInfo") as Promise, - this.getGlobalState("modelTemperature") as Promise, - this.getGlobalState("modelMaxTokens") as Promise, - this.getGlobalState("anthropicThinking") as Promise, - this.getGlobalState("maxOpenTabsContext") as Promise, - this.getGlobalState("browserToolEnabled") as Promise, - this.getGlobalState("lmStudioSpeculativeDecodingEnabled") as Promise, - this.getGlobalState("lmStudioDraftModelId") as Promise, - ]) + // Create an object to store all fetched values + const stateValues: Record = {} as Record + const secretValues: Record = {} as Record + // 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), - 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), + 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() diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 802bc9552b..5e600863af 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -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() + }) +}) diff --git a/src/extension.ts b/src/extension.ts index 3a91e990a3..fa6a148435 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 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 diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index ff9e2a24df..6f43cc65d5 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 19e3d3bac3..9592b559e2 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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({ diff --git a/src/shared/api.ts b/src/shared/api.ts index 2ce7162640..d066039850 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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 { diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 0570f6118a..c141a153d2 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -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 } diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index ccb87bd315..fd7bd1adb9 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -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] diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 3ae441cd52..8c37236adb 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -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("chat") const settingsRef = useRef(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 */} + setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))} + onSubmit={handleHumanRelaySubmit} + onCancel={handleHumanRelayCancel} + /> ) } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index d6fb682385..1f30a5a13f 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -418,7 +418,7 @@ const ContextWindowProgress = ({ contextWindow, contextTokens }: { contextWindow
Context Window:
-
+
{formatLargeNumber(contextTokens)}
diff --git a/webview-ui/src/components/human-relay/HumanRelayDialog.tsx b/webview-ui/src/components/human-relay/HumanRelayDialog.tsx new file mode 100644 index 0000000000..61d4cbe213 --- /dev/null +++ b/webview-ui/src/components/human-relay/HumanRelayDialog.tsx @@ -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 = ({ + 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 ( + !open && handleCancel()}> + + + Human Relay - Please Help Copy and Paste Information + + Please copy the text below to the web AI, then paste the AI's response into the input box below. + + + +
+
+