diff --git a/apps/cli/src/__tests__/extension-host.test.ts b/apps/cli/src/__tests__/extension-host.test.ts index 55f299d0e8..af46f945ff 100644 --- a/apps/cli/src/__tests__/extension-host.test.ts +++ b/apps/cli/src/__tests__/extension-host.test.ts @@ -1,8 +1,13 @@ // pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts -import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js" import { EventEmitter } from "events" -import type { ProviderName } from "@roo-code/types" +import fs from "fs" +import os from "os" +import path from "path" + +import type { ProviderName, WebviewMessage } from "@roo-code/types" + +import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js" vi.mock("@roo-code/vscode-shim", () => ({ createVSCodeAPI: vi.fn(() => ({ @@ -369,15 +374,15 @@ describe("ExtensionHost", () => { const emitSpy = vi.spyOn(host, "emit") // Queue messages before ready - host.sendToExtension({ type: "test1" }) - host.sendToExtension({ type: "test2" }) + host.sendToExtension({ type: "requestModes" }) + host.sendToExtension({ type: "requestCommands" }) // Mark ready (should flush) host.markWebviewReady() // Check that webviewMessage events were emitted for pending messages - expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" }) - expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" }) + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestModes" }) + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" }) }) }) }) @@ -385,7 +390,7 @@ describe("ExtensionHost", () => { describe("sendToExtension", () => { it("should queue message when webview not ready", () => { const host = createTestHost() - const message = { type: "test" } + const message: WebviewMessage = { type: "requestModes" } host.sendToExtension(message) @@ -396,7 +401,7 @@ describe("ExtensionHost", () => { it("should emit webviewMessage event when webview is ready", () => { const host = createTestHost() const emitSpy = vi.spyOn(host, "emit") - const message = { type: "test" } + const message: WebviewMessage = { type: "requestModes" } host.markWebviewReady() host.sendToExtension(message) @@ -408,7 +413,7 @@ describe("ExtensionHost", () => { const host = createTestHost() host.markWebviewReady() - host.sendToExtension({ type: "test" }) + host.sendToExtension({ type: "requestModes" }) const pending = getPrivate(host, "pendingMessages") expect(pending).toHaveLength(0) @@ -792,7 +797,7 @@ describe("ExtensionHost", () => { callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text) // Should show prompt with timeout hint - expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 10s")) + expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 60s")) }) }) @@ -1144,9 +1149,8 @@ describe("ExtensionHost", () => { }) }) - describe("handleStateMessage - mode change detection", () => { + describe("handleStateMessage - mode tracking", () => { let host: ExtensionHost - let sendToExtensionSpy: ReturnType beforeEach(() => { host = createTestHost({ @@ -1157,99 +1161,194 @@ describe("ExtensionHost", () => { }) // Mock process.stdout.write which is used by output() vi.spyOn(process.stdout, "write").mockImplementation(() => true) - sendToExtensionSpy = vi.spyOn(host, "sendToExtension") }) afterEach(() => { vi.restoreAllMocks() }) - it("should re-apply API configuration when mode changes in state", () => { - // First state update establishes current mode + it("should track current mode when state updates with a mode", () => { + // Initial state update establishes current mode callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - sendToExtensionSpy.mockClear() + expect(getPrivate(host, "currentMode")).toBe("code") - // Second state update with different mode should trigger re-apply + // Second state update should update tracked mode callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } }) - - // Should have sent updateSettings with the API configuration - expect(sendToExtensionSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "updateSettings", - updatedSettings: expect.objectContaining({ - apiProvider: "anthropic", - apiKey: "test-key", - apiModelId: "test-model", - }), - }), - ) + expect(getPrivate(host, "currentMode")).toBe("architect") }) - it("should not re-apply API configuration when mode stays the same", () => { - // First state update establishes current mode + it("should not change current mode when state has no mode", () => { + // Initial state update establishes current mode callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - sendToExtensionSpy.mockClear() + expect(getPrivate(host, "currentMode")).toBe("code") - // Second state update with same mode should not trigger re-apply - callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - - // Should not have sent updateSettings - expect(sendToExtensionSpy).not.toHaveBeenCalled() - }) - - it("should re-apply API configuration without apiKey when mode changes", () => { - // Create host without apiKey - API configuration should still be sent - // to preserve provider/model settings across mode switches - const hostNoKey = createTestHost({ - mode: "code", - apiProvider: "anthropic", - model: "test-model", - }) - const sendSpy = vi.spyOn(hostNoKey, "sendToExtension") - - // First state update establishes current mode - callPrivate(hostNoKey, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - sendSpy.mockClear() - - // Second state update with different mode - callPrivate(hostNoKey, "handleStateMessage", { - type: "state", - state: { mode: "architect", clineMessages: [] }, - }) - - // Should have sent updateSettings with provider and model (but no apiKey) - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "updateSettings", - updatedSettings: expect.objectContaining({ - apiProvider: "anthropic", - apiModelId: "test-model", - }), - }), - ) - // Verify apiKey is NOT in the config - const call = sendSpy.mock.calls[0]?.[0] as { updatedSettings: { apiKey?: string } } | undefined - expect(call?.updatedSettings.apiKey).toBeUndefined() + // State without mode should not change tracked mode + callPrivate(host, "handleStateMessage", { type: "state", state: { clineMessages: [] } }) + expect(getPrivate(host, "currentMode")).toBe("code") }) it("should track current mode across multiple changes", () => { // Start with code mode callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - sendToExtensionSpy.mockClear() + expect(getPrivate(host, "currentMode")).toBe("code") // Change to architect callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } }) - expect(sendToExtensionSpy).toHaveBeenCalledTimes(1) - sendToExtensionSpy.mockClear() + expect(getPrivate(host, "currentMode")).toBe("architect") // Change to debug callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } }) - expect(sendToExtensionSpy).toHaveBeenCalledTimes(1) + expect(getPrivate(host, "currentMode")).toBe("debug") + + // Another state update with debug + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } }) + expect(getPrivate(host, "currentMode")).toBe("debug") + }) + + it("should not send updateSettings on mode change (CLI settings are applied once during runTask)", () => { + // This test ensures mode changes don't trigger automatic re-application of API settings. + // CLI settings are applied once during runTask() via updateSettings. + // Mode-specific provider profiles are handled by the extension's handleModeSwitch. + const sendToExtensionSpy = vi.spyOn(host, "sendToExtension") + + // Initial state + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) sendToExtensionSpy.mockClear() - // Stay on debug - callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } }) + // Mode change should NOT trigger sendToExtension + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } }) expect(sendToExtensionSpy).not.toHaveBeenCalled() }) }) + + describe("ephemeral mode", () => { + describe("constructor", () => { + it("should store ephemeral option", () => { + const host = createTestHost({ ephemeral: true }) + const options = getPrivate(host, "options") + expect(options.ephemeral).toBe(true) + }) + + it("should default ephemeral to undefined", () => { + const host = createTestHost() + const options = getPrivate(host, "options") + expect(options.ephemeral).toBeUndefined() + }) + + it("should initialize ephemeralStorageDir to null", () => { + const host = createTestHost({ ephemeral: true }) + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() + }) + }) + + describe("createEphemeralStorageDir", () => { + let createdDirs: string[] = [] + + afterEach(async () => { + // Clean up any directories created during tests + for (const dir of createdDirs) { + try { + await fs.promises.rm(dir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + } + createdDirs = [] + }) + + it("should create a directory in the system temp folder", async () => { + const host = createTestHost({ ephemeral: true }) + const tmpDir = await callPrivate>(host, "createEphemeralStorageDir") + createdDirs.push(tmpDir) + + expect(tmpDir).toContain(os.tmpdir()) + expect(tmpDir).toContain("roo-cli-") + expect(fs.existsSync(tmpDir)).toBe(true) + }) + + it("should create a unique directory each time", async () => { + const host = createTestHost({ ephemeral: true }) + const dir1 = await callPrivate>(host, "createEphemeralStorageDir") + const dir2 = await callPrivate>(host, "createEphemeralStorageDir") + createdDirs.push(dir1, dir2) + + expect(dir1).not.toBe(dir2) + expect(fs.existsSync(dir1)).toBe(true) + expect(fs.existsSync(dir2)).toBe(true) + }) + + it("should include timestamp and random id in directory name", async () => { + const host = createTestHost({ ephemeral: true }) + const tmpDir = await callPrivate>(host, "createEphemeralStorageDir") + createdDirs.push(tmpDir) + + const dirName = path.basename(tmpDir) + // Format: roo-cli-{timestamp}-{randomId} + expect(dirName).toMatch(/^roo-cli-\d+-[a-z0-9]+$/) + }) + }) + + describe("dispose - ephemeral cleanup", () => { + it("should clean up ephemeral storage directory on dispose", async () => { + const host = createTestHost({ ephemeral: true }) + + // Create the ephemeral directory + const tmpDir = await callPrivate>(host, "createEphemeralStorageDir") + ;(host as unknown as Record).ephemeralStorageDir = tmpDir + + // Verify directory exists + expect(fs.existsSync(tmpDir)).toBe(true) + + // Dispose the host + await host.dispose() + + // Directory should be removed + expect(fs.existsSync(tmpDir)).toBe(false) + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() + }) + + it("should not fail dispose if ephemeral directory doesn't exist", async () => { + const host = createTestHost({ ephemeral: true }) + + // Set a non-existent directory + ;(host as unknown as Record).ephemeralStorageDir = "/non/existent/path/roo-cli-test" + + // Dispose should not throw + await expect(host.dispose()).resolves.toBeUndefined() + }) + + it("should clean up ephemeral directory with contents", async () => { + const host = createTestHost({ ephemeral: true }) + + // Create the ephemeral directory with some content + const tmpDir = await callPrivate>(host, "createEphemeralStorageDir") + ;(host as unknown as Record).ephemeralStorageDir = tmpDir + + // Add some files and subdirectories + await fs.promises.writeFile(path.join(tmpDir, "test.txt"), "test content") + await fs.promises.mkdir(path.join(tmpDir, "subdir")) + await fs.promises.writeFile(path.join(tmpDir, "subdir", "nested.txt"), "nested content") + + // Verify content exists + expect(fs.existsSync(path.join(tmpDir, "test.txt"))).toBe(true) + expect(fs.existsSync(path.join(tmpDir, "subdir", "nested.txt"))).toBe(true) + + // Dispose the host + await host.dispose() + + // Directory and all contents should be removed + expect(fs.existsSync(tmpDir)).toBe(false) + }) + + it("should not clean up anything if not in ephemeral mode", async () => { + const host = createTestHost({ ephemeral: false }) + + // ephemeralStorageDir should be null + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() + + // Dispose should complete normally + await expect(host.dispose()).resolves.toBeUndefined() + }) + }) + }) }) diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts index c93109892e..6f7ec6e01e 100644 --- a/apps/cli/src/extension-host.ts +++ b/apps/cli/src/extension-host.ts @@ -16,8 +16,15 @@ import fs from "fs" import os from "os" import readline from "readline" -import { ProviderName, ReasoningEffortExtended, RooCodeSettings, ExtensionMessage } from "@roo-code/types" +import { + ProviderName, + ReasoningEffortExtended, + RooCodeSettings, + ExtensionMessage, + WebviewMessage, +} from "@roo-code/types" import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim" +import { debugLog } from "@roo-code/core/debug-log" // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) // When bundled, import.meta.url points to dist/index.js, so go up to package root @@ -40,6 +47,11 @@ export interface ExtensionHostOptions { * Use this when running in TUI mode where Ink controls the terminal. */ disableOutput?: boolean + /** + * When true, uses a temporary storage directory that is cleaned up on exit. + * No state persists between runs - all configuration from CLI flags/environment. + */ + ephemeral?: boolean } interface ExtensionModule { @@ -54,8 +66,6 @@ interface WebviewViewProvider { resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise } -const DEBUG_LOG_PATH = path.join(os.homedir(), ".roo", "cli-debug.log") - export class ExtensionHost extends EventEmitter { private vscode: ReturnType | null = null private extensionModule: ExtensionModule | null = null @@ -94,6 +104,9 @@ export class ExtensionHost extends EventEmitter { // Track the current mode to detect mode changes private currentMode: string | null = null + // Track ephemeral storage directory for cleanup + private ephemeralStorageDir: string | null = null + constructor(options: ExtensionHostOptions) { super() this.options = options @@ -105,48 +118,7 @@ export class ExtensionHost extends EventEmitter { * This avoids console output which breaks the TUI. */ private log(message: string, data?: unknown): void { - try { - const logDir = path.dirname(DEBUG_LOG_PATH) - - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }) - } - - const timestamp = new Date().toISOString() - - const entry = data - ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` - : `[${timestamp}] ${message}\n` - - fs.appendFileSync(DEBUG_LOG_PATH, entry) - } catch { - // NO-OP - } - } - - /** - * Get the shape (keys) of an object for logging - */ - private getMessageShape(msg: unknown): Record { - if (!msg || typeof msg !== "object") { - return { _type: typeof msg } - } - - const shape: Record = {} - - for (const [key, value] of Object.entries(msg as Record)) { - if (value === null) { - shape[key] = "null" - } else if (Array.isArray(value)) { - shape[key] = `array[${value.length}]` - } else if (typeof value === "object") { - shape[key] = `object{${Object.keys(value).join(",")}}` - } else { - shape[key] = typeof value - } - } - - return shape + debugLog(message, data) } private suppressNodeWarnings(): void { @@ -204,6 +176,17 @@ export class ExtensionHost extends EventEmitter { } } + /** + * Create a unique ephemeral storage directory in the system temp folder. + * This directory will be cleaned up when dispose() is called. + */ + private async createEphemeralStorageDir(): Promise { + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}` + const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`) + await fs.promises.mkdir(tmpDir, { recursive: true }) + return tmpDir + } + async activate(): Promise { // Suppress Node.js warnings (like MaxListenersExceededWarning) before anything else this.suppressNodeWarnings() @@ -219,12 +202,20 @@ export class ExtensionHost extends EventEmitter { throw new Error(`Extension bundle not found at: ${bundlePath}`) } + // Create ephemeral storage directory if ephemeral mode is enabled + let storageDir: string | undefined + + if (this.options.ephemeral) { + storageDir = await this.createEphemeralStorageDir() + this.ephemeralStorageDir = storageDir + } + // 1. Create VSCode API mock this.vscode = createVSCodeAPI( this.options.extensionPath, this.options.workspacePath, undefined, // identity - { appRoot: CLI_PACKAGE_ROOT }, // options - point appRoot to CLI package for ripgrep + { appRoot: CLI_PACKAGE_ROOT, storageDir }, // options - point appRoot to CLI package for ripgrep, custom storageDir for ephemeral mode ) // 2. Set global vscode reference for the extension @@ -332,7 +323,7 @@ export class ExtensionHost extends EventEmitter { /** * Send a message to the extension (simulating webview -> extension communication). */ - sendToExtension(message: unknown): void { + sendToExtension(message: WebviewMessage): void { if (!this.isWebviewReady) { this.pendingMessages.push(message) return @@ -554,99 +545,55 @@ export class ExtensionHost extends EventEmitter { }) } - // Set up message listener for extension responses - this.setupMessageListener() + // Should this only be done once? + this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message) + this.on("extensionWebviewMessage", this.messageListener) + + let defaultSettings: RooCodeSettings - // Configure approval settings based on mode - // In non-interactive mode (-y flag), enable auto-approval for everything - // In interactive mode (default), we'll prompt the user for each action if (this.options.nonInteractive) { - const settings: RooCodeSettings = { + defaultSettings = { autoApprovalEnabled: true, alwaysAllowReadOnly: true, alwaysAllowReadOnlyOutsideWorkspace: true, alwaysAllowWrite: true, alwaysAllowWriteOutsideWorkspace: true, - alwaysAllowWriteProtected: false, // Keep protected files safe. + alwaysAllowWriteProtected: false, alwaysAllowBrowser: true, alwaysAllowMcp: true, alwaysAllowModeSwitch: true, alwaysAllowSubtasks: true, alwaysAllowExecute: true, alwaysAllowFollowupQuestions: true, + // NOTE: Setting to 0 should disable extension's internal timeout, + // but we need to verify this is working correctly. + followupAutoApproveTimeoutMs: 0, allowedCommands: ["*"], commandExecutionTimeout: 20, enableCheckpoints: false, // Checkpoints disabled until CLI UI is implemented. } - - this.applyRuntimeSettings(settings) - this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) - await new Promise((resolve) => setTimeout(resolve, 100)) } else { - const settings: RooCodeSettings = { + defaultSettings = { autoApprovalEnabled: false, - enableCheckpoints: false, // Checkpoints disabled until CLI UI is implemented. + enableCheckpoints: false, } - - this.applyRuntimeSettings(settings) - this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) - await new Promise((resolve) => setTimeout(resolve, 100)) } - // Always send API configuration - it may include API key from environment variables - const apiConfig = this.buildApiConfiguration() - this.sendToExtension({ type: "updateSettings", updatedSettings: apiConfig }) + const settings = { ...defaultSettings, ...this.buildApiConfiguration() } + this.applyRuntimeSettings(settings) + + this.sendToExtension({ + type: "updateSettings", + updatedSettings: settings, + }) + await new Promise((resolve) => setTimeout(resolve, 100)) this.sendToExtension({ type: "newTask", text: prompt }) await this.waitForCompletion() } - /** - * Set up listener for messages from the extension - */ - private setupMessageListener(): void { - this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message) - this.on("extensionWebviewMessage", this.messageListener) - } - private handleExtensionMessage(msg: ExtensionMessage): void { - // Log all incoming messages for debugging - this.log(`[MSG] type=${msg.type}`, this.getMessageShape(msg)) - - // For state messages, log additional details about the state - if (msg.type === "state" && msg.state) { - const state = msg.state - // Extract model ID based on provider (different providers use different fields) - const apiConfig = state.apiConfiguration - let modelId = apiConfig?.apiModelId - - if (apiConfig?.apiProvider === "openrouter") { - modelId = apiConfig?.openRouterModelId - } else if (apiConfig?.apiProvider === "openai") { - modelId = apiConfig?.openAiModelId - } else if (apiConfig?.apiProvider === "ollama") { - modelId = apiConfig?.ollamaModelId - } - - this.log(`[STATE] mode=${state.mode}, clineMessages=${state.clineMessages?.length || 0}`, { - apiProvider: apiConfig?.apiProvider, - modelId: modelId, - mode: state.mode, - cliProvider: this.options.apiProvider, - cliModel: this.options.model, - }) - - // Log any ask messages in the state that might indicate resume - if (state.clineMessages) { - for (const clineMsg of state.clineMessages) { - if (clineMsg?.ask === "resume_task" || clineMsg?.ask === "resume_completed_task") { - this.log(`[RESUME DETECTED] ask=${clineMsg.ask}, ts=${clineMsg.ts}`) - } - } - } - } - switch (msg.type) { case "state": this.handleStateMessage(msg) @@ -658,12 +605,11 @@ export class ExtensionHost extends EventEmitter { break case "modes": - // Forward modes list to the TUI. - this.emit("extensionWebviewMessage", msg) + // Forward modes list to the TUI via a dedicated event. + // DO NOT emit to "extensionWebviewMessage" - that would create an infinite loop + // since messageListener listens on that event and calls this function. + this.emit("modesUpdated", msg) break - - default: - // NO-OP } } @@ -752,29 +698,11 @@ export class ExtensionHost extends EventEmitter { // Track current mode for mode switch detection (in tool execution). const newMode = state.mode - if (newMode && this.currentMode !== null && this.currentMode !== newMode) { - this.log(`[MODE CHANGE] from=${this.currentMode} to=${newMode}, re-applying CLI settings`) - const updatedSettings = this.buildApiConfiguration() - this.sendToExtension({ type: "updateSettings", updatedSettings }) - } - if (newMode) { this.currentMode = newMode } - // Detect when the model in state differs from CLI-specified model. - // This catches task resume scenarios where the extension loads stored apiConfiguration - // which may differ from CLI-provided settings. We re-apply CLI settings proactively. - const apiConfig = state.apiConfiguration as Record | undefined - const stateModelId = this.getStateModelId(apiConfig) - const expectedModelId = this.getExpectedModelId() - - if (expectedModelId && stateModelId && stateModelId !== expectedModelId) { - this.log(`[MODEL MISMATCH] state has ${stateModelId}, CLI expects ${expectedModelId}, re-applying settings`) - const updatedSettings = this.buildApiConfiguration() - this.sendToExtension({ type: "updateSettings", updatedSettings }) - } - + // Why do we do this? const clineMessages = state.clineMessages if (clineMessages && clineMessages.length > 0) { @@ -807,28 +735,19 @@ export class ExtensionHost extends EventEmitter { * Handle messageUpdated - individual streaming updates for a single message * This is where real-time streaming happens! */ - private handleMessageUpdated(msg: ExtensionMessage): void { - const clineMessage = msg.clineMessage + private handleMessageUpdated({ clineMessage: msg }: ExtensionMessage): void { + // if (msg?.ask) { + // this.log(`[MSG] ts=${msg.ts}, ask=${msg.ask} -> ${msg.partial ? "partial" : msg.text}`) + // } else if (msg?.say) { + // this.log(`[MSG] ts=${msg.ts}, say=${msg.say} -> ${msg.partial ? "partial" : msg.text}`) + // } else if (msg) { + // this.log(`[MSG] ts=${msg.ts}, type=${msg.type}, text=${msg.text}`) + // } - if (!clineMessage) { - return - } - - const ts = clineMessage.ts - const isPartial = clineMessage.partial - const text = clineMessage.text - const type = clineMessage.type - const say = clineMessage.say - const ask = clineMessage.ask - - if (!ts) { - return - } - - if (type === "say" && say && typeof text === "string") { - this.handleSayMessage(ts, say, text, isPartial) - } else if (type === "ask" && ask && typeof text === "string") { - this.handleAskMessage(ts, ask, text, isPartial) + if (msg?.type === "say" && msg.say && typeof msg.text === "string") { + this.handleSayMessage(msg.ts, msg.say, msg.text, msg.partial) + } else if (msg?.type === "ask" && msg.ask && typeof msg.text === "string") { + this.handleAskMessage(msg.ts, msg.ask, msg.text, msg.partial) } } @@ -903,6 +822,7 @@ export class ExtensionHost extends EventEmitter { const delta = text.slice(streamed.text.length) this.writeStream(delta) } + this.finishStream(ts) } else { // Not streamed yet - output complete message @@ -1005,37 +925,40 @@ export class ExtensionHost extends EventEmitter { * In non-interactive mode: auto-approve (handled by extension settings) */ private handleAskMessage(ts: number, ask: string, text: string, isPartial: boolean | undefined): void { - // Special handling for command_output - stream it in real-time - // This needs to happen before the isPartial skip + // Special handling for command_output - stream it in real-time. + // This needs to happen before the isPartial skip. if (ask === "command_output") { this.handleCommandOutputAsk(ts, text, isPartial) return } - // Skip partial messages - wait for the complete ask + // Skip partial messages - wait for the complete ask. if (isPartial) { return } - // Check if we already handled this ask + // Check if we already handled this ask. if (this.pendingAsks.has(ts)) { return } - // In non-interactive mode, the extension's auto-approval settings handle everything - // We just need to display the action being taken + // IMPORTANT: Check disableOutput FIRST, before nonInteractive! + // In TUI mode (disableOutput=true), don't handle asks here - let the TUI handle them. + // The TUI listens to the same extensionWebviewMessage events and renders its own UI. + // If we don't check this first, the non-interactive handler would capture stdin input + // that's meant for the TUI (e.g., arrow keys show as "[B[B[B[B" escape codes). + if (this.options.disableOutput) { + return + } + + // In non-interactive mode (without TUI), the extension's auto-approval settings handle + // most things, but followup questions need special handling with timeout. if (this.options.nonInteractive) { this.handleAskMessageNonInteractive(ts, ask, text) return } - // In TUI mode (disableOutput), don't handle asks here - let the TUI handle them - // The TUI listens to the same extensionWebviewMessage events and renders its own UI - if (this.options.disableOutput) { - return - } - - // Interactive mode - prompt user for input + // Interactive mode - prompt user for input. this.handleAskMessageInteractive(ts, ask, text) } @@ -1070,9 +993,12 @@ export class ExtensionHost extends EventEmitter { case "tool": if (!alreadyDisplayed && text) { + let toolInfo + let toolName + try { - const toolInfo = JSON.parse(text) - const toolName = toolInfo.tool || "unknown" + toolInfo = JSON.parse(text) + toolName = toolInfo.tool || "unknown" this.output(`\n[tool] ${toolName}`) // Display all tool parameters (excluding 'tool' which is the name). @@ -1241,8 +1167,10 @@ export class ExtensionHost extends EventEmitter { // Check if user entered a number corresponding to a suggestion const num = parseInt(responseText, 10) + if (!isNaN(num) && num >= 1 && num <= suggestions.length) { const selectedSuggestion = suggestions[num - 1] + if (selectedSuggestion) { responseText = selectedSuggestion.answer || String(selectedSuggestion) this.output(`Selected: ${responseText}`) @@ -1267,8 +1195,9 @@ export class ExtensionHost extends EventEmitter { /** * Handle followup questions with a timeout (for non-interactive mode) - * Shows the prompt but auto-selects the first option after 10 seconds + * Shows the prompt but auto-selects the first option after the timeout * if the user doesn't type anything. Cancels the timeout on any keypress. + * Default timeout is 60 seconds, configurable via followupAutoApproveTimeoutMs. */ private async handleFollowupQuestionWithTimeout(ts: number, text: string): Promise { let question = text @@ -1289,33 +1218,40 @@ export class ExtensionHost extends EventEmitter { // Show numbered suggestions if (suggestions.length > 0) { this.output("\nSuggested answers:") + suggestions.forEach((suggestion, index) => { const suggestionText = suggestion.answer || String(suggestion) const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" this.output(` ${index + 1}. ${suggestionText}${modeHint}`) }) + this.output("") } - // Default to first suggestion or empty string + // Default to first suggestion or empty string. const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null const defaultAnswer = firstSuggestion?.answer ?? "" + // Default timeout is 10 seconds for testing (will be configurable later). + const timeoutMs = 60_000 + try { const answer = await this.promptForInputWithTimeout( suggestions.length > 0 - ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in 10s): ` - : "Your answer (auto-select in 10s): ", - 10000, // 10 second timeout + ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): ` + : `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `, + timeoutMs, defaultAnswer, ) let responseText = answer.trim() - // Check if user entered a number corresponding to a suggestion + // Check if user entered a number corresponding to a suggestion. const num = parseInt(responseText, 10) + if (!isNaN(num) && num >= 1 && num <= suggestions.length) { const selectedSuggestion = suggestions[num - 1] + if (selectedSuggestion) { responseText = selectedSuggestion.answer || String(selectedSuggestion) this.output(`Selected: ${responseText}`) @@ -1323,8 +1259,8 @@ export class ExtensionHost extends EventEmitter { } this.sendFollowupResponse(responseText) - } catch { - // If prompt fails, use default + } catch (_error) { + // If prompt fails, use default. this.output(`[Using default: ${defaultAnswer || "(empty)"}]`) this.sendFollowupResponse(defaultAnswer) } @@ -1339,15 +1275,18 @@ export class ExtensionHost extends EventEmitter { return new Promise((resolve) => { // Temporarily restore console for interactive prompts const wasQuiet = this.options.quiet + if (wasQuiet) { this.restoreConsole() } // Put stdin in raw mode to detect individual keypresses const wasRaw = process.stdin.isRaw + if (process.stdin.isTTY) { process.stdin.setRawMode(true) } + process.stdin.resume() let inputBuffer = "" @@ -1371,10 +1310,13 @@ export class ExtensionHost extends EventEmitter { const cleanup = () => { clearTimeout(timeout) process.stdin.removeListener("data", onData) + if (process.stdin.isTTY && wasRaw !== undefined) { process.stdin.setRawMode(wasRaw) } + process.stdin.pause() + if (wasQuiet) { this.setupQuietMode() } @@ -1486,13 +1428,14 @@ export class ExtensionHost extends EventEmitter { try { const approved = await this.promptForYesNo("Approve this action? (y/n): ") this.sendApprovalResponse(approved) - // Note: Mode switch detection and API config re-application is handled in handleStateMessage - // This works for both interactive and non-interactive (auto-approved) modes + // Note: Mode switch detection and API config re-application is handled in handleStateMessage. + // This works for both interactive and non-interactive (auto-approved) modes. } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment. } /** @@ -1776,22 +1719,22 @@ export class ExtensionHost extends EventEmitter { * Clean up resources */ async dispose(): Promise { - // Clear pending asks + // Clear pending asks. this.pendingAsks.clear() - // Close readline interface if open + // Close readline interface if open. if (this.rl) { this.rl.close() this.rl = null } - // Remove message listener + // Remove message listener. if (this.messageListener) { this.off("extensionWebviewMessage", this.messageListener) this.messageListener = null } - // Deactivate extension if it has a deactivate function + // Deactivate extension if it has a deactivate function. if (this.extensionModule?.deactivate) { try { await this.extensionModule.deactivate() @@ -1800,17 +1743,27 @@ export class ExtensionHost extends EventEmitter { } } - // Clear references + // Clear references. this.vscode = null this.extensionModule = null this.extensionAPI = null this.webviewProviders.clear() - // Clear globals + // Clear globals. delete (global as Record).vscode delete (global as Record).__extensionHost - // Restore console if it was suppressed + // Restore console if it was suppressed. this.restoreConsole() + + // Clean up ephemeral storage directory if it exists. + if (this.ephemeralStorageDir) { + try { + await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true }) + this.ephemeralStorageDir = null + } catch (_error) { + // NO-OP + } + } } } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 1eead41a17..7c9a0e7e7c 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -58,6 +58,7 @@ program "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", DEFAULTS.reasoningEffort, ) + .option("--ephemeral", "Run without persisting state (uses temporary storage)", false) .option("--no-tui", "Disable TUI, use plain text output") .action( async ( @@ -74,6 +75,7 @@ program model?: string mode?: string reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" + ephemeral: boolean tui: boolean }, ) => { @@ -153,6 +155,7 @@ program quiet: boolean nonInteractive: boolean disableOutput: boolean + ephemeral?: boolean }) => { return new ExtensionHost({ mode: opts.mode, @@ -169,6 +172,7 @@ program quiet: opts.quiet, nonInteractive: opts.nonInteractive, disableOutput: opts.disableOutput, + ephemeral: opts.ephemeral, }) } @@ -186,6 +190,7 @@ program debug: options.debug, exitOnComplete: options.exitOnComplete, reasoningEffort: options.reasoningEffort, + ephemeral: options.ephemeral, createExtensionHost: createExtensionHost, version: packageJson.version, }), @@ -219,6 +224,7 @@ program verbose: options.debug, quiet: !options.verbose && !options.debug, nonInteractive: options.yes, + ephemeral: options.ephemeral, }) // Handle SIGINT (Ctrl+C) diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 167f82e5f0..ff43b7b886 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { EventEmitter } from "events" import { randomUUID } from "crypto" +import type { ClineMessage, TodoItem, WebviewMessage } from "@roo-code/types" // Import only message-utils to avoid custom-tools dependencies (execa/child_process) import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils" -import type { ClineMessage, TodoItem } from "@roo-code/types" import { useCLIStore } from "./store.js" import { getContextWindow } from "../utils/getContextWindow.js" @@ -56,7 +56,7 @@ const PICKER_HEIGHT = 10 // Max height for picker when open interface ExtensionHostInterface extends EventEmitter { activate(): Promise runTask(prompt: string): Promise - sendToExtension(message: unknown): void + sendToExtension(message: WebviewMessage): void dispose(): Promise } @@ -77,6 +77,7 @@ interface ExtensionHostOptions { quiet: boolean nonInteractive: boolean disableOutput: boolean + ephemeral?: boolean } /** @@ -155,6 +156,7 @@ function AppInner({ debug, exitOnComplete, reasoningEffort, + ephemeral, createExtensionHost, version, }: TUIAppProps) { @@ -228,6 +230,11 @@ function AppInner({ const exitHintTimeout = useRef(null) const pendingExit = useRef(false) + // Countdown timer for auto-accepting followup questions (10 seconds for testing) + const FOLLOWUP_TIMEOUT_SECONDS = 10 + const [countdownSeconds, setCountdownSeconds] = useState(null) + const countdownIntervalRef = useRef(null) + // Track whether user wants to type custom response for followup questions const [showCustomInput, setShowCustomInput] = useState(false) // Ref to track transition state (handles async state update timing) @@ -313,10 +320,7 @@ function AppInner({ return } - hostRef.current.sendToExtension({ - type: "searchFiles", - query, - }) + hostRef.current.sendToExtension({ type: "searchFiles", query }) }, []) // Create autocomplete triggers @@ -413,9 +417,64 @@ function AppInner({ if (exitHintTimeout.current) { clearTimeout(exitHintTimeout.current) } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + } } }, []) + // Countdown timer for auto-accepting followup questions + // Start countdown when a followup question with suggestions appears + useEffect(() => { + // Clear any existing countdown + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + + // Only start countdown for followup questions with suggestions (not custom input mode) + if ( + pendingAsk?.type === "followup" && + pendingAsk.suggestions && + pendingAsk.suggestions.length > 0 && + !showCustomInput + ) { + // Start countdown + setCountdownSeconds(FOLLOWUP_TIMEOUT_SECONDS) + + countdownIntervalRef.current = setInterval(() => { + setCountdownSeconds((prev) => { + if (prev === null || prev <= 1) { + // Time's up! Auto-select first option + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + // Auto-submit the first suggestion + if (pendingAsk?.suggestions && pendingAsk.suggestions.length > 0) { + const firstSuggestion = pendingAsk.suggestions[0] + if (firstSuggestion) { + handleSubmit(firstSuggestion.answer) + } + } + return null + } + return prev - 1 + }) + }, 1000) + } else { + // No countdown needed + setCountdownSeconds(null) + } + + return () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + } + }, [pendingAsk?.id, pendingAsk?.type, showCustomInput]) // Re-run when pendingAsk changes or user switches to custom input + // Refresh search results when fileSearchResults changes while file picker is open // This handles the async timing where API results arrive after initial search // IMPORTANT: Only run when fileSearchResults array identity changes (new API response) @@ -774,6 +833,7 @@ function AppInner({ quiet: !verbose && !debug, nonInteractive, disableOutput: true, + ephemeral, }) hostRef.current = host @@ -943,6 +1003,28 @@ function AppInner({ } }) + // Cancel countdown timer when user navigates in the followup suggestion menu + // This provides better UX - any user interaction cancels the auto-accept timer + const showFollowupSuggestions = + pendingAsk?.type === "followup" && + pendingAsk.suggestions && + pendingAsk.suggestions.length > 0 && + !showCustomInput + + useInput((_input, key) => { + // Only handle when followup suggestions are shown and countdown is active + if (showFollowupSuggestions && countdownSeconds !== null) { + // Cancel countdown on any arrow key navigation + if (key.upArrow || key.downArrow) { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + setCountdownSeconds(null) + } + } + }) + // Handle picker state changes from AutocompleteInput // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlePickerStateChange = useCallback((state: AutocompletePickerState) => setPickerState(state), []) @@ -999,9 +1081,10 @@ function AppInner({ } // Status bar content + // Don't show spinner when waiting for user input (pendingAsk is set) const statusBarMessage = showExitHint ? ( Press Ctrl+C again to exit - ) : isLoading ? ( + ) : isLoading && !pendingAsk ? ( {view === "ToolUse" ? "Using tool" : "Thinking"} @@ -1082,6 +1165,13 @@ function AppInner({ if (showCustomInput || isTransitioningToCustomInput.current) return if (value === "__CUSTOM__") { + // Clear countdown timer synchronously BEFORE state update + // This prevents race condition where interval fires before useEffect cleanup + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + setCountdownSeconds(null) isTransitioningToCustomInput.current = true setShowCustomInput(true) } else if (value.trim()) { @@ -1090,7 +1180,12 @@ function AppInner({ }} /> - ↑↓ navigate • Enter select + + ↑↓ navigate • Enter select + {countdownSeconds !== null && ( + • Auto-select in {countdownSeconds}s + )} + ) : ( diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index 5f708bf4c6..a9e5ada1cb 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -72,6 +72,8 @@ export interface AppProps { debug: boolean exitOnComplete: boolean reasoningEffort?: string + /** Run in ephemeral mode - no state persists after this session */ + ephemeral?: boolean version: string } diff --git a/packages/core/package.json b/packages/core/package.json index 41f2dfa95c..ad25cc863d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./message-utils": "./src/message-utils/index.ts" + "./message-utils": "./src/message-utils/index.ts", + "./debug-log": "./src/debug-log/index.ts" }, "scripts": { "lint": "eslint src --ext=ts --max-warnings=0", diff --git a/packages/core/src/debug-log/index.ts b/packages/core/src/debug-log/index.ts new file mode 100644 index 0000000000..797ec60ba9 --- /dev/null +++ b/packages/core/src/debug-log/index.ts @@ -0,0 +1,91 @@ +/** + * File-based debug logging utility + * + * This writes logs to ~/.roo/cli-debug.log, avoiding stdout/stderr + * which would break TUI applications. The log format is timestamped JSON. + * + * Usage: + * import { debugLog, DebugLogger } from "@roo-code/core/debug-log" + * + * // Simple logging + * debugLog("handleModeSwitch", { mode: newMode, configId }) + * + * // Or create a named logger for a component + * const log = new DebugLogger("ClineProvider") + * log.info("handleModeSwitch", { mode: newMode }) + */ + +import * as fs from "fs" +import * as path from "path" +import * as os from "os" + +const DEBUG_LOG_PATH = path.join(os.homedir(), ".roo", "cli-debug.log") + +/** + * Simple file-based debug log function. + * Writes timestamped entries to ~/.roo/cli-debug.log + */ +export function debugLog(message: string, data?: unknown): void { + try { + const logDir = path.dirname(DEBUG_LOG_PATH) + + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } + + const timestamp = new Date().toISOString() + + const entry = data + ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` + : `[${timestamp}] ${message}\n` + + fs.appendFileSync(DEBUG_LOG_PATH, entry) + } catch { + // NO-OP - don't let logging errors break functionality + } +} + +/** + * Debug logger with component context. + * Prefixes all messages with the component name. + */ +export class DebugLogger { + private component: string + + constructor(component: string) { + this.component = component + } + + /** + * Log a debug message with optional data + */ + debug(message: string, data?: unknown): void { + debugLog(`[${this.component}] ${message}`, data) + } + + /** + * Alias for debug + */ + info(message: string, data?: unknown): void { + this.debug(message, data) + } + + /** + * Log a warning + */ + warn(message: string, data?: unknown): void { + debugLog(`[${this.component}] WARN: ${message}`, data) + } + + /** + * Log an error + */ + error(message: string, data?: unknown): void { + debugLog(`[${this.component}] ERROR: ${message}`, data) + } +} + +/** + * Pre-configured logger for provider/mode debugging + */ +export const providerDebugLog = new DebugLogger("ProviderSettings") diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 68e132f572..81018ee44a 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -1,11 +1,18 @@ -import type { GlobalSettings } from "./global-settings.js" +import { z } from "zod" + +import type { GlobalSettings, RooCodeSettings } from "./global-settings.js" import type { ProviderSettings, ProviderSettingsEntry } from "./provider-settings.js" import type { HistoryItem } from "./history.js" -import type { ModeConfig } from "./mode.js" +import type { ModeConfig, PromptComponent } from "./mode.js" import type { TelemetrySetting } from "./telemetry.js" import type { Experiments } from "./experiment.js" import type { ClineMessage, QueuedMessage } from "./message.js" -import type { MarketplaceItem, MarketplaceInstalledMetadata } from "./marketplace.js" +import { + type MarketplaceItem, + type MarketplaceInstalledMetadata, + type InstallMarketplaceItemOptions, + marketplaceItemSchema, +} from "./marketplace.js" import type { TodoItem } from "./todo.js" import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js" import type { SerializedCustomToolDefinition } from "./custom-tool.js" @@ -13,7 +20,10 @@ import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" import type { ModelRecord, RouterModels } from "./model.js" -// Represents JSON data that is sent from extension to the webview or cli. +/** + * ExtensionMessage + * Extension -> Webview | CLI + */ export interface ExtensionMessage { type: | "action" @@ -325,3 +335,310 @@ export interface Command { description?: string argumentHint?: string } + +/** + * WebviewMessage + * Webview | CLI -> Extension + */ + +export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse" + +export type AudioType = "notification" | "celebration" | "progress_loop" + +export interface UpdateTodoListPayload { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + todos: any[] +} + +export type EditQueuedMessagePayload = Pick + +export interface WebviewMessage { + type: + | "updateTodoList" + | "deleteMultipleTasksWithIds" + | "currentApiConfigName" + | "saveApiConfiguration" + | "upsertApiConfiguration" + | "deleteApiConfiguration" + | "loadApiConfiguration" + | "loadApiConfigurationById" + | "renameApiConfiguration" + | "getListApiConfiguration" + | "customInstructions" + | "webviewDidLaunch" + | "newTask" + | "askResponse" + | "terminalOperation" + | "clearTask" + | "didShowAnnouncement" + | "selectImages" + | "exportCurrentTask" + | "shareCurrentTask" + | "showTaskWithId" + | "deleteTaskWithId" + | "exportTaskWithId" + | "importSettings" + | "exportSettings" + | "resetState" + | "flushRouterModels" + | "requestRouterModels" + | "requestOpenAiModels" + | "requestOllamaModels" + | "requestLmStudioModels" + | "requestRooModels" + | "requestRooCreditBalance" + | "requestVsCodeLmModels" + | "requestHuggingFaceModels" + | "openImage" + | "saveImage" + | "openFile" + | "openMention" + | "cancelTask" + | "cancelAutoApproval" + | "updateVSCodeSetting" + | "getVSCodeSetting" + | "vsCodeSetting" + | "updateCondensingPrompt" + | "playSound" + | "playTts" + | "stopTts" + | "ttsEnabled" + | "ttsSpeed" + | "openKeyboardShortcuts" + | "openMcpSettings" + | "openProjectMcpSettings" + | "restartMcpServer" + | "refreshAllMcpServers" + | "toggleToolAlwaysAllow" + | "toggleToolEnabledForPrompt" + | "toggleMcpServer" + | "updateMcpTimeout" + | "enhancePrompt" + | "enhancedPrompt" + | "draggedImages" + | "deleteMessage" + | "deleteMessageConfirm" + | "submitEditedMessage" + | "editMessageConfirm" + | "enableMcpServerCreation" + | "remoteControlEnabled" + | "taskSyncEnabled" + | "searchCommits" + | "setApiConfigPassword" + | "mode" + | "updatePrompt" + | "getSystemPrompt" + | "copySystemPrompt" + | "systemPrompt" + | "enhancementApiConfigId" + | "autoApprovalEnabled" + | "updateCustomMode" + | "deleteCustomMode" + | "setopenAiCustomModelInfo" + | "openCustomModesSettings" + | "checkpointDiff" + | "checkpointRestore" + | "deleteMcpServer" + | "codebaseIndexEnabled" + | "telemetrySetting" + | "testBrowserConnection" + | "browserConnectionResult" + | "searchFiles" + | "toggleApiConfigPin" + | "hasOpenedModeSelector" + | "clearCloudAuthSkipModel" + | "cloudButtonClicked" + | "rooCloudSignIn" + | "cloudLandingPageSignIn" + | "rooCloudSignOut" + | "rooCloudManualUrl" + | "claudeCodeSignIn" + | "claudeCodeSignOut" + | "switchOrganization" + | "condenseTaskContextRequest" + | "requestIndexingStatus" + | "startIndexing" + | "clearIndexData" + | "indexingStatusUpdate" + | "indexCleared" + | "focusPanelRequest" + | "openExternal" + | "filterMarketplaceItems" + | "marketplaceButtonClicked" + | "installMarketplaceItem" + | "installMarketplaceItemWithParameters" + | "cancelMarketplaceInstall" + | "removeInstalledMarketplaceItem" + | "marketplaceInstallResult" + | "fetchMarketplaceData" + | "switchTab" + | "shareTaskSuccess" + | "exportMode" + | "exportModeResult" + | "importMode" + | "importModeResult" + | "checkRulesDirectory" + | "checkRulesDirectoryResult" + | "saveCodeIndexSettingsAtomic" + | "requestCodeIndexSecretStatus" + | "requestCommands" + | "openCommandFile" + | "deleteCommand" + | "createCommand" + | "insertTextIntoTextarea" + | "showMdmAuthRequiredNotification" + | "imageGenerationSettings" + | "queueMessage" + | "removeQueuedMessage" + | "editQueuedMessage" + | "dismissUpsell" + | "getDismissedUpsells" + | "updateSettings" + | "allowedCommands" + | "deniedCommands" + | "killBrowserSession" + | "openBrowserSessionPanel" + | "showBrowserSessionPanelAtStep" + | "refreshBrowserSessionPanel" + | "browserPanelDidLaunch" + | "openDebugApiHistory" + | "openDebugUiHistory" + | "downloadErrorDiagnostics" + | "requestClaudeCodeRateLimits" + | "refreshCustomTools" + | "requestModes" + | "switchMode" + text?: string + editedMessageContent?: string + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" + disabled?: boolean + context?: string + dataUri?: string + askResponse?: ClineAskResponse + apiConfiguration?: ProviderSettings + images?: string[] + bool?: boolean + value?: number + stepIndex?: number + isLaunchAction?: boolean + forceShow?: boolean + commands?: string[] + audioType?: AudioType + serverName?: string + toolName?: string + alwaysAllow?: boolean + isEnabled?: boolean + mode?: string + promptMode?: string | "enhance" + customPrompt?: PromptComponent + dataUrls?: string[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + values?: Record + query?: string + setting?: string + slug?: string + modeConfig?: ModeConfig + timeout?: number + payload?: WebViewMessagePayload + source?: "global" | "project" + requestId?: string + ids?: string[] + hasSystemPromptOverride?: boolean + terminalOperation?: "continue" | "abort" + messageTs?: number + restoreCheckpoint?: boolean + historyPreviewCollapsed?: boolean + filters?: { type?: string; search?: string; tags?: string[] } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + settings?: any + url?: string // For openExternal + mpItem?: MarketplaceItem + mpInstallOptions?: InstallMarketplaceItemOptions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config?: Record // Add config to the payload + visibility?: ShareVisibility // For share visibility + hasContent?: boolean // For checkRulesDirectoryResult + checkOnly?: boolean // For deleteCustomMode check + upsellId?: string // For dismissUpsell + list?: string[] // For dismissedUpsells response + organizationId?: string | null // For organization switching + useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow + codeIndexSettings?: { + // Global state settings + codebaseIndexEnabled: boolean + codebaseIndexQdrantUrl: string + codebaseIndexEmbedderProvider: + | "openai" + | "ollama" + | "openai-compatible" + | "gemini" + | "mistral" + | "vercel-ai-gateway" + | "bedrock" + | "openrouter" + codebaseIndexEmbedderBaseUrl?: string + codebaseIndexEmbedderModelId: string + codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers + codebaseIndexOpenAiCompatibleBaseUrl?: string + codebaseIndexBedrockRegion?: string + codebaseIndexBedrockProfile?: string + codebaseIndexSearchMaxResults?: number + codebaseIndexSearchMinScore?: number + codebaseIndexOpenRouterSpecificProvider?: string // OpenRouter provider routing + + // Secret settings + codeIndexOpenAiKey?: string + codeIndexQdrantApiKey?: string + codebaseIndexOpenAiCompatibleApiKey?: string + codebaseIndexGeminiApiKey?: string + codebaseIndexMistralApiKey?: string + codebaseIndexVercelAiGatewayApiKey?: string + codebaseIndexOpenRouterApiKey?: string + } + updatedSettings?: RooCodeSettings +} + +export const checkoutDiffPayloadSchema = z.object({ + ts: z.number().optional(), + previousCommitHash: z.string().optional(), + commitHash: z.string(), + mode: z.enum(["full", "checkpoint", "from-init", "to-current"]), +}) + +export type CheckpointDiffPayload = z.infer + +export const checkoutRestorePayloadSchema = z.object({ + ts: z.number(), + commitHash: z.string(), + mode: z.enum(["preview", "restore"]), +}) + +export type CheckpointRestorePayload = z.infer + +export interface IndexingStatusPayload { + state: "Standby" | "Indexing" | "Indexed" | "Error" + message: string +} + +export interface IndexClearedPayload { + success: boolean + error?: string +} + +export const installMarketplaceItemWithParametersPayloadSchema = z.object({ + item: marketplaceItemSchema, + parameters: z.record(z.string(), z.any()), +}) + +export type InstallMarketplaceItemWithParametersPayload = z.infer< + typeof installMarketplaceItemWithParametersPayloadSchema +> + +export type WebViewMessagePayload = + | CheckpointDiffPayload + | CheckpointRestorePayload + | IndexingStatusPayload + | IndexClearedPayload + | InstallMarketplaceItemWithParametersPayload + | UpdateTodoListPayload + | EditQueuedMessagePayload diff --git a/packages/vscode-shim/src/api/create-vscode-api-mock.ts b/packages/vscode-shim/src/api/create-vscode-api-mock.ts index 1eb22d3675..fd4a94a8a6 100644 --- a/packages/vscode-shim/src/api/create-vscode-api-mock.ts +++ b/packages/vscode-shim/src/api/create-vscode-api-mock.ts @@ -68,6 +68,13 @@ export interface VSCodeAPIMockOptions { * Defaults to the directory containing this module. */ appRoot?: string + + /** + * Custom storage directory for persistent state. + * Defaults to ~/.vscode-mock. + * Set to a temp directory for ephemeral/no-persist mode. + */ + storageDir?: string } /** @@ -82,6 +89,7 @@ export function createVSCodeAPIMock( const context = new ExtensionContextImpl({ extensionPath: extensionRootPath, workspacePath: workspacePath, + storageDir: options?.storageDir, }) const workspace = new WorkspaceAPI(workspacePath, context) const window = new WindowAPI() diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 420ab332b2..e7ae0d96b5 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -2,6 +2,8 @@ import { ExtensionContext } from "vscode" import { z, ZodError } from "zod" import deepEqual from "fast-deep-equal" +import { debugLog } from "../../utils/debug-log" + import { type ProviderSettingsWithId, providerSettingsWithIdSchema, @@ -377,12 +379,21 @@ export class ProviderSettingsManager { return await this.lock(async () => { const providerProfiles = await this.load() - return Object.entries(providerProfiles.apiConfigs).map(([name, apiConfig]) => ({ + const configs = Object.entries(providerProfiles.apiConfigs).map(([name, apiConfig]) => ({ name, id: apiConfig.id || "", apiProvider: apiConfig.apiProvider, modelId: this.cleanModelId(getModelId(apiConfig)), })) + + // DEBUG: Log listed configs + debugLog("[ProviderSettingsManager.listConfig]", { + configCount: configs.length, + configs: configs.map((c) => ({ name: c.name, id: c.id, provider: c.apiProvider })), + modeApiConfigs: providerProfiles.modeApiConfigs, + }) + + return configs }) } catch (error) { throw new Error(`Failed to list configs: ${error}`) @@ -458,16 +469,40 @@ export class ProviderSettingsManager { public async activateProfile( params: { name: string } | { id: string }, ): Promise { + // DEBUG: Log entry point + debugLog("[ProviderSettingsManager.activateProfile] START", { params }) + const { name, ...providerSettings } = await this.getProfile(params) + // DEBUG: Log what was retrieved from getProfile + debugLog("[ProviderSettingsManager.activateProfile] getProfile result", { + name, + id: providerSettings.id, + apiProvider: providerSettings.apiProvider, + hasSettings: Object.keys(providerSettings).length > 0, + settingsKeys: Object.keys(providerSettings).filter( + (k) => providerSettings[k as keyof typeof providerSettings] !== undefined, + ), + }) + try { return await this.lock(async () => { const providerProfiles = await this.load() providerProfiles.currentApiConfigName = name await this.store(providerProfiles) + + debugLog("[ProviderSettingsManager.activateProfile] END - profile activated", { + name, + id: providerSettings.id, + }) + return { name, ...providerSettings } }) } catch (error) { + debugLog("[ProviderSettingsManager.activateProfile] ERROR", { + params, + error: error instanceof Error ? error.message : String(error), + }) throw new Error(`Failed to activate profile: ${error instanceof Error ? error.message : error}`) } } @@ -536,10 +571,27 @@ export class ProviderSettingsManager { public async getModeConfigId(mode: Mode) { try { return await this.lock(async () => { - const { modeApiConfigs } = await this.load() - return modeApiConfigs?.[mode] + const providerProfiles = await this.load() + const configId = providerProfiles.modeApiConfigs?.[mode] + + // DEBUG: Log mode config lookup + debugLog("[ProviderSettingsManager.getModeConfigId]", { + mode, + configId, + allModeApiConfigs: providerProfiles.modeApiConfigs, + availableConfigIds: Object.entries(providerProfiles.apiConfigs).map(([name, c]) => ({ + name, + id: c.id, + })), + }) + + return configId }) } catch (error) { + debugLog("[ProviderSettingsManager.getModeConfigId] ERROR", { + mode, + error: error instanceof Error ? error.message : String(error), + }) throw new Error(`Failed to get mode config: ${error}`) } } @@ -610,7 +662,15 @@ export class ProviderSettingsManager { try { const content = await this.context.secrets.get(this.secretsKey) + // DEBUG: Log raw secrets content + debugLog("[ProviderSettingsManager.load] secrets.get result", { + secretsKey: this.secretsKey, + hasContent: !!content, + contentLength: content?.length ?? 0, + }) + if (!content) { + debugLog("[ProviderSettingsManager.load] returning default profiles (no content found)") return this.defaultProviderProfiles } @@ -631,12 +691,31 @@ export class ProviderSettingsManager { {} as Record, ) - return { + const result = { ...providerProfiles, apiConfigs: Object.fromEntries( Object.entries(apiConfigs).filter(([_, apiConfig]) => apiConfig !== null), ), } + + // DEBUG: Log loaded profiles summary + debugLog("[ProviderSettingsManager.load] loaded profiles", { + currentApiConfigName: result.currentApiConfigName, + apiConfigNames: Object.keys(result.apiConfigs), + modeApiConfigs: result.modeApiConfigs, + configDetails: Object.entries(result.apiConfigs).map(([name, config]) => ({ + name, + id: config.id, + apiProvider: config.apiProvider, + hasApiKey: !!( + (config as any)?.apiKey || + (config as any)?.openRouterApiKey || + (config as any)?.requestyApiKey + ), + })), + }) + + return result } catch (error) { if (error instanceof ZodError) { TelemetryService.instance.captureSchemaValidationError({ @@ -645,6 +724,10 @@ export class ProviderSettingsManager { }) } + debugLog("[ProviderSettingsManager.load] ERROR", { + error: error instanceof Error ? error.message : String(error), + }) + throw new Error(`Failed to read provider profiles from secrets: ${error}`) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1c5ceb64ae..42f53b7a67 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3,6 +3,8 @@ import * as path from "path" import fs from "fs/promises" import EventEmitter from "events" +import { debugLog, DebugLogger } from "../../utils/debug-log" + import { Anthropic } from "@anthropic-ai/sdk" import delay from "delay" import axios from "axios" @@ -904,7 +906,24 @@ export class ClineProvider if (profile?.name) { try { - await this.activateProviderProfile({ name: profile.name }) + // Check if the profile has actual API configuration (not just an id). + // In CLI mode, the ProviderSettingsManager may return empty default profiles + // that only contain 'id' and 'name' fields. Activating such a profile would + // overwrite the CLI's working API configuration with empty settings. + const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) + const hasActualSettings = !!fullProfile.apiProvider + + if (hasActualSettings) { + await this.activateProviderProfile({ name: profile.name }) + } else { + debugLog( + "[createTaskWithHistoryItem] SKIPPING profile activation - profile has no apiProvider", + { + savedConfigId, + profileName: profile.name, + }, + ) + } } catch (error) { // Log the error but continue with task restoration. this.log( @@ -1241,6 +1260,15 @@ export class ClineProvider * @param newMode The mode to switch to */ public async handleModeSwitch(newMode: Mode) { + // DEBUG: Log entry point with current state + const currentApiConfigName = this.getGlobalState("currentApiConfigName") + const currentMode = this.getGlobalState("mode") + debugLog("[handleModeSwitch] START", { + newMode, + currentMode, + currentApiConfigName, + }) + const task = this.getCurrentTask() if (task) { @@ -1279,6 +1307,14 @@ export class ClineProvider const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() + // DEBUG: Log mode config lookup results + debugLog("[handleModeSwitch] getModeConfigId result", { + newMode, + savedConfigId, + listApiConfigCount: listApiConfig.length, + listApiConfigNames: listApiConfig.map((c) => ({ name: c.name, id: c.id, provider: c.apiProvider })), + }) + // Update listApiConfigMeta first to ensure UI has latest data. await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -1286,22 +1322,80 @@ export class ClineProvider if (savedConfigId) { const profile = listApiConfig.find(({ id }) => id === savedConfigId) + // DEBUG: Log profile activation attempt + debugLog("[handleModeSwitch] activating saved config", { + savedConfigId, + foundProfile: profile ? { name: profile.name, id: profile.id, provider: profile.apiProvider } : null, + }) + if (profile?.name) { - await this.activateProviderProfile({ name: profile.name }) + // Check if the profile has actual API configuration (not just an id). + // In CLI mode, the ProviderSettingsManager may return empty default profiles + // that only contain 'id' and 'name' fields. Activating such a profile would + // overwrite the CLI's working API configuration with empty settings. + // Skip activation if the profile has no apiProvider set - this indicates + // an unconfigured/empty profile. + const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) + const hasActualSettings = !!fullProfile.apiProvider + + if (hasActualSettings) { + await this.activateProviderProfile({ name: profile.name }) + } else { + debugLog( + "[handleModeSwitch] SKIPPING profile activation - profile has no apiProvider (CLI mode workaround)", + { + savedConfigId, + profileName: profile.name, + profileKeys: Object.keys(fullProfile).filter( + (k) => fullProfile[k as keyof typeof fullProfile] !== undefined, + ), + }, + ) + } + } else { + debugLog("[handleModeSwitch] WARNING: savedConfigId exists but profile not found in listApiConfig", { + savedConfigId, + availableIds: listApiConfig.map((c) => c.id), + }) } } else { // If no saved config for this mode, save current config as default. - const currentApiConfigName = this.getGlobalState("currentApiConfigName") + const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") - if (currentApiConfigName) { - const config = listApiConfig.find((c) => c.name === currentApiConfigName) + // DEBUG: Log no saved config case + debugLog("[handleModeSwitch] no saved config for mode, using current", { + newMode, + currentApiConfigNameAfter, + }) + + if (currentApiConfigNameAfter) { + const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) if (config?.id) { + debugLog("[handleModeSwitch] saving current config as mode default", { + newMode, + configId: config.id, + configName: config.name, + }) await this.providerSettingsManager.setModeConfig(newMode, config.id) } } } + // DEBUG: Log final state after mode switch + const finalState = await this.getState() + debugLog("[handleModeSwitch] END - final state", { + newMode, + finalApiProvider: finalState.apiConfiguration?.apiProvider, + finalApiConfigName: finalState.currentApiConfigName, + // Check various provider API keys to see if any are set + hasAnyApiKey: !!( + (finalState.apiConfiguration as any)?.apiKey || + (finalState.apiConfiguration as any)?.openRouterApiKey || + (finalState.apiConfiguration as any)?.requestyApiKey + ), + }) + await this.postStateToWebview() } @@ -1441,8 +1535,27 @@ export class ClineProvider } async activateProviderProfile(args: { name: string } | { id: string }) { + // DEBUG: Log entry point + debugLog("[activateProviderProfile] START", { args }) + const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) + // DEBUG: Log what was returned from activateProfile + debugLog("[activateProviderProfile] activateProfile result", { + name, + id, + apiProvider: providerSettings.apiProvider, + hasApiKey: !!( + (providerSettings as any)?.apiKey || + (providerSettings as any)?.openRouterApiKey || + (providerSettings as any)?.requestyApiKey + ), + // Log all keys that have values (but not the values themselves for security) + settingsKeys: Object.keys(providerSettings).filter( + (k) => providerSettings[k as keyof typeof providerSettings] !== undefined, + ), + }) + // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), @@ -1452,6 +1565,9 @@ export class ClineProvider const { mode } = await this.getState() + // DEBUG: Log mode config update + debugLog("[activateProviderProfile] setting mode config", { mode, id }) + if (id) { await this.providerSettingsManager.setModeConfig(mode, id) } @@ -1460,6 +1576,18 @@ export class ClineProvider await this.postStateToWebview() + // DEBUG: Log final state + const finalState = this.contextProxy.getProviderSettings() + debugLog("[activateProviderProfile] END - final provider settings", { + name, + apiProvider: finalState.apiProvider, + hasApiKey: !!( + (finalState as any)?.apiKey || + (finalState as any)?.openRouterApiKey || + (finalState as any)?.requestyApiKey + ), + }) + if (providerSettings.apiProvider) { this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider }) } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5505f77c55..4b0281e9e3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -13,9 +13,13 @@ import { type TelemetrySetting, type UserSettingsConfig, type ModelRecord, + type WebviewMessage, + type EditQueuedMessagePayload, TelemetryEventName, RooCodeSettings, ExperimentId, + checkoutDiffPayloadSchema, + checkoutRestorePayloadSchema, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -33,12 +37,6 @@ import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" -import { - type WebviewMessage, - type EditQueuedMessagePayload, - checkoutDiffPayloadSchema, - checkoutRestorePayloadSchema, -} from "../../shared/WebviewMessage" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e915d0d6be..9ec3eba5aa 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,315 +1,3 @@ -import { z } from "zod" - -import { - type RooCodeSettings, - type ProviderSettings, - type PromptComponent, - type ModeConfig, - type InstallMarketplaceItemOptions, - type MarketplaceItem, - type ShareVisibility, - type QueuedMessage, - marketplaceItemSchema, -} from "@roo-code/types" - -import { Mode } from "./modes" +export type { WebviewMessage, WebViewMessagePayload } from "@roo-code/types" export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse" - -export type PromptMode = Mode | "enhance" - -export type AudioType = "notification" | "celebration" | "progress_loop" - -export interface UpdateTodoListPayload { - todos: any[] -} - -export type EditQueuedMessagePayload = Pick - -export interface WebviewMessage { - type: - | "updateTodoList" - | "deleteMultipleTasksWithIds" - | "currentApiConfigName" - | "saveApiConfiguration" - | "upsertApiConfiguration" - | "deleteApiConfiguration" - | "loadApiConfiguration" - | "loadApiConfigurationById" - | "renameApiConfiguration" - | "getListApiConfiguration" - | "customInstructions" - | "webviewDidLaunch" - | "newTask" - | "askResponse" - | "terminalOperation" - | "clearTask" - | "didShowAnnouncement" - | "selectImages" - | "exportCurrentTask" - | "shareCurrentTask" - | "showTaskWithId" - | "deleteTaskWithId" - | "exportTaskWithId" - | "importSettings" - | "exportSettings" - | "resetState" - | "flushRouterModels" - | "requestRouterModels" - | "requestOpenAiModels" - | "requestOllamaModels" - | "requestLmStudioModels" - | "requestRooModels" - | "requestRooCreditBalance" - | "requestVsCodeLmModels" - | "requestHuggingFaceModels" - | "openImage" - | "saveImage" - | "openFile" - | "openMention" - | "cancelTask" - | "cancelAutoApproval" - | "updateVSCodeSetting" - | "getVSCodeSetting" - | "vsCodeSetting" - | "updateCondensingPrompt" - | "playSound" - | "playTts" - | "stopTts" - | "ttsEnabled" - | "ttsSpeed" - | "openKeyboardShortcuts" - | "openMcpSettings" - | "openProjectMcpSettings" - | "restartMcpServer" - | "refreshAllMcpServers" - | "toggleToolAlwaysAllow" - | "toggleToolEnabledForPrompt" - | "toggleMcpServer" - | "updateMcpTimeout" - | "enhancePrompt" - | "enhancedPrompt" - | "draggedImages" - | "deleteMessage" - | "deleteMessageConfirm" - | "submitEditedMessage" - | "editMessageConfirm" - | "enableMcpServerCreation" - | "remoteControlEnabled" - | "taskSyncEnabled" - | "searchCommits" - | "setApiConfigPassword" - | "mode" - | "updatePrompt" - | "getSystemPrompt" - | "copySystemPrompt" - | "systemPrompt" - | "enhancementApiConfigId" - | "autoApprovalEnabled" - | "updateCustomMode" - | "deleteCustomMode" - | "setopenAiCustomModelInfo" - | "openCustomModesSettings" - | "checkpointDiff" - | "checkpointRestore" - | "deleteMcpServer" - | "codebaseIndexEnabled" - | "telemetrySetting" - | "testBrowserConnection" - | "browserConnectionResult" - | "searchFiles" - | "toggleApiConfigPin" - | "hasOpenedModeSelector" - | "clearCloudAuthSkipModel" - | "cloudButtonClicked" - | "rooCloudSignIn" - | "cloudLandingPageSignIn" - | "rooCloudSignOut" - | "rooCloudManualUrl" - | "claudeCodeSignIn" - | "claudeCodeSignOut" - | "switchOrganization" - | "condenseTaskContextRequest" - | "requestIndexingStatus" - | "startIndexing" - | "clearIndexData" - | "indexingStatusUpdate" - | "indexCleared" - | "focusPanelRequest" - | "openExternal" - | "filterMarketplaceItems" - | "marketplaceButtonClicked" - | "installMarketplaceItem" - | "installMarketplaceItemWithParameters" - | "cancelMarketplaceInstall" - | "removeInstalledMarketplaceItem" - | "marketplaceInstallResult" - | "fetchMarketplaceData" - | "switchTab" - | "shareTaskSuccess" - | "exportMode" - | "exportModeResult" - | "importMode" - | "importModeResult" - | "checkRulesDirectory" - | "checkRulesDirectoryResult" - | "saveCodeIndexSettingsAtomic" - | "requestCodeIndexSecretStatus" - | "requestCommands" - | "openCommandFile" - | "deleteCommand" - | "createCommand" - | "insertTextIntoTextarea" - | "showMdmAuthRequiredNotification" - | "imageGenerationSettings" - | "queueMessage" - | "removeQueuedMessage" - | "editQueuedMessage" - | "dismissUpsell" - | "getDismissedUpsells" - | "updateSettings" - | "allowedCommands" - | "deniedCommands" - | "killBrowserSession" - | "openBrowserSessionPanel" - | "showBrowserSessionPanelAtStep" - | "refreshBrowserSessionPanel" - | "browserPanelDidLaunch" - | "openDebugApiHistory" - | "openDebugUiHistory" - | "downloadErrorDiagnostics" - | "requestClaudeCodeRateLimits" - | "refreshCustomTools" - | "requestModes" - | "switchMode" - text?: string - editedMessageContent?: string - tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" - disabled?: boolean - context?: string - dataUri?: string - askResponse?: ClineAskResponse - apiConfiguration?: ProviderSettings - images?: string[] - bool?: boolean - value?: number - stepIndex?: number - isLaunchAction?: boolean - forceShow?: boolean - commands?: string[] - audioType?: AudioType - serverName?: string - toolName?: string - alwaysAllow?: boolean - isEnabled?: boolean - mode?: Mode - promptMode?: PromptMode - customPrompt?: PromptComponent - dataUrls?: string[] - values?: Record - query?: string - setting?: string - slug?: string - modeConfig?: ModeConfig - timeout?: number - payload?: WebViewMessagePayload - source?: "global" | "project" - requestId?: string - ids?: string[] - hasSystemPromptOverride?: boolean - terminalOperation?: "continue" | "abort" - messageTs?: number - restoreCheckpoint?: boolean - historyPreviewCollapsed?: boolean - filters?: { type?: string; search?: string; tags?: string[] } - settings?: any - url?: string // For openExternal - mpItem?: MarketplaceItem - mpInstallOptions?: InstallMarketplaceItemOptions - config?: Record // Add config to the payload - visibility?: ShareVisibility // For share visibility - hasContent?: boolean // For checkRulesDirectoryResult - checkOnly?: boolean // For deleteCustomMode check - upsellId?: string // For dismissUpsell - list?: string[] // For dismissedUpsells response - organizationId?: string | null // For organization switching - useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow - codeIndexSettings?: { - // Global state settings - codebaseIndexEnabled: boolean - codebaseIndexQdrantUrl: string - codebaseIndexEmbedderProvider: - | "openai" - | "ollama" - | "openai-compatible" - | "gemini" - | "mistral" - | "vercel-ai-gateway" - | "bedrock" - | "openrouter" - codebaseIndexEmbedderBaseUrl?: string - codebaseIndexEmbedderModelId: string - codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers - codebaseIndexOpenAiCompatibleBaseUrl?: string - codebaseIndexBedrockRegion?: string - codebaseIndexBedrockProfile?: string - codebaseIndexSearchMaxResults?: number - codebaseIndexSearchMinScore?: number - codebaseIndexOpenRouterSpecificProvider?: string // OpenRouter provider routing - - // Secret settings - codeIndexOpenAiKey?: string - codeIndexQdrantApiKey?: string - codebaseIndexOpenAiCompatibleApiKey?: string - codebaseIndexGeminiApiKey?: string - codebaseIndexMistralApiKey?: string - codebaseIndexVercelAiGatewayApiKey?: string - codebaseIndexOpenRouterApiKey?: string - } - updatedSettings?: RooCodeSettings -} - -export const checkoutDiffPayloadSchema = z.object({ - ts: z.number().optional(), - previousCommitHash: z.string().optional(), - commitHash: z.string(), - mode: z.enum(["full", "checkpoint", "from-init", "to-current"]), -}) - -export type CheckpointDiffPayload = z.infer - -export const checkoutRestorePayloadSchema = z.object({ - ts: z.number(), - commitHash: z.string(), - mode: z.enum(["preview", "restore"]), -}) - -export type CheckpointRestorePayload = z.infer - -export interface IndexingStatusPayload { - state: "Standby" | "Indexing" | "Indexed" | "Error" - message: string -} - -export interface IndexClearedPayload { - success: boolean - error?: string -} - -export const installMarketplaceItemWithParametersPayloadSchema = z.object({ - item: marketplaceItemSchema, - parameters: z.record(z.string(), z.any()), -}) - -export type InstallMarketplaceItemWithParametersPayload = z.infer< - typeof installMarketplaceItemWithParametersPayloadSchema -> - -export type WebViewMessagePayload = - | CheckpointDiffPayload - | CheckpointRestorePayload - | IndexingStatusPayload - | IndexClearedPayload - | InstallMarketplaceItemWithParametersPayload - | UpdateTodoListPayload - | EditQueuedMessagePayload diff --git a/src/utils/debug-log.ts b/src/utils/debug-log.ts new file mode 100644 index 0000000000..3257531755 --- /dev/null +++ b/src/utils/debug-log.ts @@ -0,0 +1,19 @@ +/** + * File-based debug logging utility + * + * Re-exports from @roo-code/core/debug-log for consistency across the codebase. + * This writes logs to ~/.roo/cli-debug.log, avoiding stdout/stderr + * which would break TUI applications. + * + * Usage: + * import { debugLog, DebugLogger } from "../utils/debug-log" + * + * // Simple logging + * debugLog("handleModeSwitch", { mode: newMode, configId }) + * + * // Or create a named logger for a component + * const log = new DebugLogger("ClineProvider") + * log.info("handleModeSwitch", { mode: newMode }) + */ + +export { debugLog, DebugLogger, providerDebugLog } from "@roo-code/core/debug-log" diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 2b784e1fe0..e92d998d81 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -11,7 +11,7 @@ import { Trans } from "react-i18next" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" -import type { ClineAsk, ClineMessage, ExtensionMessage } from "@roo-code/types" +import type { ClineAsk, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types" import { ClineSayTool } from "@roo/ExtensionMessage" import { findLast } from "@roo/array" @@ -19,7 +19,6 @@ import { SuggestionItem } from "@roo-code/types" import { combineApiRequests } from "@roo/combineApiRequests" import { combineCommandSequences } from "@roo/combineCommandSequences" import { getApiMetrics } from "@roo/getApiMetrics" -import { AudioType } from "@roo/WebviewMessage" import { getAllModes } from "@roo/modes" import { ProfileValidator } from "@roo/ProfileValidator" import { getLatestTodo } from "@roo/todo"