More progress

This commit is contained in:
cte 2026-01-07 11:01:06 -08:00
parent 5ec80f94eb
commit 3d7117bb7b
15 changed files with 1097 additions and 610 deletions

View file

@ -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<unknown[]>(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<typeof vi.spyOn>
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<ExtensionHostOptions>(host, "options")
expect(options.ephemeral).toBe(true)
})
it("should default ephemeral to undefined", () => {
const host = createTestHost()
const options = getPrivate<ExtensionHostOptions>(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<Promise<string>>(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<Promise<string>>(host, "createEphemeralStorageDir")
const dir2 = await callPrivate<Promise<string>>(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<Promise<string>>(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<Promise<string>>(host, "createEphemeralStorageDir")
;(host as unknown as Record<string, unknown>).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<string, unknown>).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<Promise<string>>(host, "createEphemeralStorageDir")
;(host as unknown as Record<string, unknown>).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()
})
})
})
})

View file

@ -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<void>
}
const DEBUG_LOG_PATH = path.join(os.homedir(), ".roo", "cli-debug.log")
export class ExtensionHost extends EventEmitter {
private vscode: ReturnType<typeof createVSCodeAPI> | 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<string, string> {
if (!msg || typeof msg !== "object") {
return { _type: typeof msg }
}
const shape: Record<string, string> = {}
for (const [key, value] of Object.entries(msg as Record<string, unknown>)) {
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<string> {
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<void> {
// 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<void>((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<void>((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<void>((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<string, unknown> | 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<void> {
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<void> {
// 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<string, unknown>).vscode
delete (global as Record<string, unknown>).__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
}
}
}
}

View file

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

View file

@ -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<void>
runTask(prompt: string): Promise<void>
sendToExtension(message: unknown): void
sendToExtension(message: WebviewMessage): void
dispose(): Promise<void>
}
@ -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<NodeJS.Timeout | null>(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<number | null>(null)
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(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<any>) => 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 ? (
<Text color="yellow">Press Ctrl+C again to exit</Text>
) : isLoading ? (
) : isLoading && !pendingAsk ? (
<Box>
<LoadingText>{view === "ToolUse" ? "Using tool" : "Thinking"}</LoadingText>
<Text color={theme.dimText}> </Text>
@ -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({
}}
/>
<HorizontalLine active={true} />
<Text color={theme.dimText}> navigate Enter select</Text>
<Text color={theme.dimText}>
navigate Enter select
{countdownSeconds !== null && (
<Text color="yellow"> Auto-select in {countdownSeconds}s</Text>
)}
</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>

View file

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

View file

@ -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",

View file

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

View file

@ -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<QueuedMessage, "id" | "text" | "images">
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<string, any>
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<string, any> // 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<typeof checkoutDiffPayloadSchema>
export const checkoutRestorePayloadSchema = z.object({
ts: z.number(),
commitHash: z.string(),
mode: z.enum(["preview", "restore"]),
})
export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>
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

View file

@ -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()

View file

@ -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<ProviderSettingsWithId & { name: string }> {
// 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<string, ProviderSettingsWithId>,
)
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}`)
}
}

View file

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

View file

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

View file

@ -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<QueuedMessage, "id" | "text" | "images">
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<string, any>
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<string, any> // 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<typeof checkoutDiffPayloadSchema>
export const checkoutRestorePayloadSchema = z.object({
ts: z.number(),
commitHash: z.string(),
mode: z.enum(["preview", "restore"]),
})
export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>
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

19
src/utils/debug-log.ts Normal file
View file

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

View file

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