Browse Source

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

Matt Rubens 10 months ago
parent
commit
279df6e7a4

+ 5 - 0
.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.

+ 55 - 3
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<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)

+ 3 - 0
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<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)
 	}

+ 14 - 0
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", () => {

+ 139 - 0
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<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,
+		})
+	})
+}

+ 8 - 3
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<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 })
 		}
 	}
 

+ 1 - 5
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 })
 	}
 

+ 0 - 4
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",
-			},
 		})
 	}
 

+ 258 - 0
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")
+		})
+	})
+})

+ 132 - 0
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<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)
+	}
+}

+ 163 - 415
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<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()

+ 94 - 0
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()
+	})
+})

+ 46 - 0
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<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

+ 21 - 0
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"

+ 15 - 0
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({

+ 47 - 1
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 {

+ 16 - 19
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
 }

+ 99 - 89
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]

+ 53 - 0
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<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}
+			/>
 		</>
 	)
 }

+ 1 - 1
webview-ui/src/components/chat/TaskHeader.tsx

@@ -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)]">

+ 113 - 0
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<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>
+	)
+}

+ 27 - 0
webview-ui/src/components/settings/ApiOptions.tsx

@@ -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}