From 3903fe5adabe1acb79e3255761b1f9ce1f08cb94 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 10 Jan 2026 01:00:02 -0800 Subject: [PATCH 1/3] Some cleanup in ExtensionHost --- apps/cli/docs/AGENT_LOOP.md | 21 +- apps/cli/package.json | 1 + .../agent/__tests__/extension-client.test.ts | 101 ++- .../agent/__tests__/extension-host.test.ts | 669 +++++------------- apps/cli/src/agent/events.ts | 15 + apps/cli/src/agent/extension-client.ts | 18 + apps/cli/src/agent/extension-host.ts | 364 ++++------ apps/cli/src/agent/message-processor.ts | 18 +- apps/cli/src/agent/state-store.ts | 34 + apps/cli/src/lib/storage/ephemeral.ts | 10 + apps/cli/src/lib/storage/index.ts | 1 + apps/cli/src/ui/App.tsx | 12 +- apps/cli/src/ui/__tests__/store.test.ts | 28 +- apps/cli/src/ui/hooks/useExtensionHost.ts | 29 +- apps/cli/src/ui/hooks/useTaskSubmit.ts | 9 +- packages/vscode-shim/src/index.ts | 1 + .../src/interfaces/extension-host.ts | 69 ++ packages/vscode-shim/src/vscode.ts | 3 + pnpm-lock.yaml | 3 + 19 files changed, 622 insertions(+), 784 deletions(-) create mode 100644 apps/cli/src/lib/storage/ephemeral.ts create mode 100644 packages/vscode-shim/src/interfaces/extension-host.ts diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md index 8dcb0651f29..a7b1d9eed40 100644 --- a/apps/cli/docs/AGENT_LOOP.md +++ b/apps/cli/docs/AGENT_LOOP.md @@ -180,12 +180,13 @@ function isStreaming(messages) { ### ExtensionClient -The **single source of truth** for agent state. It: +The **single source of truth** for agent state, including the current mode. It: - Receives all messages from the extension - Stores them in the `StateStore` +- Tracks the current mode from state messages - Computes the current state via `detectAgentState()` -- Emits events when state changes +- Emits events when state changes (including mode changes) ```typescript const client = new ExtensionClient({ @@ -199,21 +200,31 @@ if (state.isWaitingForInput) { console.log(`Agent needs: ${state.currentAsk}`) } +// Query current mode +const mode = client.getCurrentMode() +console.log(`Current mode: ${mode}`) // e.g., "code", "architect", "ask" + // Subscribe to events client.on("waitingForInput", (event) => { console.log(`Waiting for: ${event.ask}`) }) + +// Subscribe to mode changes +client.on("modeChanged", (event) => { + console.log(`Mode changed: ${event.previousMode} -> ${event.currentMode}`) +}) ``` ### StateStore -Holds the `clineMessages` array and computed state: +Holds the `clineMessages` array, computed state, and current mode: ```typescript interface StoreState { messages: ClineMessage[] // The raw message array agentState: AgentStateInfo // Computed state isInitialized: boolean // Have we received any state? + currentMode: string | undefined // Current mode (e.g., "code", "architect") } ``` @@ -221,9 +232,9 @@ interface StoreState { Handles incoming messages from the extension: -- `"state"` messages → Update `clineMessages` array +- `"state"` messages → Update `clineMessages` array and track mode - `"messageUpdated"` messages → Update single message in array -- Emits events for state transitions +- Emits events for state transitions and mode changes ### AskDispatcher diff --git a/apps/cli/package.json b/apps/cli/package.json index ac2770faf61..3939a0aa584 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "commander": "^12.1.0", "fuzzysort": "^3.1.0", "ink": "^6.6.0", + "p-wait-for": "^5.0.2", "react": "^19.1.0", "superjson": "^2.2.6", "zustand": "^5.0.0" diff --git a/apps/cli/src/agent/__tests__/extension-client.test.ts b/apps/cli/src/agent/__tests__/extension-client.test.ts index 03de87c4891..3d87a30200f 100644 --- a/apps/cli/src/agent/__tests__/extension-client.test.ts +++ b/apps/cli/src/agent/__tests__/extension-client.test.ts @@ -14,8 +14,8 @@ function createMessage(overrides: Partial): ClineMessage { return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides } } -function createStateMessage(messages: ClineMessage[]): ExtensionMessage { - return { type: "state", state: { clineMessages: messages } } as ExtensionMessage +function createStateMessage(messages: ClineMessage[], mode?: string): ExtensionMessage { + return { type: "state", state: { clineMessages: messages, mode } } as ExtensionMessage } describe("detectAgentState", () => { @@ -300,6 +300,44 @@ describe("ExtensionClient", () => { client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })])) expect(callCount).toBe(1) // Should not increase. }) + + it("should emit modeChanged events", () => { + const { client } = createMockClient() + const modeChanges: { previousMode: string | undefined; currentMode: string }[] = [] + + client.onModeChanged((event) => { + modeChanges.push(event) + }) + + // Set initial mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + + expect(modeChanges).toHaveLength(1) + expect(modeChanges[0]).toEqual({ previousMode: undefined, currentMode: "code" }) + + // Change mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect")) + + expect(modeChanges).toHaveLength(2) + expect(modeChanges[1]).toEqual({ previousMode: "code", currentMode: "architect" }) + }) + + it("should not emit modeChanged when mode stays the same", () => { + const { client } = createMockClient() + let modeChangeCount = 0 + + client.onModeChanged(() => { + modeChangeCount++ + }) + + // Set initial mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(modeChangeCount).toBe(1) + + // Same mode - should not emit + client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "code")) + expect(modeChangeCount).toBe(1) + }) }) describe("Response methods", () => { @@ -458,6 +496,65 @@ describe("ExtensionClient", () => { expect(client.isInitialized()).toBe(false) expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK) }) + + it("should reset mode on reset", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(client.getCurrentMode()).toBe("code") + + client.reset() + + expect(client.getCurrentMode()).toBeUndefined() + }) + }) + + describe("Mode tracking", () => { + it("should return undefined mode when not initialized", () => { + const { client } = createMockClient() + expect(client.getCurrentMode()).toBeUndefined() + }) + + it("should track mode from state messages", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + + expect(client.getCurrentMode()).toBe("code") + }) + + it("should update mode when it changes", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(client.getCurrentMode()).toBe("code") + + client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "architect")) + expect(client.getCurrentMode()).toBe("architect") + }) + + it("should preserve mode when state message has no mode", () => { + const { client } = createMockClient() + + // Set initial mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(client.getCurrentMode()).toBe("code") + + // State update without mode - should preserve existing mode + client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })])) + expect(client.getCurrentMode()).toBe("code") + }) + + it("should preserve mode when task is cleared", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect")) + expect(client.getCurrentMode()).toBe("architect") + + client.clearTask() + // Mode should be preserved after clear + expect(client.getCurrentMode()).toBe("architect") + }) }) }) diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index 1691bf2bb6c..d9cdb11efba 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -2,12 +2,11 @@ import { EventEmitter } from "events" import fs from "fs" -import os from "os" -import path from "path" import type { WebviewMessage } from "@roo-code/types" import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js" +import { ExtensionClient } from "../extension-client.js" vi.mock("@roo-code/vscode-shim", () => ({ createVSCodeAPI: vi.fn(() => ({ @@ -16,6 +15,10 @@ vi.mock("@roo-code/vscode-shim", () => ({ setRuntimeConfigValues: vi.fn(), })) +vi.mock("@/lib/storage/index.js", () => ({ + createEphemeralStorageDir: vi.fn(() => Promise.resolve("/tmp/roo-cli-test-ephemeral")), +})) + /** * Create a test ExtensionHost with default options. */ @@ -46,8 +49,16 @@ function getPrivate(host: ExtensionHost, key: string): T { return (host as unknown as PrivateHost)[key] as T } +/** + * Helper to set private members for testing + */ +function setPrivate(host: ExtensionHost, key: string, value: unknown): void { + ;(host as unknown as PrivateHost)[key] = value +} + /** * Helper to call private methods for testing + * This uses a more permissive type to avoid TypeScript errors with private methods */ function callPrivate(host: ExtensionHost, method: string, ...args: unknown[]): T { const fn = (host as unknown as PrivateHost)[method] as ((...a: unknown[]) => T) | undefined @@ -86,7 +97,12 @@ describe("ExtensionHost", () => { const host = new ExtensionHost(options) - expect(getPrivate(host, "options")).toEqual(options) + // Options are stored but integrationTest is set to true + const storedOptions = getPrivate(host, "options") + expect(storedOptions.mode).toBe(options.mode) + expect(storedOptions.workspacePath).toBe(options.workspacePath) + expect(storedOptions.extensionPath).toBe(options.extensionPath) + expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor }) it("should be an EventEmitter instance", () => { @@ -97,8 +113,7 @@ describe("ExtensionHost", () => { it("should initialize with default state values", () => { const host = createTestHost() - expect(getPrivate(host, "isWebviewReady")).toBe(false) - expect(getPrivate(host, "pendingMessages")).toEqual([]) + expect(getPrivate(host, "isReady")).toBe(false) expect(getPrivate(host, "vscode")).toBeNull() expect(getPrivate(host, "extensionModule")).toBeNull() }) @@ -115,25 +130,26 @@ describe("ExtensionHost", () => { }) describe("webview provider registration", () => { - it("should register webview provider", () => { + it("should register webview provider without throwing", () => { const host = createTestHost() const mockProvider = { resolveWebviewView: vi.fn() } - host.registerWebviewProvider("test-view", mockProvider) - - const providers = getPrivate>(host, "webviewProviders") - expect(providers.get("test-view")).toBe(mockProvider) + // registerWebviewProvider is now a no-op, just ensure it doesn't throw + expect(() => { + host.registerWebviewProvider("test-view", mockProvider) + }).not.toThrow() }) - it("should unregister webview provider", () => { + it("should unregister webview provider without throwing", () => { const host = createTestHost() const mockProvider = { resolveWebviewView: vi.fn() } host.registerWebviewProvider("test-view", mockProvider) - host.unregisterWebviewProvider("test-view") - const providers = getPrivate>(host, "webviewProviders") - expect(providers.has("test-view")).toBe(false) + // unregisterWebviewProvider is now a no-op, just ensure it doesn't throw + expect(() => { + host.unregisterWebviewProvider("test-view") + }).not.toThrow() }) it("should handle unregistering non-existent provider gracefully", () => { @@ -160,49 +176,48 @@ describe("ExtensionHost", () => { }) describe("markWebviewReady", () => { - it("should set isWebviewReady to true", () => { + it("should set isReady to true", () => { const host = createTestHost() host.markWebviewReady() - expect(getPrivate(host, "isWebviewReady")).toBe(true) + expect(getPrivate(host, "isReady")).toBe(true) }) - it("should emit webviewReady event", () => { + it("should send webviewDidLaunch message", () => { const host = createTestHost() - const listener = vi.fn() + const emitSpy = vi.spyOn(host, "emit") - host.on("webviewReady", listener) host.markWebviewReady() - expect(listener).toHaveBeenCalled() + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "webviewDidLaunch" }) }) - it("should flush pending messages", () => { + it("should send updateSettings message", () => { const host = createTestHost() const emitSpy = vi.spyOn(host, "emit") - // Queue messages before ready - 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: "requestModes" }) - expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" }) + // Check that updateSettings was called + const updateSettingsCall = emitSpy.mock.calls.find( + (call) => + call[0] === "webviewMessage" && + typeof call[1] === "object" && + call[1] !== null && + (call[1] as WebviewMessage).type === "updateSettings", + ) + expect(updateSettingsCall).toBeDefined() }) }) }) describe("sendToExtension", () => { - it("should queue message when webview not ready", () => { + it("should throw error when extension not ready", () => { const host = createTestHost() const message: WebviewMessage = { type: "requestModes" } - host.sendToExtension(message) - - const pending = getPrivate(host, "pendingMessages") - expect(pending).toContain(message) + expect(() => { + host.sendToExtension(message) + }).toThrow("You cannot send messages to the extension before it is ready") }) it("should emit webviewMessage event when webview is ready", () => { @@ -211,51 +226,34 @@ describe("ExtensionHost", () => { const message: WebviewMessage = { type: "requestModes" } host.markWebviewReady() + emitSpy.mockClear() // Clear the markWebviewReady calls host.sendToExtension(message) expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message) }) - it("should not queue message when webview is ready", () => { + it("should not throw when webview is ready", () => { const host = createTestHost() host.markWebviewReady() - host.sendToExtension({ type: "requestModes" }) - const pending = getPrivate(host, "pendingMessages") - expect(pending).toHaveLength(0) + expect(() => { + host.sendToExtension({ type: "requestModes" }) + }).not.toThrow() }) }) - describe("handleExtensionMessage", () => { - it("should forward messages to the client", () => { - const host = createTestHost() - const client = host.getExtensionClient() - const handleMessageSpy = vi.spyOn(client, "handleMessage") - - callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } }) - - expect(handleMessageSpy).toHaveBeenCalled() - }) - - it("should track mode from state messages", () => { + describe("message handling via client", () => { + it("should forward extension messages to the client", () => { const host = createTestHost() + const client = getPrivate(host, "client") as ExtensionClient - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "architect", clineMessages: [] }, - }) - - expect(getPrivate(host, "currentMode")).toBe("architect") - }) - - it("should emit modesUpdated for modes messages", () => { - const host = createTestHost() - const emitSpy = vi.spyOn(host, "emit") + // Simulate extension message + host.emit("extensionWebviewMessage", { type: "state", state: { clineMessages: [] } }) - callPrivate(host, "handleExtensionMessage", { type: "modes", modes: [] }) - - expect(emitSpy).toHaveBeenCalledWith("modesUpdated", { type: "modes", modes: [] }) + // Message listener is set up in activate(), which we can't easily call in unit tests + // But we can verify the client exists and has the handleMessage method + expect(typeof client.handleMessage).toBe("function") }) }) @@ -274,94 +272,63 @@ describe("ExtensionHost", () => { const host = createTestHost() expect(typeof host.isWaitingForInput()).toBe("boolean") }) - - it("should return isAgentRunning() status", () => { - const host = createTestHost() - expect(typeof host.isAgentRunning()).toBe("boolean") - }) - - it("should return the client from getExtensionClient()", () => { - const host = createTestHost() - const client = host.getExtensionClient() - - expect(client).toBeDefined() - expect(typeof client.handleMessage).toBe("function") - }) - - it("should return the output manager from getOutputManager()", () => { - const host = createTestHost() - const outputManager = host.getOutputManager() - - expect(outputManager).toBeDefined() - expect(typeof outputManager.output).toBe("function") - }) - - it("should return the prompt manager from getPromptManager()", () => { - const host = createTestHost() - const promptManager = host.getPromptManager() - - expect(promptManager).toBeDefined() - }) - - it("should return the ask dispatcher from getAskDispatcher()", () => { - const host = createTestHost() - const askDispatcher = host.getAskDispatcher() - - expect(askDispatcher).toBeDefined() - expect(typeof askDispatcher.handleAsk).toBe("function") - }) }) describe("quiet mode", () => { describe("setupQuietMode", () => { - it("should suppress console.log, warn, debug, info when enabled", () => { + it("should not modify console when integrationTest is true", () => { + // By default, constructor sets integrationTest = true const host = createTestHost() const originalLog = console.log callPrivate(host, "setupQuietMode") - // These should be no-ops now (different from original) - expect(console.log).not.toBe(originalLog) - - // Verify they are actually no-ops by calling them (should not throw) - expect(() => console.log("test")).not.toThrow() - expect(() => console.warn("test")).not.toThrow() - expect(() => console.debug("test")).not.toThrow() - expect(() => console.info("test")).not.toThrow() - - // Restore for other tests - callPrivate(host, "restoreConsole") + // Console should not be modified since integrationTest is true + expect(console.log).toBe(originalLog) }) - it("should preserve console.error", () => { + it("should suppress console when integrationTest is false", () => { const host = createTestHost() - const originalError = console.error + const originalLog = console.log + + // Override integrationTest to false + const options = getPrivate(host, "options") + options.integrationTest = false callPrivate(host, "setupQuietMode") - expect(console.error).toBe(originalError) + // Console should be modified + expect(console.log).not.toBe(originalLog) + // Restore for other tests callPrivate(host, "restoreConsole") }) - it("should store original console methods", () => { + it("should preserve console.error even when suppressing", () => { const host = createTestHost() - const originalLog = console.log + const originalError = console.error + + // Override integrationTest to false + const options = getPrivate(host, "options") + options.integrationTest = false callPrivate(host, "setupQuietMode") - const stored = getPrivate<{ log: typeof console.log }>(host, "originalConsole") - expect(stored.log).toBe(originalLog) + expect(console.error).toBe(originalError) callPrivate(host, "restoreConsole") }) }) describe("restoreConsole", () => { - it("should restore original console methods", () => { + it("should restore original console methods when suppressed", () => { const host = createTestHost() const originalLog = console.log + // Override integrationTest to false to actually suppress + const options = getPrivate(host, "options") + options.integrationTest = false + callPrivate(host, "setupQuietMode") callPrivate(host, "restoreConsole") @@ -376,20 +343,6 @@ describe("ExtensionHost", () => { }).not.toThrow() }) }) - - describe("suppressNodeWarnings", () => { - it("should suppress process.emitWarning", () => { - const host = createTestHost() - const originalEmitWarning = process.emitWarning - - callPrivate(host, "suppressNodeWarnings") - - expect(process.emitWarning).not.toBe(originalEmitWarning) - - // Restore - callPrivate(host, "restoreConsole") - }) - }) }) describe("dispose", () => { @@ -401,7 +354,7 @@ describe("ExtensionHost", () => { it("should remove message listener", async () => { const listener = vi.fn() - ;(host as unknown as Record).messageListener = listener + setPrivate(host, "messageListener", listener) host.on("extensionWebviewMessage", listener) await host.dispose() @@ -411,9 +364,9 @@ describe("ExtensionHost", () => { it("should call extension deactivate if available", async () => { const deactivateMock = vi.fn() - ;(host as unknown as Record).extensionModule = { + setPrivate(host, "extensionModule", { deactivate: deactivateMock, - } + }) await host.dispose() @@ -421,7 +374,7 @@ describe("ExtensionHost", () => { }) it("should clear vscode reference", async () => { - ;(host as unknown as Record).vscode = { context: {} } + setPrivate(host, "vscode", { context: {} }) await host.dispose() @@ -429,22 +382,13 @@ describe("ExtensionHost", () => { }) it("should clear extensionModule reference", async () => { - ;(host as unknown as Record).extensionModule = {} + setPrivate(host, "extensionModule", {}) await host.dispose() expect(getPrivate(host, "extensionModule")).toBeNull() }) - it("should clear webviewProviders", async () => { - host.registerWebviewProvider("test", {}) - - await host.dispose() - - const providers = getPrivate>(host, "webviewProviders") - expect(providers.size).toBe(0) - }) - it("should delete global vscode", async () => { ;(global as Record).vscode = {} @@ -461,422 +405,175 @@ describe("ExtensionHost", () => { expect((global as Record).__extensionHost).toBeUndefined() }) - it("should restore console if it was suppressed", async () => { + it("should call restoreConsole", async () => { const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole") await host.dispose() expect(restoreConsoleSpy).toHaveBeenCalled() }) + }) - it("should clear managers", async () => { - const outputManager = host.getOutputManager() - const askDispatcher = host.getAskDispatcher() - const outputClearSpy = vi.spyOn(outputManager, "clear") - const askClearSpy = vi.spyOn(askDispatcher, "clear") + describe("runTask", () => { + it("should send newTask message when called", async () => { + const host = createTestHost() + host.markWebviewReady() - await host.dispose() + const emitSpy = vi.spyOn(host, "emit") - expect(outputClearSpy).toHaveBeenCalled() - expect(askClearSpy).toHaveBeenCalled() - }) + // Start the task (will hang waiting for completion) + const taskPromise = host.runTask("test prompt") - it("should reset client", async () => { - const client = host.getExtensionClient() - const resetSpy = vi.spyOn(client, "reset") + // Emit completion to resolve the promise + setTimeout(() => host.emit("taskComplete"), 10) - await host.dispose() + await taskPromise - expect(resetSpy).toHaveBeenCalled() + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" }) }) - }) - describe("waitForCompletion", () => { it("should resolve when taskComplete is emitted", async () => { const host = createTestHost() + host.markWebviewReady() - const promise = callPrivate>(host, "waitForCompletion") + const taskPromise = host.runTask("test prompt") // Emit completion after a short delay setTimeout(() => host.emit("taskComplete"), 10) - await expect(promise).resolves.toBeUndefined() + await expect(taskPromise).resolves.toBeUndefined() }) it("should reject when taskError is emitted", async () => { const host = createTestHost() + host.markWebviewReady() - const promise = callPrivate>(host, "waitForCompletion") + const taskPromise = host.runTask("test prompt") setTimeout(() => host.emit("taskError", "Test error"), 10) - await expect(promise).rejects.toThrow("Test error") + await expect(taskPromise).rejects.toThrow("Test error") }) }) - describe("mode tracking via handleExtensionMessage", () => { - let host: ExtensionHost + describe("initial settings", () => { + it("should set mode from options", () => { + const host = createTestHost({ mode: "architect" }) - beforeEach(() => { - host = createTestHost({ - mode: "code", - provider: "anthropic", - apiKey: "test-key", - model: "test-model", - }) - // Mock process.stdout.write which is used by output() - vi.spyOn(process.stdout, "write").mockImplementation(() => true) + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.mode).toBe("architect") }) - afterEach(() => { - vi.restoreAllMocks() - }) + it("should enable auto-approval in non-interactive mode", () => { + const host = createTestHost({ nonInteractive: true }) - it("should track current mode when state updates with a mode", () => { - // Initial state update establishes current mode - callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - expect(getPrivate(host, "currentMode")).toBe("code") - - // Second state update should update tracked mode - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "architect", clineMessages: [] }, - }) - expect(getPrivate(host, "currentMode")).toBe("architect") + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.autoApprovalEnabled).toBe(true) + expect(initialSettings.alwaysAllowReadOnly).toBe(true) + expect(initialSettings.alwaysAllowWrite).toBe(true) + expect(initialSettings.alwaysAllowExecute).toBe(true) }) - it("should not change current mode when state has no mode", () => { - // Initial state update establishes current mode - callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - expect(getPrivate(host, "currentMode")).toBe("code") + it("should disable auto-approval in interactive mode", () => { + const host = createTestHost({ nonInteractive: false }) - // State without mode should not change tracked mode - callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } }) - expect(getPrivate(host, "currentMode")).toBe("code") + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.autoApprovalEnabled).toBe(false) }) - it("should track current mode across multiple changes", () => { - // Start with code mode - callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - expect(getPrivate(host, "currentMode")).toBe("code") - - // Change to architect - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "architect", clineMessages: [] }, - }) - expect(getPrivate(host, "currentMode")).toBe("architect") - - // Change to debug - callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "debug", clineMessages: [] } }) - expect(getPrivate(host, "currentMode")).toBe("debug") - - // Another state update with debug - callPrivate(host, "handleExtensionMessage", { 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, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) - sendToExtensionSpy.mockClear() - - // Mode change should NOT trigger sendToExtension - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "architect", clineMessages: [] }, - }) - expect(sendToExtensionSpy).not.toHaveBeenCalled() - }) - }) - - describe("applyRuntimeSettings - mode switching", () => { - it("should use currentMode when set (from user mode switches)", () => { - const host = createTestHost({ - mode: "code", // Initial mode from CLI options - provider: "anthropic", - apiKey: "test-key", - model: "test-model", - }) - - // Simulate user switching mode via Ctrl+M - this updates currentMode - ;(host as unknown as Record).currentMode = "architect" + it("should set reasoning effort when specified", () => { + const host = createTestHost({ reasoningEffort: "high" }) - // Create settings object to be modified - const settings: Record = {} - callPrivate(host, "applyRuntimeSettings", settings) - - // Should use currentMode (architect), not options.mode (code) - expect(settings.mode).toBe("architect") + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.enableReasoningEffort).toBe(true) + expect(initialSettings.reasoningEffort).toBe("high") }) - it("should fall back to options.mode when currentMode is not set", () => { - const host = createTestHost({ - mode: "code", - provider: "anthropic", - apiKey: "test-key", - model: "test-model", - }) - - // currentMode is not set (still null from constructor) - expect(getPrivate(host, "currentMode")).toBe("code") // Set from options.mode in constructor - - const settings: Record = {} - callPrivate(host, "applyRuntimeSettings", settings) + it("should disable reasoning effort when set to disabled", () => { + const host = createTestHost({ reasoningEffort: "disabled" }) - // Should use options.mode as fallback - expect(settings.mode).toBe("code") + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.enableReasoningEffort).toBe(false) }) - it("should use currentMode even when it differs from initial options.mode", () => { - const host = createTestHost({ - mode: "code", - provider: "anthropic", - apiKey: "test-key", - model: "test-model", - }) - - // Simulate multiple mode switches: code -> architect -> debug - ;(host as unknown as Record).currentMode = "debug" - - const settings: Record = {} - callPrivate(host, "applyRuntimeSettings", settings) + it("should not set reasoning effort when unspecified", () => { + const host = createTestHost({ reasoningEffort: "unspecified" }) - // Should use the latest currentMode - expect(settings.mode).toBe("debug") + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.enableReasoningEffort).toBeUndefined() + expect(initialSettings.reasoningEffort).toBeUndefined() }) + }) - it("should not set mode if neither currentMode nor options.mode is set", () => { - const host = createTestHost({ - // No mode specified - mode defaults to "code" in createTestHost - provider: "anthropic", - apiKey: "test-key", - model: "test-model", - }) + describe("ephemeral mode", () => { + it("should store ephemeral option correctly", () => { + const host = createTestHost({ ephemeral: true }) - // Explicitly set currentMode to null (edge case) - ;(host as unknown as Record).currentMode = null - // Also clear options.mode const options = getPrivate(host, "options") - options.mode = "" - - const settings: Record = {} - callPrivate(host, "applyRuntimeSettings", settings) - - // Mode should not be set - expect(settings.mode).toBeUndefined() + expect(options.ephemeral).toBe(true) }) - }) - describe("mode switching - end to end simulation", () => { - let host: ExtensionHost + it("should default ephemeralStorageDir to null", () => { + const host = createTestHost() - beforeEach(() => { - host = createTestHost({ - mode: "code", - provider: "anthropic", - apiKey: "test-key", - model: "test-model", - }) - vi.spyOn(process.stdout, "write").mockImplementation(() => true) + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() }) - afterEach(() => { - vi.restoreAllMocks() - }) + it("should clean up ephemeral storage directory on dispose", async () => { + const host = createTestHost({ ephemeral: true }) - it("should preserve mode switch when starting a new task", () => { - // Step 1: Initial state from extension (like webviewDidLaunch response) - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "code", clineMessages: [] }, - }) - expect(getPrivate(host, "currentMode")).toBe("code") + // Set up a mock ephemeral storage directory + const mockEphemeralDir = "/tmp/roo-cli-test-ephemeral-cleanup" + setPrivate(host, "ephemeralStorageDir", mockEphemeralDir) - // Step 2: User presses Ctrl+M to switch mode, extension sends new state - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "architect", clineMessages: [] }, - }) - expect(getPrivate(host, "currentMode")).toBe("architect") + // Mock fs.promises.rm + const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined) - // Step 3: When runTask is called, applyRuntimeSettings should use architect - const settings: Record = {} - callPrivate(host, "applyRuntimeSettings", settings) - expect(settings.mode).toBe("architect") - }) + await host.dispose() - it("should handle mode switch before any state messages", () => { - // currentMode is initialized to options.mode in constructor - expect(getPrivate(host, "currentMode")).toBe("code") + expect(rmMock).toHaveBeenCalledWith(mockEphemeralDir, { recursive: true, force: true }) + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() - // Without any state messages, should still use options.mode - const settings: Record = {} - callPrivate(host, "applyRuntimeSettings", settings) - expect(settings.mode).toBe("code") + rmMock.mockRestore() }) - it("should track multiple mode switches correctly", () => { - // Switch through multiple modes - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "code", clineMessages: [] }, - }) - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "architect", clineMessages: [] }, - }) - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "debug", clineMessages: [] }, - }) - callPrivate(host, "handleExtensionMessage", { - type: "state", - state: { mode: "ask", clineMessages: [] }, - }) + it("should not clean up when ephemeralStorageDir is null", async () => { + const host = createTestHost() - // Should use the most recent mode - expect(getPrivate(host, "currentMode")).toBe("ask") + // ephemeralStorageDir is null by default + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() - const settings: Record = {} - callPrivate(host, "applyRuntimeSettings", settings) - expect(settings.mode).toBe("ask") - }) - }) + const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined) - 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) - }) + await host.dispose() - it("should default ephemeral to undefined", () => { - const host = createTestHost() - const options = getPrivate(host, "options") - expect(options.ephemeral).toBeUndefined() - }) + // rm should not be called when there's no ephemeral storage + expect(rmMock).not.toHaveBeenCalled() - it("should initialize ephemeralStorageDir to null", () => { - const host = createTestHost({ ephemeral: true }) - expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() - }) + rmMock.mockRestore() }) - 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 handle ephemeral storage cleanup errors gracefully", async () => { + const host = createTestHost({ ephemeral: 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) + // Set up a mock ephemeral storage directory + setPrivate(host, "ephemeralStorageDir", "/tmp/roo-cli-test-ephemeral-error") - expect(dir1).not.toBe(dir2) - expect(fs.existsSync(dir1)).toBe(true) - expect(fs.existsSync(dir2)).toBe(true) - }) + // Mock fs.promises.rm to throw an error + const rmMock = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("Cleanup failed")) - 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) + // dispose should not throw even if cleanup fails + await expect(host.dispose()).resolves.toBeUndefined() - const dirName = path.basename(tmpDir) - // Format: roo-cli-{timestamp}-{randomId} - expect(dirName).toMatch(/^roo-cli-\d+-[a-z0-9]+$/) - }) + rmMock.mockRestore() }) - 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) + it("should not affect normal mode when ephemeral is false", () => { + const host = createTestHost({ ephemeral: false }) - // 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() - }) + const options = getPrivate(host, "options") + expect(options.ephemeral).toBe(false) + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() }) }) }) diff --git a/apps/cli/src/agent/events.ts b/apps/cli/src/agent/events.ts index 1934993febe..9b374310ad7 100644 --- a/apps/cli/src/agent/events.ts +++ b/apps/cli/src/agent/events.ts @@ -71,6 +71,11 @@ export interface ClientEventMap { */ taskCleared: void + /** + * Emitted when the current mode changes. + */ + modeChanged: ModeChangedEvent + /** * Emitted on any error during message processing. */ @@ -113,6 +118,16 @@ export interface TaskCompletedEvent { message?: ClineMessage } +/** + * Event payload when mode changes. + */ +export interface ModeChangedEvent { + /** The previous mode (undefined if first mode set) */ + previousMode: string | undefined + /** The new/current mode */ + currentMode: string +} + // ============================================================================= // Typed Event Emitter // ============================================================================= diff --git a/apps/cli/src/agent/extension-client.ts b/apps/cli/src/agent/extension-client.ts index 8efc346057f..c2d77dfdd91 100644 --- a/apps/cli/src/agent/extension-client.ts +++ b/apps/cli/src/agent/extension-client.ts @@ -36,6 +36,7 @@ import { type ClientEventMap, type AgentStateChangeEvent, type WaitingForInputEvent, + type ModeChangedEvent, } from "./events.js" import { AgentLoopState, type AgentStateInfo } from "./agent-state.js" @@ -154,10 +155,12 @@ export class ExtensionClient { if (typeof message === "string") { parsed = parseExtensionMessage(message) + if (!parsed) { if (this.debug) { console.log("[ExtensionClient] Failed to parse message:", message) } + return } } else { @@ -257,6 +260,14 @@ export class ExtensionClient { return this.store.isInitialized() } + /** + * Get the current mode (e.g., "code", "architect", "ask"). + * Returns undefined if no mode has been received yet. + */ + getCurrentMode(): string | undefined { + return this.store.getCurrentMode() + } + // =========================================================================== // Event Subscriptions - Realtime notifications // =========================================================================== @@ -319,6 +330,13 @@ export class ExtensionClient { return this.on("waitingForInput", listener) } + /** + * Convenience method: Subscribe only to mode changes. + */ + onModeChanged(listener: (event: ModeChangedEvent) => void): () => void { + return this.on("modeChanged", listener) + } + // =========================================================================== // Response Methods - Send actions to the extension // =========================================================================== diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 5e569f42c15..f42a6c3aa16 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -6,12 +6,6 @@ * 2. Loading the extension bundle via require() * 3. Activating the extension * 4. Wiring up managers for output, prompting, and ask handling - * - * Managers handle all the heavy lifting: - * - ExtensionClient: Agent state detection (single source of truth) - * - OutputManager: CLI output and streaming - * - PromptManager: User input collection - * - AskDispatcher: Ask routing and handling */ import { EventEmitter } from "events" @@ -19,7 +13,7 @@ import { createRequire } from "module" import path from "path" import { fileURLToPath } from "url" import fs from "fs" -import os from "os" +import pWaitFor from "p-wait-for" import type { ClineMessage, @@ -28,15 +22,16 @@ import type { RooCodeSettings, WebviewMessage, } from "@roo-code/types" -import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim" +import { createVSCodeAPI, IExtensionHost, setRuntimeConfigValues } from "@roo-code/vscode-shim" import { DebugLogger } from "@roo-code/core/cli" import type { SupportedProvider } from "@/types/index.js" import type { User } from "@/lib/sdk/index.js" import { getProviderSettings } from "@/lib/utils/provider.js" +import { createEphemeralStorageDir } from "@/lib/storage/index.js" import type { AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js" -import { type AgentStateInfo, AgentLoopState } from "./agent-state.js" +import type { AgentStateInfo } from "./agent-state.js" import { ExtensionClient } from "./extension-client.js" import { OutputManager } from "./output-manager.js" import { PromptManager } from "./prompt-manager.js" @@ -52,10 +47,6 @@ const cliLogger = new DebugLogger("CLI") const __dirname = path.dirname(fileURLToPath(import.meta.url)) const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..") -// ============================================================================= -// Types -// ============================================================================= - export interface ExtensionHostOptions { mode: string reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" @@ -92,22 +83,26 @@ interface WebviewViewProvider { resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise } -// ============================================================================= -// ExtensionHost Class -// ============================================================================= +export interface ExtensionHostInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, handler: (...args: any[]) => void): void + activate(): Promise + runTask(prompt: string): Promise + sendToExtension(message: WebviewMessage): void + dispose(): Promise +} -export class ExtensionHost extends EventEmitter { - // Extension lifecycle +export class ExtensionHost extends EventEmitter implements IExtensionHost { + // Extension lifecycle. private vscode: ReturnType | null = null private extensionModule: ExtensionModule | null = null private extensionAPI: unknown = null - private webviewProviders: Map = new Map() private options: ExtensionHostOptions - private isWebviewReady = false - private pendingMessages: unknown[] = [] + private isReady = false private messageListener: ((message: ExtensionMessage) => void) | null = null + private initialSettings: RooCodeSettings - // Console suppression + // Console suppression. private originalConsole: { log: typeof console.log warn: typeof console.warn @@ -115,12 +110,10 @@ export class ExtensionHost extends EventEmitter { debug: typeof console.debug info: typeof console.info } | null = null - private originalProcessEmitWarning: typeof process.emitWarning | null = null - // Mode tracking - private currentMode: string | null = null + private originalProcessEmitWarning: typeof process.emitWarning | null = null - // Ephemeral storage + // Ephemeral storage. private ephemeralStorageDir: string | null = null // ========================================================================== @@ -159,9 +152,9 @@ export class ExtensionHost extends EventEmitter { super() this.options = options - this.currentMode = options.mode || null + this.options.integrationTest = true - // Initialize client - single source of truth for agent state. + // Initialize client - single source of truth for agent state (including mode). this.client = new ExtensionClient({ sendMessage: (msg) => this.sendToExtension(msg), debug: options.debug, // Enable debug logging in the client. @@ -189,6 +182,47 @@ export class ExtensionHost extends EventEmitter { // Wire up client events. this.setupClientEventHandlers() + + // Populate initial settings. + const baseSettings: RooCodeSettings = { + mode: this.options.mode, + commandExecutionTimeout: 30, + browserToolEnabled: false, + enableCheckpoints: false, + ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), + } + + this.initialSettings = this.options.nonInteractive + ? { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + alwaysAllowWrite: true, + alwaysAllowWriteOutsideWorkspace: true, + alwaysAllowWriteProtected: true, + alwaysAllowBrowser: true, + alwaysAllowMcp: true, + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + alwaysAllowExecute: true, + allowedCommands: ["*"], + ...baseSettings, + } + : { + autoApprovalEnabled: false, + ...baseSettings, + } + + if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") { + if (this.options.reasoningEffort === "disabled") { + this.initialSettings.enableReasoningEffort = false + } else { + this.initialSettings.enableReasoningEffort = true + this.initialSettings.reasoningEffort = this.options.reasoningEffort + } + } + + this.setupQuietMode() } // ========================================================================== @@ -226,54 +260,33 @@ export class ExtensionHost extends EventEmitter { // Handle task completion. this.client.on("taskCompleted", (event: TaskCompletedEvent) => { this.emit("agentTaskCompleted", event) - this.handleTaskCompleted(event) - }) - } - /** - * Debug logging for messages (first/last pattern). - */ - private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void { - if (msg.partial) { - if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) { - this.outputManager.setLoggedFirstPartial(msg.ts) - cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask }) + // Output completion message via OutputManager. + // Note: completion_result is an "ask" type, not a "say" type. + if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") { + this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "") } - } else { - cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask }) - this.outputManager.clearLoggedFirstPartial(msg.ts) - } - } - - /** - * Handle task completion. - */ - private handleTaskCompleted(event: TaskCompletedEvent): void { - // Output completion message via OutputManager. - // Note: completion_result is an "ask" type, not a "say" type. - if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") { - this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "") - } - // Emit taskComplete for waitForCompletion. - this.emit("taskComplete") + // Emit taskComplete for waitForCompletion. + this.emit("taskComplete") + }) } // ========================================================================== - // Console Suppression + // Logging + Console Suppression // ========================================================================== - private suppressNodeWarnings(): void { - this.originalProcessEmitWarning = process.emitWarning - process.emitWarning = () => {} - process.on("warning", () => {}) - } - private setupQuietMode(): void { if (this.options.integrationTest) { return } + // Suppress node warnings. + this.originalProcessEmitWarning = process.emitWarning + process.emitWarning = () => {} + process.on("warning", () => {}) + + // Suppress console output. this.originalConsole = { log: console.log, warn: console.warn, @@ -308,21 +321,23 @@ export class ExtensionHost extends EventEmitter { } } + private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void { + if (msg.partial) { + if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) { + this.outputManager.setLoggedFirstPartial(msg.ts) + cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask }) + } + } else { + cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask }) + this.outputManager.clearLoggedFirstPartial(msg.ts) + } + } + // ========================================================================== // Extension Lifecycle // ========================================================================== - 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 { - this.suppressNodeWarnings() - this.setupQuietMode() - + public async activate(): Promise { const bundlePath = path.join(this.options.extensionPath, "extension.js") if (!fs.existsSync(bundlePath)) { @@ -333,8 +348,7 @@ export class ExtensionHost extends EventEmitter { let storageDir: string | undefined if (this.options.ephemeral) { - storageDir = await this.createEphemeralStorageDir() - this.ephemeralStorageDir = storageDir + this.ephemeralStorageDir = await createEphemeralStorageDir() } // Create VSCode API mock. @@ -372,6 +386,7 @@ export class ExtensionHost extends EventEmitter { this.extensionModule = require(bundlePath) as ExtensionModule } catch (error) { Module._resolveFilename = originalResolve + throw new Error( `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`, ) @@ -385,168 +400,82 @@ export class ExtensionHost extends EventEmitter { throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`) } - // Set up message listener - forward all messages to client - this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message) + // Set up message listener - forward all messages to client. + this.messageListener = (message: ExtensionMessage) => this.client.handleMessage(message) this.on("extensionWebviewMessage", this.messageListener) + + await pWaitFor(() => this.isReady, { interval: 100, timeout: 10_000 }) } - // ========================================================================== - // Webview Provider Registration - // ========================================================================== + public registerWebviewProvider(_viewId: string, _provider: WebviewViewProvider): void {} - registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void { - this.webviewProviders.set(viewId, provider) - } + public unregisterWebviewProvider(_viewId: string): void {} - unregisterWebviewProvider(viewId: string): void { - this.webviewProviders.delete(viewId) - } + public markWebviewReady(): void { + this.isReady = true - isInInitialSetup(): boolean { - return !this.isWebviewReady - } + // Send initial webview messages to trigger proper extension initialization. + // This is critical for the extension to start sending state updates properly. + this.sendToExtension({ type: "webviewDidLaunch" }) - markWebviewReady(): void { - this.isWebviewReady = true - this.emit("webviewReady") - this.flushPendingMessages() + setRuntimeConfigValues("roo-cline", this.initialSettings as Record) + this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings }) } - private flushPendingMessages(): void { - if (this.pendingMessages.length > 0) { - for (const message of this.pendingMessages) { - this.emit("webviewMessage", message) - } - this.pendingMessages = [] - } + public isInInitialSetup(): boolean { + return !this.isReady } // ========================================================================== // Message Handling // ========================================================================== - sendToExtension(message: WebviewMessage): void { - if (!this.isWebviewReady) { - this.pendingMessages.push(message) - return - } - this.emit("webviewMessage", message) - } - - /** - * Handle incoming messages from extension. - * Forward to client (single source of truth). - */ - private handleExtensionMessage(msg: ExtensionMessage): void { - // Track mode changes - if (msg.type === "state" && msg.state?.mode && typeof msg.state.mode === "string") { - this.currentMode = msg.state.mode + public sendToExtension(message: WebviewMessage): void { + if (!this.isReady) { + throw new Error("You cannot send messages to the extension before it is ready") } - // Forward to client - it's the single source of truth - this.client.handleMessage(msg) - - // Handle modes separately - if (msg.type === "modes") { - this.emit("modesUpdated", msg) - } + this.emit("webviewMessage", message) } // ========================================================================== // Task Management // ========================================================================== - private applyRuntimeSettings(settings: RooCodeSettings): void { - const activeMode = this.currentMode || this.options.mode - if (activeMode) { - settings.mode = activeMode - } - - if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") { - if (this.options.reasoningEffort === "disabled") { - settings.enableReasoningEffort = false - } else { - settings.enableReasoningEffort = true - settings.reasoningEffort = this.options.reasoningEffort - } - } - - setRuntimeConfigValues("roo-cline", settings as Record) - } - - async runTask(prompt: string): Promise { - if (!this.isWebviewReady) { - await new Promise((resolve) => this.once("webviewReady", resolve)) - } - - // Send initial webview messages to trigger proper extension initialization - // This is critical for the extension to start sending state updates properly - this.sendToExtension({ type: "webviewDidLaunch" }) - - const baseSettings: RooCodeSettings = { - commandExecutionTimeout: 30, - browserToolEnabled: false, - enableCheckpoints: false, - ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), - } - - const settings: RooCodeSettings = this.options.nonInteractive - ? { - autoApprovalEnabled: true, - alwaysAllowReadOnly: true, - alwaysAllowReadOnlyOutsideWorkspace: true, - alwaysAllowWrite: true, - alwaysAllowWriteOutsideWorkspace: true, - alwaysAllowWriteProtected: true, - alwaysAllowBrowser: true, - alwaysAllowMcp: true, - alwaysAllowModeSwitch: true, - alwaysAllowSubtasks: true, - alwaysAllowExecute: true, - allowedCommands: ["*"], - ...baseSettings, - } - : { - autoApprovalEnabled: false, - ...baseSettings, - } - - this.applyRuntimeSettings(settings) - this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) - await new Promise((resolve) => setTimeout(resolve, 100)) + public async runTask(prompt: string): Promise { this.sendToExtension({ type: "newTask", text: prompt }) - await this.waitForCompletion() - } - private waitForCompletion(timeoutMs: number = 110000): Promise { return new Promise((resolve, reject) => { let timeoutId: NodeJS.Timeout | null = null + const timeoutMs: number = 110_000 const completeHandler = () => { cleanup() resolve() } + const errorHandler = (error: string) => { cleanup() reject(new Error(error)) } - const timeoutHandler = () => { - cleanup() - reject( - new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`), - ) - } + const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId) timeoutId = null } + this.off("taskComplete", completeHandler) this.off("taskError", errorHandler) } - // Set timeout to prevent indefinite hanging - timeoutId = setTimeout(timeoutHandler, timeoutMs) + // Set timeout to prevent indefinite hanging. + timeoutId = setTimeout(() => { + cleanup() + reject( + new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`), + ) + }, timeoutMs) this.once("taskComplete", completeHandler) this.once("taskError", errorHandler) @@ -554,65 +483,23 @@ export class ExtensionHost extends EventEmitter { } // ========================================================================== - // Public Agent State API (delegated to ExtensionClient) + // Public Agent State API // ========================================================================== /** * Get the current agent loop state. */ - getAgentState(): AgentStateInfo { + public getAgentState(): AgentStateInfo { return this.client.getAgentState() } /** * Check if the agent is currently waiting for user input. */ - isWaitingForInput(): boolean { + public isWaitingForInput(): boolean { return this.client.getAgentState().isWaitingForInput } - /** - * Check if the agent is currently running. - */ - isAgentRunning(): boolean { - return this.client.getAgentState().isRunning - } - - /** - * Get the current agent loop state enum value. - */ - getAgentLoopState(): AgentLoopState { - return this.client.getAgentState().state - } - - /** - * Get the underlying ExtensionClient for advanced use cases. - */ - getExtensionClient(): ExtensionClient { - return this.client - } - - /** - * Get the OutputManager for advanced output control. - */ - getOutputManager(): OutputManager { - return this.outputManager - } - - /** - * Get the PromptManager for advanced prompting. - */ - getPromptManager(): PromptManager { - return this.promptManager - } - - /** - * Get the AskDispatcher for advanced ask handling. - */ - getAskDispatcher(): AskDispatcher { - return this.askDispatcher - } - // ========================================================================== // Cleanup // ========================================================================== @@ -644,7 +531,6 @@ export class ExtensionHost extends EventEmitter { this.vscode = null this.extensionModule = null this.extensionAPI = null - this.webviewProviders.clear() // Clear globals. delete (global as Record).vscode diff --git a/apps/cli/src/agent/message-processor.ts b/apps/cli/src/agent/message-processor.ts index 9ae298caf01..2b9fd13602f 100644 --- a/apps/cli/src/agent/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -161,7 +161,21 @@ export class MessageProcessor { return } - const { clineMessages } = message.state + const { clineMessages, mode } = message.state + + // Track mode changes. + if (mode && typeof mode === "string") { + const previousMode = this.store.getCurrentMode() + + if (previousMode !== mode) { + if (this.options.debug) { + debugLog("[MessageProcessor] Mode changed", { from: previousMode, to: mode }) + } + + this.store.setCurrentMode(mode) + this.emitter.emit("modeChanged", { previousMode, currentMode: mode }) + } + } if (!clineMessages) { if (this.options.debug) { @@ -170,7 +184,7 @@ export class MessageProcessor { return } - // Get previous state for comparison + // Get previous state for comparison. const previousState = this.store.getAgentState() // Update the store with new messages diff --git a/apps/cli/src/agent/state-store.ts b/apps/cli/src/agent/state-store.ts index d502e7bae0e..68dcfc40698 100644 --- a/apps/cli/src/agent/state-store.ts +++ b/apps/cli/src/agent/state-store.ts @@ -48,6 +48,12 @@ export interface StoreState { */ lastUpdatedAt: number + /** + * The current mode (e.g., "code", "architect", "ask"). + * Tracked from state messages received from the extension. + */ + currentMode: string | undefined + /** * Optional: Cache of extension state fields we might need. * This is a subset of the full ExtensionState. @@ -64,6 +70,7 @@ function createInitialState(): StoreState { agentState: detectAgentState([]), isInitialized: false, lastUpdatedAt: Date.now(), + currentMode: undefined, } } @@ -183,6 +190,13 @@ export class StateStore { return this.state.agentState.state } + /** + * Get the current mode (e.g., "code", "architect", "ask"). + */ + getCurrentMode(): string | undefined { + return this.state.currentMode + } + // =========================================================================== // State Updates // =========================================================================== @@ -203,6 +217,7 @@ export class StateStore { agentState: newAgentState, isInitialized: true, lastUpdatedAt: Date.now(), + currentMode: this.state.currentMode, // Preserve mode across message updates }) return previousAgentState @@ -249,10 +264,27 @@ export class StateStore { agentState: detectAgentState([]), isInitialized: true, // Still initialized, just empty lastUpdatedAt: Date.now(), + currentMode: this.state.currentMode, // Preserve mode when clearing task extensionState: undefined, }) } + /** + * Set the current mode. + * Called when mode changes are detected from extension state messages. + * + * @param mode - The new mode value + */ + setCurrentMode(mode: string | undefined): void { + if (this.state.currentMode !== mode) { + this.updateState({ + ...this.state, + currentMode: mode, + lastUpdatedAt: Date.now(), + }) + } + } + /** * Reset to completely uninitialized state. * Called on disconnect or reset. @@ -366,6 +398,7 @@ export function getDefaultStore(): StateStore { if (!defaultStore) { defaultStore = new StateStore() } + return defaultStore } @@ -377,5 +410,6 @@ export function resetDefaultStore(): void { if (defaultStore) { defaultStore.reset() } + defaultStore = null } diff --git a/apps/cli/src/lib/storage/ephemeral.ts b/apps/cli/src/lib/storage/ephemeral.ts new file mode 100644 index 00000000000..28984cfe587 --- /dev/null +++ b/apps/cli/src/lib/storage/ephemeral.ts @@ -0,0 +1,10 @@ +import path from "path" +import os from "os" +import fs from "fs" + +export async function 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 +} diff --git a/apps/cli/src/lib/storage/index.ts b/apps/cli/src/lib/storage/index.ts index 54c5da988e6..53424472c2a 100644 --- a/apps/cli/src/lib/storage/index.ts +++ b/apps/cli/src/lib/storage/index.ts @@ -1,3 +1,4 @@ export * from "./config-dir.js" export * from "./settings.js" export * from "./credentials.js" +export * from "./ephemeral.js" diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 7ffb425c224..fdb8644f53b 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -1,9 +1,8 @@ import { Box, Text, useApp, useInput } from "ink" import { Select } from "@inkjs/ui" import { useState, useEffect, useCallback, useRef, useMemo } from "react" -import type { WebviewMessage } from "@roo-code/types" -import { ExtensionHostOptions } from "@/agent/index.js" +import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js" import { getGlobalCommandsForAutocomplete } from "@/lib/utils/commands.js" import { arePathsEqual } from "@/lib/utils/path.js" @@ -59,15 +58,6 @@ import ScrollIndicator from "./components/ScrollIndicator.js" const PICKER_HEIGHT = 10 -interface ExtensionHostInterface { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on(event: string, handler: (...args: any[]) => void): void - activate(): Promise - runTask(prompt: string): Promise - sendToExtension(message: WebviewMessage): void - dispose(): Promise -} - export interface TUIAppProps extends ExtensionHostOptions { initialPrompt: string debug: boolean diff --git a/apps/cli/src/ui/__tests__/store.test.ts b/apps/cli/src/ui/__tests__/store.test.ts index 0573ad2fad4..5b8b4fbf774 100644 --- a/apps/cli/src/ui/__tests__/store.test.ts +++ b/apps/cli/src/ui/__tests__/store.test.ts @@ -184,47 +184,47 @@ describe("useCLIStore", () => { it("should support the full task resumption workflow", () => { const store = useCLIStore.getState - // Step 1: Initial state with task history and modes from webviewDidLaunch + // Step 1: Initial state with task history and modes from webviewDidLaunch. store().setTaskHistory([{ id: "task1", task: "Previous task", workspace: "/test", ts: Date.now() }]) store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }]) store().setAllSlashCommands([{ key: "new", name: "new", source: "global" as const }]) - // Step 2: User starts a new task + // Step 2: User starts a new task. store().setHasStartedTask(true) store().addMessage({ id: "1", role: "user", content: "New task" }) store().addMessage({ id: "2", role: "assistant", content: "Working on it..." }) store().setLoading(true) - // Verify current state + // Verify current state. expect(store().messages.length).toBe(2) expect(store().hasStartedTask).toBe(true) - // Step 3: User selects a task from history to resume - // This triggers resetForTaskSwitch + setIsResumingTask(true) + // Step 3: User selects a task from history to resume. + // This triggers resetForTaskSwitch + setIsResumingTask(true). store().resetForTaskSwitch() store().setIsResumingTask(true) - // Verify task-specific state is cleared but global state preserved + // Verify task-specific state is cleared but global state preserved. expect(store().messages).toEqual([]) expect(store().isLoading).toBe(false) expect(store().hasStartedTask).toBe(false) - expect(store().isResumingTask).toBe(true) // Flag is set - expect(store().taskHistory.length).toBe(1) // Preserved - expect(store().availableModes.length).toBe(1) // Preserved - expect(store().allSlashCommands.length).toBe(1) // Preserved + expect(store().isResumingTask).toBe(true) // Flag is set. + expect(store().taskHistory.length).toBe(1) // Preserved. + expect(store().availableModes.length).toBe(1) // Preserved. + expect(store().allSlashCommands.length).toBe(1) // Preserved. // Step 4: Extension sends state message with clineMessages - // (simulated by adding messages) + // (simulated by adding messages). store().addMessage({ id: "old1", role: "user", content: "Previous task prompt" }) store().addMessage({ id: "old2", role: "assistant", content: "Previous response" }) - // Step 5: After processing state, isResumingTask should be cleared + // Step 5: After processing state, isResumingTask should be cleared. store().setIsResumingTask(false) - // Final verification + // Final verification. expect(store().isResumingTask).toBe(false) expect(store().messages.length).toBe(2) - expect(store().taskHistory.length).toBe(1) // Still preserved + expect(store().taskHistory.length).toBe(1) // Still preserved. }) it("should allow reading isResumingTask synchronously during message processing", () => { diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index 949fe0a5a6d..3494cc66ac3 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -3,19 +3,10 @@ import { useApp } from "ink" import { randomUUID } from "crypto" import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" -import { ExtensionHostOptions } from "@/agent/index.js" +import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js" import { useCLIStore } from "../store.js" -interface ExtensionHostInterface { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on(event: string, handler: (...args: any[]) => void): void - activate(): Promise - runTask(prompt: string): Promise - sendToExtension(message: WebviewMessage): void - dispose(): Promise -} - export interface UseExtensionHostOptions extends ExtensionHostOptions { initialPrompt?: string exitOnComplete?: boolean @@ -111,7 +102,6 @@ export function useExtensionHost({ // Request initial state from extension (triggers // postStateToWebview which includes taskHistory). - host.sendToExtension({ type: "webviewDidLaunch" }) host.sendToExtension({ type: "requestCommands" }) host.sendToExtension({ type: "requestModes" }) @@ -136,28 +126,25 @@ export function useExtensionHost({ } }, []) // Run once on mount - // Stable sendToExtension - uses ref to always access current host - // This function reference never changes, preventing downstream useCallback/useMemo invalidations + // Stable sendToExtension - uses ref to always access current host. + // This function reference never changes, preventing downstream + // useCallback/useMemo invalidations. const sendToExtension = useCallback((msg: WebviewMessage) => { hostRef.current?.sendToExtension(msg) }, []) - // Stable runTask - uses ref to always access current host + // Stable runTask - uses ref to always access current host. const runTask = useCallback((prompt: string): Promise => { if (!hostRef.current) { return Promise.reject(new Error("Extension host not ready")) } + return hostRef.current.runTask(prompt) }, []) - // Memoized return object to prevent unnecessary re-renders in consumers + // Memoized return object to prevent unnecessary re-renders in consumers. return useMemo( - () => ({ - isReady: isReadyRef.current, - sendToExtension, - runTask, - cleanup, - }), + () => ({ isReady: isReadyRef.current, sendToExtension, runTask, cleanup }), [sendToExtension, runTask, cleanup], ) } diff --git a/apps/cli/src/ui/hooks/useTaskSubmit.ts b/apps/cli/src/ui/hooks/useTaskSubmit.ts index c45d8bbd004..0ae752a7aca 100644 --- a/apps/cli/src/ui/hooks/useTaskSubmit.ts +++ b/apps/cli/src/ui/hooks/useTaskSubmit.ts @@ -73,14 +73,15 @@ export function useTaskSubmit({ const globalCommand = getGlobalCommand(commandMatch[1]) if (globalCommand?.action === "clearTask") { - // Reset CLI state and send clearTask to extension + // Reset CLI state and send clearTask to extension. useCLIStore.getState().reset() - // Reset component-level refs to avoid stale message tracking + + // Reset component-level refs to avoid stale message tracking. seenMessageIds.current.clear() firstTextMessageSkipped.current = false sendToExtension({ type: "clearTask" }) - // Re-request state, commands and modes since reset() cleared them - sendToExtension({ type: "webviewDidLaunch" }) + + // Re-request state, commands and modes since reset() cleared them. sendToExtension({ type: "requestCommands" }) sendToExtension({ type: "requestModes" }) return diff --git a/packages/vscode-shim/src/index.ts b/packages/vscode-shim/src/index.ts index 02c1b2f2b85..52f50138f54 100644 --- a/packages/vscode-shim/src/index.ts +++ b/packages/vscode-shim/src/index.ts @@ -80,6 +80,7 @@ export { type FileStat, type Terminal, type CancellationToken, + type IExtensionHost, } from "./vscode.js" // Export utilities diff --git a/packages/vscode-shim/src/interfaces/extension-host.ts b/packages/vscode-shim/src/interfaces/extension-host.ts new file mode 100644 index 00000000000..1b48327291d --- /dev/null +++ b/packages/vscode-shim/src/interfaces/extension-host.ts @@ -0,0 +1,69 @@ +/** + * Interface defining the contract that an ExtensionHost must implement + * to work with the vscode-shim WindowAPI. + * + * This interface is used implicitly by WindowAPI when accessing global.__extensionHost. + * The ExtensionHost implementation (e.g., in apps/cli) must satisfy this contract. + */ + +import type { WebviewViewProvider } from "./webview.js" + +/** + * ExtensionHost interface for bridging the vscode-shim with the actual extension host. + * + * The ExtensionHost acts as a message broker between the extension and the CLI/webview, + * providing event-based communication and webview provider registration. + */ +export interface IExtensionHost { + /** + * Register a webview view provider with a specific view ID. + * Called by WindowAPI.registerWebviewViewProvider to allow the extension host + * to track registered providers. + * + * @param viewId - The unique identifier for the webview view + * @param provider - The webview view provider to register + */ + registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void + + /** + * Unregister a previously registered webview view provider. + * Called when disposing of a webview registration. + * + * @param viewId - The unique identifier of the webview view to unregister + */ + unregisterWebviewProvider(viewId: string): void + + /** + * Check if the extension host is in its initial setup phase. + * Used to determine if certain actions should be deferred until setup completes. + * + * @returns true if initial setup is in progress, false otherwise + */ + isInInitialSetup(): boolean + + /** + * Mark the webview as ready, signaling that initial setup has completed. + * This should be called after resolveWebviewView completes successfully. + */ + markWebviewReady(): void + + /** + * Emit an event to registered listeners. + * Used for forwarding messages from the extension to the webview/CLI. + * + * @param event - The event name to emit + * @param message - The message payload to send with the event + * @returns true if the event had listeners, false otherwise + */ + emit(event: string, message: unknown): boolean + + /** + * Register a listener for an event. + * Used for receiving messages from the webview/CLI to the extension. + * + * @param event - The event name to listen for + * @param listener - The callback function to invoke when the event is emitted + * @returns The ExtensionHost instance for chaining + */ + on(event: string, listener: (message: unknown) => void): this +} diff --git a/packages/vscode-shim/src/vscode.ts b/packages/vscode-shim/src/vscode.ts index 27dbedc7700..9d6c55255c5 100644 --- a/packages/vscode-shim/src/vscode.ts +++ b/packages/vscode-shim/src/vscode.ts @@ -129,6 +129,9 @@ export type { UriHandler, } from "./interfaces/webview.js" +// Extension host interface +export type { IExtensionHost } from "./interfaces/extension-host.js" + // Workspace interfaces export type { WorkspaceConfiguration, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b792874495..177d0b3e5ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: ink: specifier: ^6.6.0 version: 6.6.0(@types/react@18.3.23)(react@19.2.3) + p-wait-for: + specifier: ^5.0.2 + version: 5.0.2 react: specifier: ^19.1.0 version: 19.2.3 From 569055ea9a4b5ef5429b3facd6f79b907d271d3c Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 10 Jan 2026 01:34:24 -0800 Subject: [PATCH 2/3] Better type safety --- .../agent/__tests__/extension-host.test.ts | 59 ++++++++++++------- apps/cli/src/agent/extension-host.ts | 39 +++++------- apps/cli/src/ui/hooks/useExtensionHost.ts | 8 +-- packages/vscode-shim/src/index.ts | 2 + .../src/interfaces/extension-host.ts | 26 +++++++- packages/vscode-shim/src/vscode.ts | 2 +- 6 files changed, 82 insertions(+), 54 deletions(-) diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index d9cdb11efba..38edf50d283 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -3,10 +3,11 @@ import { EventEmitter } from "events" import fs from "fs" -import type { WebviewMessage } from "@roo-code/types" +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js" import { ExtensionClient } from "../extension-client.js" +import { AgentLoopState } from "../agent-state.js" vi.mock("@roo-code/vscode-shim", () => ({ createVSCodeAPI: vi.fn(() => ({ @@ -248,11 +249,14 @@ describe("ExtensionHost", () => { const host = createTestHost() const client = getPrivate(host, "client") as ExtensionClient - // Simulate extension message - host.emit("extensionWebviewMessage", { type: "state", state: { clineMessages: [] } }) + // Simulate extension message. + host.emit("extensionWebviewMessage", { + type: "state", + state: { clineMessages: [] }, + } as unknown as ExtensionMessage) - // Message listener is set up in activate(), which we can't easily call in unit tests - // But we can verify the client exists and has the handleMessage method + // Message listener is set up in activate(), which we can't easily call in unit tests. + // But we can verify the client exists and has the handleMessage method. expect(typeof client.handleMessage).toBe("function") }) }) @@ -420,40 +424,53 @@ describe("ExtensionHost", () => { host.markWebviewReady() const emitSpy = vi.spyOn(host, "emit") + const client = getPrivate(host, "client") as ExtensionClient // Start the task (will hang waiting for completion) const taskPromise = host.runTask("test prompt") - // Emit completion to resolve the promise - setTimeout(() => host.emit("taskComplete"), 10) + // Emit completion to resolve the promise via the client's emitter + const taskCompletedEvent = { + success: true, + stateInfo: { + state: AgentLoopState.IDLE, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_task" as const, + description: "Task completed", + }, + } + setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10) await taskPromise expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" }) }) - it("should resolve when taskComplete is emitted", async () => { + it("should resolve when taskCompleted is emitted on client", async () => { const host = createTestHost() host.markWebviewReady() + const client = getPrivate(host, "client") as ExtensionClient const taskPromise = host.runTask("test prompt") - // Emit completion after a short delay - setTimeout(() => host.emit("taskComplete"), 10) + // Emit completion after a short delay via the client's emitter + const taskCompletedEvent = { + success: true, + stateInfo: { + state: AgentLoopState.IDLE, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_task" as const, + description: "Task completed", + }, + } + setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10) await expect(taskPromise).resolves.toBeUndefined() }) - - it("should reject when taskError is emitted", async () => { - const host = createTestHost() - host.markWebviewReady() - - const taskPromise = host.runTask("test prompt") - - setTimeout(() => host.emit("taskError", "Test error"), 10) - - await expect(taskPromise).rejects.toThrow("Test error") - }) }) describe("initial settings", () => { diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index f42a6c3aa16..da40c990b27 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -8,11 +8,12 @@ * 4. Wiring up managers for output, prompting, and ask handling */ -import { EventEmitter } from "events" import { createRequire } from "module" import path from "path" import { fileURLToPath } from "url" import fs from "fs" +import { EventEmitter } from "events" + import pWaitFor from "p-wait-for" import type { @@ -22,7 +23,7 @@ import type { RooCodeSettings, WebviewMessage, } from "@roo-code/types" -import { createVSCodeAPI, IExtensionHost, setRuntimeConfigValues } from "@roo-code/vscode-shim" +import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim" import { DebugLogger } from "@roo-code/core/cli" import type { SupportedProvider } from "@/types/index.js" @@ -30,7 +31,7 @@ import type { User } from "@/lib/sdk/index.js" import { getProviderSettings } from "@/lib/utils/provider.js" import { createEphemeralStorageDir } from "@/lib/storage/index.js" -import type { AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js" +import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js" import type { AgentStateInfo } from "./agent-state.js" import { ExtensionClient } from "./extension-client.js" import { OutputManager } from "./output-manager.js" @@ -83,16 +84,15 @@ interface WebviewViewProvider { resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise } -export interface ExtensionHostInterface { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on(event: string, handler: (...args: any[]) => void): void +export interface ExtensionHostInterface extends IExtensionHost { + client: ExtensionClient activate(): Promise runTask(prompt: string): Promise sendToExtension(message: WebviewMessage): void dispose(): Promise } -export class ExtensionHost extends EventEmitter implements IExtensionHost { +export class ExtensionHost extends EventEmitter implements ExtensionHostInterface { // Extension lifecycle. private vscode: ReturnType | null = null private extensionModule: ExtensionModule | null = null @@ -124,7 +124,7 @@ export class ExtensionHost extends EventEmitter implements IExtensionHost { * ExtensionClient: Single source of truth for agent loop state. * Handles message processing and state detection. */ - private client: ExtensionClient + public readonly client: ExtensionClient /** * OutputManager: Handles all CLI output and streaming. @@ -234,11 +234,6 @@ export class ExtensionHost extends EventEmitter implements IExtensionHost { * The client emits events, managers handle them. */ private setupClientEventHandlers(): void { - // Forward state changes for external consumers. - this.client.on("stateChange", (event: AgentStateChangeEvent) => { - this.emit("agentStateChange", event) - }) - // Handle new messages - delegate to OutputManager. this.client.on("message", (msg: ClineMessage) => { this.logMessageDebug(msg, "new") @@ -253,22 +248,16 @@ export class ExtensionHost extends EventEmitter implements IExtensionHost { // Handle waiting for input - delegate to AskDispatcher. this.client.on("waitingForInput", (event: WaitingForInputEvent) => { - this.emit("agentWaitingForInput", event) this.askDispatcher.handleAsk(event.message) }) // Handle task completion. this.client.on("taskCompleted", (event: TaskCompletedEvent) => { - this.emit("agentTaskCompleted", event) - // Output completion message via OutputManager. // Note: completion_result is an "ask" type, not a "say" type. if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") { this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "") } - - // Emit taskComplete for waitForCompletion. - this.emit("taskComplete") }) } @@ -454,9 +443,9 @@ export class ExtensionHost extends EventEmitter implements IExtensionHost { resolve() } - const errorHandler = (error: string) => { + const errorHandler = (error: Error) => { cleanup() - reject(new Error(error)) + reject(error) } const cleanup = () => { @@ -465,8 +454,8 @@ export class ExtensionHost extends EventEmitter implements IExtensionHost { timeoutId = null } - this.off("taskComplete", completeHandler) - this.off("taskError", errorHandler) + this.client.off("taskCompleted", completeHandler) + this.client.off("error", errorHandler) } // Set timeout to prevent indefinite hanging. @@ -477,8 +466,8 @@ export class ExtensionHost extends EventEmitter implements IExtensionHost { ) }, timeoutMs) - this.once("taskComplete", completeHandler) - this.once("taskError", errorHandler) + this.client.once("taskCompleted", completeHandler) + this.client.once("error", errorHandler) }) } diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index 3494cc66ac3..91bdac2bf01 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -80,9 +80,9 @@ export function useExtensionHost({ hostRef.current = host isReadyRef.current = true - host.on("extensionWebviewMessage", onExtensionMessage) + host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage)) - host.on("taskComplete", async () => { + host.client.on("taskCompleted", async () => { setComplete(true) setLoading(false) @@ -93,8 +93,8 @@ export function useExtensionHost({ } }) - host.on("taskError", (err: string) => { - setError(err) + host.client.on("error", (err: Error) => { + setError(err.message) setLoading(false) }) diff --git a/packages/vscode-shim/src/index.ts b/packages/vscode-shim/src/index.ts index 52f50138f54..8f40746de7b 100644 --- a/packages/vscode-shim/src/index.ts +++ b/packages/vscode-shim/src/index.ts @@ -81,6 +81,8 @@ export { type Terminal, type CancellationToken, type IExtensionHost, + type ExtensionHostEventMap, + type ExtensionHostEventName, } from "./vscode.js" // Export utilities diff --git a/packages/vscode-shim/src/interfaces/extension-host.ts b/packages/vscode-shim/src/interfaces/extension-host.ts index 1b48327291d..f485ee60211 100644 --- a/packages/vscode-shim/src/interfaces/extension-host.ts +++ b/packages/vscode-shim/src/interfaces/extension-host.ts @@ -8,13 +8,33 @@ import type { WebviewViewProvider } from "./webview.js" +/** + * Core event map for ExtensionHost communication. + * Maps event names to their payload types. + * + * - "extensionWebviewMessage": Messages from the extension to the webview/CLI + * - "webviewMessage": Messages from the webview/CLI to the extension + */ +export interface ExtensionHostEventMap { + extensionWebviewMessage: unknown + webviewMessage: unknown +} + +/** + * Allowed event names for ExtensionHost communication. + */ +export type ExtensionHostEventName = keyof ExtensionHostEventMap + /** * ExtensionHost interface for bridging the vscode-shim with the actual extension host. * * The ExtensionHost acts as a message broker between the extension and the CLI/webview, * providing event-based communication and webview provider registration. + * + * @template TEventMap - Event map type that must include the core ExtensionHostEventMap events. + * Implementations can extend this with additional events. */ -export interface IExtensionHost { +export interface IExtensionHost { /** * Register a webview view provider with a specific view ID. * Called by WindowAPI.registerWebviewViewProvider to allow the extension host @@ -55,7 +75,7 @@ export interface IExtensionHost { * @param message - The message payload to send with the event * @returns true if the event had listeners, false otherwise */ - emit(event: string, message: unknown): boolean + emit(event: K, message: TEventMap[K]): boolean /** * Register a listener for an event. @@ -65,5 +85,5 @@ export interface IExtensionHost { * @param listener - The callback function to invoke when the event is emitted * @returns The ExtensionHost instance for chaining */ - on(event: string, listener: (message: unknown) => void): this + on(event: K, listener: (message: TEventMap[K]) => void): this } diff --git a/packages/vscode-shim/src/vscode.ts b/packages/vscode-shim/src/vscode.ts index 9d6c55255c5..a25cd1e8d99 100644 --- a/packages/vscode-shim/src/vscode.ts +++ b/packages/vscode-shim/src/vscode.ts @@ -130,7 +130,7 @@ export type { } from "./interfaces/webview.js" // Extension host interface -export type { IExtensionHost } from "./interfaces/extension-host.js" +export type { IExtensionHost, ExtensionHostEventMap, ExtensionHostEventName } from "./interfaces/extension-host.js" // Workspace interfaces export type { From a3f02bdefdf5a6787759b3d86bf35b46202bfb34 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 10 Jan 2026 13:31:27 -0800 Subject: [PATCH 3/3] Streaming comment execution & streaming file writes --- apps/cli/docs/AGENT_LOOP.md | 58 +- .../agent/__tests__/extension-host.test.ts | 7 +- apps/cli/src/agent/ask-dispatcher.ts | 81 +-- apps/cli/src/agent/events.ts | 15 + apps/cli/src/agent/extension-host.ts | 50 +- apps/cli/src/agent/index.ts | 2 + apps/cli/src/agent/message-processor.ts | 61 ++- apps/cli/src/agent/output-manager.ts | 230 +++++++- apps/cli/src/ui/App.tsx | 38 +- .../cli/src/ui/components/ChatHistoryItem.tsx | 29 +- .../autocomplete/triggers/HelpTrigger.tsx | 1 - .../triggers/__tests__/HelpTrigger.test.tsx | 8 - .../__tests__/HistoryTrigger.test.tsx | 7 - .../src/ui/components/autocomplete/types.ts | 8 - .../autocomplete/useAutocompletePicker.ts | 24 +- .../src/ui/components/tools/FileWriteTool.tsx | 82 ++- apps/cli/src/ui/hooks/index.ts | 6 +- apps/cli/src/ui/hooks/useClientEvents.ts | 508 ++++++++++++++++++ apps/cli/src/ui/hooks/useExtensionHost.ts | 32 +- apps/cli/src/ui/hooks/useExtensionState.ts | 83 +++ apps/cli/src/ui/hooks/useMessageHandlers.ts | 410 -------------- apps/cli/src/ui/store.ts | 17 + src/core/tools/WriteToFileTool.ts | 44 +- 23 files changed, 1232 insertions(+), 569 deletions(-) create mode 100644 apps/cli/src/ui/hooks/useClientEvents.ts create mode 100644 apps/cli/src/ui/hooks/useExtensionState.ts delete mode 100644 apps/cli/src/ui/hooks/useMessageHandlers.ts diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md index a7b1d9eed40..ab3c145f4a7 100644 --- a/apps/cli/docs/AGENT_LOOP.md +++ b/apps/cli/docs/AGENT_LOOP.md @@ -37,11 +37,57 @@ interface ClineMessage { | **say** | Informational - agent is telling you something | No | | **ask** | Interactive - agent needs something from you | Usually yes | -## The Key Insight +## The Key Insight: How the CLI Knows When to Prompt -> **The agent loop stops whenever the last message is an `ask` type (with `partial: false`).** +The CLI doesn't receive any special "waiting" signal from the extension. Instead, it simply **looks at the last message** and asks three questions: -The specific `ask` value tells you exactly what the agent needs. +### Is the agent waiting for user input? + +``` +isWaitingForInput = true when ALL of these are true: + + 1. Last message type is "ask" (not "say") + 2. Last message is NOT partial: true (streaming is complete) + 3. The ask type is "blocking" (not "command_output") +``` + +That's it. No timing. No special signals. Just look at what the last message is. + +### Why this works + +When the extension needs user input: + +- It sends an `ask` message and **blocks** (waits for response) +- The ask stays as the last message until the CLI responds +- CLI sees the ask → prompts user → sends response → extension continues + +When auto-approval is enabled: + +- Extension sends an `ask` message +- Extension **immediately auto-responds** to its own ask (doesn't wait) +- New messages quickly follow the ask +- CLI sees the ask but it's quickly superseded by newer messages +- State never "settles" at waiting because the extension kept going + +### The Simple Logic + +```typescript +function isWaitingForInput(messages) { + const lastMessage = messages.at(-1) + + // Still streaming? Not waiting. + if (lastMessage?.partial === true) return false + + // Not an ask? Not waiting. + if (lastMessage?.type !== "ask") return false + + // Non-blocking ask? Not waiting. + if (lastMessage?.ask === "command_output") return false + + // It's a blocking ask that's done streaming → waiting! + return true +} +``` ## Ask Categories @@ -348,8 +394,8 @@ Example output: ## Summary 1. **Agent communicates via `ClineMessage` stream** -2. **Last message determines state** -3. **`ask` messages (non-partial) block the agent** -4. **Ask category determines required action** +2. **State detection is simple: look at the last message** +3. **Waiting = last message is a non-partial, blocking `ask`** +4. **Auto-approval works by the extension auto-responding to its own asks** 5. **`partial: true` or `api_req_started` without cost = streaming** 6. **`ExtensionClient` is the single source of truth** diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index 38edf50d283..854c0f2c786 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -98,12 +98,11 @@ describe("ExtensionHost", () => { const host = new ExtensionHost(options) - // Options are stored but integrationTest is set to true + // Options are stored as provided const storedOptions = getPrivate(host, "options") expect(storedOptions.mode).toBe(options.mode) expect(storedOptions.workspacePath).toBe(options.workspacePath) expect(storedOptions.extensionPath).toBe(options.extensionPath) - expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor }) it("should be an EventEmitter instance", () => { @@ -281,8 +280,8 @@ describe("ExtensionHost", () => { describe("quiet mode", () => { describe("setupQuietMode", () => { it("should not modify console when integrationTest is true", () => { - // By default, constructor sets integrationTest = true - const host = createTestHost() + // Explicitly set integrationTest = true + const host = createTestHost({ integrationTest: true }) const originalLog = console.log callPrivate(host, "setupQuietMode") diff --git a/apps/cli/src/agent/ask-dispatcher.ts b/apps/cli/src/agent/ask-dispatcher.ts index 8d57e4547cd..9958212ece1 100644 --- a/apps/cli/src/agent/ask-dispatcher.ts +++ b/apps/cli/src/agent/ask-dispatcher.ts @@ -135,6 +135,7 @@ export class AskDispatcher { } // Skip partial messages (wait for complete) + // Note: Streaming output for partial tool/command messages is handled by OutputManager if (message.partial) { return { handled: false } } @@ -356,9 +357,12 @@ export class AskDispatcher { * Handle command execution approval. */ private async handleCommandApproval(ts: number, text: string): Promise { - this.outputManager.output("\n[command request]") - this.outputManager.output(` Command: ${text || "(no command specified)"}`) - this.outputManager.markDisplayed(ts, text || "", false) + // Skip output if we already streamed this command via partial messages + if (!this.outputManager.isAlreadyDisplayed(ts)) { + this.outputManager.output("\n[command request]") + this.outputManager.output(` Command: ${text || "(no command specified)"}`) + this.outputManager.markDisplayed(ts, text || "", false) + } if (this.nonInteractive) { // Auto-approved by extension settings @@ -380,46 +384,49 @@ export class AskDispatcher { * Handle tool execution approval. */ private async handleToolApproval(ts: number, text: string): Promise { - let toolName = "unknown" - let toolInfo: Record = {} - - try { - toolInfo = JSON.parse(text) as Record - toolName = (toolInfo.tool as string) || "unknown" - } catch { - // Use raw text if not JSON - } - - const isProtected = toolInfo.isProtected === true - - if (isProtected) { - this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`) - this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`) - this.outputManager.output( - ` Protected files include .rooignore, .roo/*, and other sensitive config files.`, - ) - } else { - this.outputManager.output(`\n[Tool Request] ${toolName}`) - } + // Skip output if we already streamed this tool request via partial messages + if (!this.outputManager.isAlreadyDisplayed(ts)) { + let toolName = "unknown" + let toolInfo: Record = {} + + try { + toolInfo = JSON.parse(text) as Record + toolName = (toolInfo.tool as string) || "unknown" + } catch { + // Use raw text if not JSON + } - // Display tool details - for (const [key, value] of Object.entries(toolInfo)) { - if (key === "tool" || key === "isProtected") continue + const isProtected = toolInfo.isProtected === true - let displayValue: string - if (typeof value === "string") { - displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value - } else if (typeof value === "object" && value !== null) { - const json = JSON.stringify(value) - displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json + if (isProtected) { + this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`) + this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`) + this.outputManager.output( + ` Protected files include .rooignore, .roo/*, and other sensitive config files.`, + ) } else { - displayValue = String(value) + this.outputManager.output(`\n[Tool Request] ${toolName}`) } - this.outputManager.output(` ${key}: ${displayValue}`) - } + // Display tool details + for (const [key, value] of Object.entries(toolInfo)) { + if (key === "tool" || key === "isProtected") continue + + let displayValue: string + if (typeof value === "string") { + displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value + } else if (typeof value === "object" && value !== null) { + const json = JSON.stringify(value) + displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json + } else { + displayValue = String(value) + } + + this.outputManager.output(` ${key}: ${displayValue}`) + } - this.outputManager.markDisplayed(ts, text || "", false) + this.outputManager.markDisplayed(ts, text || "", false) + } if (this.nonInteractive) { // Auto-approved by extension settings (unless protected) diff --git a/apps/cli/src/agent/events.ts b/apps/cli/src/agent/events.ts index 9b374310ad7..49a82a97c06 100644 --- a/apps/cli/src/agent/events.ts +++ b/apps/cli/src/agent/events.ts @@ -76,6 +76,11 @@ export interface ClientEventMap { */ modeChanged: ModeChangedEvent + /** + * Emitted when command execution output is received (streaming terminal output). + */ + commandExecutionOutput: CommandExecutionOutputEvent + /** * Emitted on any error during message processing. */ @@ -128,6 +133,16 @@ export interface ModeChangedEvent { currentMode: string } +/** + * Event payload for command execution output (streaming terminal output). + */ +export interface CommandExecutionOutputEvent { + /** Unique execution ID */ + executionId: string + /** The terminal output received so far */ + output: string +} + // ============================================================================= // Typed Event Emitter // ============================================================================= diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index da40c990b27..968de48e9fc 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -31,7 +31,7 @@ import type { User } from "@/lib/sdk/index.js" import { getProviderSettings } from "@/lib/utils/provider.js" import { createEphemeralStorageDir } from "@/lib/storage/index.js" -import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js" +import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js" import type { AgentStateInfo } from "./agent-state.js" import { ExtensionClient } from "./extension-client.js" import { OutputManager } from "./output-manager.js" @@ -152,7 +152,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac super() this.options = options - this.options.integrationTest = true + // this.options.integrationTest = true // Initialize client - single source of truth for agent state (including mode). this.client = new ExtensionClient({ @@ -189,6 +189,17 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac commandExecutionTimeout: 30, browserToolEnabled: false, enableCheckpoints: false, + // Disable preventFocusDisruption experiment for CLI - it's only relevant for VSCode diff views + // and preventing it causes tool messages to not stream during LLM generation + experiments: { + multiFileApplyDiff: false, + powerSteering: false, + preventFocusDisruption: false, + imageGeneration: false, + runSlashCommand: false, + multipleNativeToolCalls: false, + customTools: false, + }, ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), } @@ -237,12 +248,26 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Handle new messages - delegate to OutputManager. this.client.on("message", (msg: ClineMessage) => { this.logMessageDebug(msg, "new") + // DEBUG: Log all incoming messages with timestamp (only when -d flag is set) + if (this.options.debug) { + const ts = new Date().toISOString() + const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}` + const partial = msg.partial ? "PARTIAL" : "COMPLETE" + process.stdout.write(`\n[DEBUG ${ts}] NEW ${msgType} ${partial} ts=${msg.ts}\n`) + } this.outputManager.outputMessage(msg) }) // Handle message updates - delegate to OutputManager. this.client.on("messageUpdated", (msg: ClineMessage) => { this.logMessageDebug(msg, "updated") + // DEBUG: Log all message updates with timestamp (only when -d flag is set) + if (this.options.debug) { + const ts = new Date().toISOString() + const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}` + const partial = msg.partial ? "PARTIAL" : "COMPLETE" + process.stdout.write(`\n[DEBUG ${ts}] UPDATED ${msgType} ${partial} ts=${msg.ts}\n`) + } this.outputManager.outputMessage(msg) }) @@ -259,6 +284,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "") } }) + + // Handle streaming terminal output from commandExecutionStatus messages. + this.client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => { + this.outputManager.outputStreamingTerminalOutput(event.executionId, event.output) + }) } // ========================================================================== @@ -435,9 +465,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.sendToExtension({ type: "newTask", text: prompt }) return new Promise((resolve, reject) => { - let timeoutId: NodeJS.Timeout | null = null - const timeoutMs: number = 110_000 - const completeHandler = () => { cleanup() resolve() @@ -449,23 +476,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac } const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null - } - this.client.off("taskCompleted", completeHandler) this.client.off("error", errorHandler) } - // Set timeout to prevent indefinite hanging. - timeoutId = setTimeout(() => { - cleanup() - reject( - new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`), - ) - }, timeoutMs) - this.client.once("taskCompleted", completeHandler) this.client.once("error", errorHandler) }) diff --git a/apps/cli/src/agent/index.ts b/apps/cli/src/agent/index.ts index 23cbaacb4d1..f55fefad058 100644 --- a/apps/cli/src/agent/index.ts +++ b/apps/cli/src/agent/index.ts @@ -1 +1,3 @@ export * from "./extension-host.js" +export { ExtensionClient } from "./extension-client.js" +export type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js" diff --git a/apps/cli/src/agent/message-processor.ts b/apps/cli/src/agent/message-processor.ts index 2b9fd13602f..dce32a93e73 100644 --- a/apps/cli/src/agent/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -21,7 +21,13 @@ import { ExtensionMessage, ClineMessage } from "@roo-code/types" import { debugLog } from "@roo-code/core/cli" import type { StateStore } from "./state-store.js" -import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js" +import type { + TypedEventEmitter, + AgentStateChangeEvent, + WaitingForInputEvent, + TaskCompletedEvent, + CommandExecutionOutputEvent, +} from "./events.js" import { isSignificantStateChange, transitionedToWaiting, @@ -121,6 +127,10 @@ export class MessageProcessor { this.handleInvoke(message) break + case "commandExecutionStatus": + this.handleCommandExecutionStatus(message) + break + default: // Other message types are not relevant to state detection if (this.options.debug) { @@ -237,6 +247,7 @@ export class MessageProcessor { } const clineMessage = message.clineMessage + const previousState = this.store.getAgentState() // Update the message in the store @@ -277,6 +288,45 @@ export class MessageProcessor { // But they might trigger state changes through subsequent messages } + /** + * Handle a "commandExecutionStatus" message - streaming terminal output. + * + * This message is sent during command execution to provide live terminal + * output before the final command_output message is created. + */ + private handleCommandExecutionStatus(message: ExtensionMessage): void { + if (!message.text) { + return + } + + try { + const status = JSON.parse(message.text) as { status: string; executionId?: string; output?: string } + + // Only emit for "output" status which contains terminal output + if (status.status === "output" && status.executionId && status.output !== undefined) { + if (this.options.debug) { + debugLog("[MessageProcessor] Command execution output", { + executionId: status.executionId, + outputLength: status.output.length, + }) + } + + const event: CommandExecutionOutputEvent = { + executionId: status.executionId, + output: status.output, + } + this.emitter.emit("commandExecutionOutput", event) + } + } catch { + // Ignore parse errors + if (this.options.debug) { + debugLog("[MessageProcessor] Failed to parse commandExecutionStatus", { + text: message.text?.substring(0, 100), + }) + } + } + } + // =========================================================================== // Event Emission Helpers // =========================================================================== @@ -372,6 +422,15 @@ export class MessageProcessor { // A more sophisticated implementation would track seen message timestamps const lastMessage = messages[messages.length - 1] if (lastMessage) { + // DEBUG: Log all emitted ask messages to trace partial handling + if (this.options.debug && lastMessage.type === "ask") { + debugLog("[MessageProcessor] EMIT message", { + ask: lastMessage.ask, + partial: lastMessage.partial, + textLen: lastMessage.text?.length || 0, + ts: lastMessage.ts, + }) + } this.emitter.emit("message", lastMessage) } } diff --git a/apps/cli/src/agent/output-manager.ts b/apps/cli/src/agent/output-manager.ts index 0863546f6c4..f657b2802e2 100644 --- a/apps/cli/src/agent/output-manager.ts +++ b/apps/cli/src/agent/output-manager.ts @@ -90,6 +90,16 @@ export class OutputManager { */ private loggedFirstPartial = new Set() + /** + * Track streaming terminal output by execution ID. + */ + private terminalOutputByExecutionId = new Map() + + /** + * Flag to track if we've streamed any terminal output (to skip command_output). + */ + private hasStreamedTerminalOutput = false + /** * Observable for streaming state changes. * External systems can subscribe to know when streaming starts/ends. @@ -126,10 +136,24 @@ export class OutputManager { if (msg.type === "say" && msg.say) { this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage) } else if (msg.type === "ask" && msg.ask) { - // For ask messages, we only output command_output here - // Other asks are handled by AskDispatcher - if (msg.ask === "command_output") { - this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete) + // Handle streaming output for different ask types + switch (msg.ask) { + case "command_output": + this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete) + break + + case "tool": + // Stream tool requests (file create/edit/delete) as they come in + this.outputToolRequest(ts, text, isPartial, alreadyDisplayedComplete) + break + + case "command": + // Stream command requests as they come in + this.outputCommandRequest(ts, text, isPartial, alreadyDisplayedComplete) + break + + // Other ask types (followup, completion_result, etc.) are handled by AskDispatcher + // when complete (partial: false) } } } @@ -161,9 +185,16 @@ export class OutputManager { } /** - * Check if a message has already been fully displayed. + * Check if a message has already been displayed (streamed or complete). + * Returns true if we've streamed content for this ts OR if we've fully displayed it. */ isAlreadyDisplayed(ts: number): boolean { + // Check if we've streamed any content for this message + // (streamedContent is set during streaming, before displayedMessages is finalized) + if (this.streamedContent.has(ts)) { + return true + } + // Check if we've fully displayed this message const displayed = this.displayedMessages.get(ts) return displayed !== undefined && !displayed.partial } @@ -198,6 +229,10 @@ export class OutputManager { this.streamedContent.clear() this.currentlyStreamingTs = null this.loggedFirstPartial.clear() + this.terminalOutputByExecutionId.clear() + this.hasStreamedTerminalOutput = false + this.toolContentStreamed.clear() + this.toolContentTruncated.clear() this.streamingState.next({ ts: null, isStreaming: false }) } @@ -335,6 +370,7 @@ export class OutputManager { /** * Output command_output (shared between say and ask types). + * Skips output if we've already streamed terminal output via commandExecutionStatus. */ outputCommandOutput( ts: number, @@ -342,6 +378,15 @@ export class OutputManager { isPartial: boolean, alreadyDisplayedComplete: boolean | undefined, ): void { + // Skip if we've already streamed terminal output - avoid duplicate display + if (this.hasStreamedTerminalOutput) { + // Mark as displayed but don't output - we already showed it via [terminal] + if (!isPartial) { + this.displayedMessages.set(ts, { ts, text, partial: false }) + } + return + } + if (isPartial && text) { this.streamContent(ts, text, "[command output]") this.displayedMessages.set(ts, { ts, text, partial: true }) @@ -365,6 +410,147 @@ export class OutputManager { } } + /** + * Track streamed tool content separately (content grows, not the full JSON text). + */ + private toolContentStreamed = new Map() + + /** + * Track which tool messages have already shown truncation marker. + */ + private toolContentTruncated = new Set() + + /** + * Maximum lines to show when streaming file content. + */ + private static readonly MAX_PREVIEW_LINES = 5 + + /** + * Output tool request (file create/edit/delete) with streaming content preview. + * Shows the file content being written (up to 20 lines), then final state when complete. + */ + private outputToolRequest( + ts: number, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + ): void { + // Parse tool info to get the tool name, path, and content for display + let toolName = "tool" + let toolPath = "" + let content = "" + try { + const toolInfo = JSON.parse(text) as Record + toolName = (toolInfo.tool as string) || "tool" + toolPath = (toolInfo.path as string) || "" + content = (toolInfo.content as string) || "" + } catch { + // Use default if not JSON + } + + if (isPartial && text) { + const previousContent = this.toolContentStreamed.get(ts) || "" + const previous = this.streamedContent.get(ts) + + if (!previous) { + // First partial - show header with path (if has valid extension) + // Check for valid extension: must have a dot followed by 1+ characters + const hasValidExtension = /\.[a-zA-Z0-9]+$/.test(toolPath) + const pathInfo = hasValidExtension ? ` ${toolPath}` : "" + this.writeRaw(`\n[${toolName}]${pathInfo}\n`) + this.streamedContent.set(ts, { ts, text, headerShown: true }) + this.currentlyStreamingTs = ts + this.streamingState.next({ ts, isStreaming: true }) + } + + // Stream content delta (new content since last update) + if (content.length > previousContent.length && content.startsWith(previousContent)) { + const delta = content.slice(previousContent.length) + // Check if we're still within the preview limit + const previousLineCount = previousContent === "" ? 0 : previousContent.split("\n").length + const currentLineCount = content === "" ? 0 : content.split("\n").length + const previouslyTruncated = this.toolContentTruncated.has(ts) + + if (!previouslyTruncated) { + if (currentLineCount <= OutputManager.MAX_PREVIEW_LINES) { + // Still under limit - output the delta + this.writeRaw(delta) + } else if (previousLineCount < OutputManager.MAX_PREVIEW_LINES) { + // Just crossed the limit - output remaining lines up to limit, mark as truncated + // (truncation message will be shown at completion with final count) + const linesToShow = OutputManager.MAX_PREVIEW_LINES - previousLineCount + const deltaLines = delta.split("\n") + const truncatedDelta = deltaLines.slice(0, linesToShow).join("\n") + if (truncatedDelta) { + this.writeRaw(truncatedDelta) + } + this.toolContentTruncated.add(ts) + } else { + // Already at/past limit but not yet marked - just mark as truncated + this.toolContentTruncated.add(ts) + } + } + // If already truncated, don't output more content + this.toolContentStreamed.set(ts, content) + } + + this.displayedMessages.set(ts, { ts, text, partial: true }) + } else if (!isPartial && !alreadyDisplayedComplete) { + // Tool request complete - check if we need to show truncation message + const previousContent = this.toolContentStreamed.get(ts) || "" + const currentLineCount = content === "" ? 0 : content.split("\n").length + + // Show truncation message if content exceeds preview limit + // (We only mark as truncated during partials, the actual message is shown here with final count) + if (currentLineCount > OutputManager.MAX_PREVIEW_LINES && previousContent) { + const remainingLines = currentLineCount - OutputManager.MAX_PREVIEW_LINES + this.writeRaw(`\n... (${remainingLines} more lines)\n`) + } + + // Show final stats + const pathInfo = toolPath ? ` ${toolPath}` : "" + const charCount = content.length + this.writeRaw(`[${toolName}]${pathInfo} complete (${currentLineCount} lines, ${charCount} chars)\n`) + this.currentlyStreamingTs = null + this.streamingState.next({ ts: null, isStreaming: false }) + this.displayedMessages.set(ts, { ts, text, partial: false }) + // Clean up tool content tracking + this.toolContentStreamed.delete(ts) + this.toolContentTruncated.delete(ts) + } + } + + /** + * Output command request with streaming support. + * Streams partial content as it arrives from the LLM. + */ + private outputCommandRequest( + ts: number, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + ): void { + if (isPartial && text) { + this.streamContent(ts, text, "[command]") + this.displayedMessages.set(ts, { ts, text, partial: true }) + } else if (!isPartial && !alreadyDisplayedComplete) { + // Command request complete - finish the stream + // Note: AskDispatcher will handle the actual prompt/approval + const streamed = this.streamedContent.get(ts) + + if (streamed) { + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeRaw(delta) + } + this.finishStream(ts) + } + // Don't output non-streamed content here - AskDispatcher handles complete command requests + + this.displayedMessages.set(ts, { ts, text, partial: false }) + } + } + // =========================================================================== // Streaming Helpers // =========================================================================== @@ -411,4 +597,38 @@ export class OutputManager { this.displayedMessages.set(ts, { ts, text: text || "", partial: false }) } } + + // =========================================================================== + // Terminal Output Streaming (commandExecutionStatus) + // =========================================================================== + + /** + * Output streaming terminal output from commandExecutionStatus messages. + * This provides live terminal output during command execution, before + * the final command_output message is created. + * + * @param executionId - Unique execution ID for this command + * @param output - The accumulated terminal output so far + */ + outputStreamingTerminalOutput(executionId: string, output: string): void { + if (this.disabled) return + + // Mark that we've streamed terminal output (to skip command_output later) + this.hasStreamedTerminalOutput = true + + const previousOutput = this.terminalOutputByExecutionId.get(executionId) + + if (!previousOutput) { + // First time seeing this execution - output header and initial content + this.writeRaw("\n[terminal] ") + this.writeRaw(output) + this.terminalOutputByExecutionId.set(executionId, output) + } else if (output.length > previousOutput.length && output.startsWith(previousOutput)) { + // Output has grown - write only the delta + const delta = output.slice(previousOutput.length) + this.writeRaw(delta) + this.terminalOutputByExecutionId.set(executionId, output) + } + // If output hasn't grown or doesn't start with previous, ignore (likely reset) + } } diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index fdb8644f53b..927c6a61c8c 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -18,12 +18,13 @@ import { useTerminalSize, useToast, useExtensionHost, - useMessageHandlers, useTaskSubmit, useGlobalInput, useFollowupCountdown, useFocusManagement, usePickerHandlers, + useClientEvents, + useExtensionState, } from "./hooks/index.js" // Import extracted utilities. @@ -159,16 +160,14 @@ function AppInner({ // Toast notifications for ephemeral messages (e.g., mode changes). const { currentToast, showInfo } = useToast() - const { - handleExtensionMessage, - seenMessageIds, - pendingCommandRef: _pendingCommandRef, - firstTextMessageSkipped, - } = useMessageHandlers({ - nonInteractive, - }) + // Handle non-message extension state (modes, file search, commands, task history) + const { handleExtensionState } = useExtensionState() + + // Track seen message IDs and first text message skip for task submission + const seenMessageIds = useRef>(new Set()) + const firstTextMessageSkipped = useRef(false) - const { sendToExtension, runTask, cleanup } = useExtensionHost({ + const { client, sendToExtension, runTask, cleanup } = useExtensionHost({ initialPrompt, mode, reasoningEffort, @@ -182,10 +181,27 @@ function AppInner({ nonInteractive, ephemeral, exitOnComplete, - onExtensionMessage: handleExtensionMessage, + onExtensionState: handleExtensionState, createExtensionHost, }) + // Subscribe to ExtensionClient events for unified message handling + const { reset: resetClientEvents } = useClientEvents({ + client, + nonInteractive, + }) + + // Reset tracking state when task is cleared + useEffect(() => { + if (!client) return + const unsubscribe = client.on("taskCleared" as "stateChange", () => { + seenMessageIds.current.clear() + firstTextMessageSkipped.current = false + resetClientEvents() + }) + return unsubscribe + }, [client, resetClientEvents]) + // Initialize task submit hook const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({ sendToExtension, diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index c51b0faddbc..622ba5003f1 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -1,5 +1,6 @@ -import { memo } from "react" +// memo temporarily removed for debugging import { Box, Newline, Text } from "ink" +import { DebugLogger } from "@roo-code/core/cli" import type { TUIMessage } from "../types.js" import * as theme from "../theme.js" @@ -7,6 +8,8 @@ import * as theme from "../theme.js" import TodoDisplay from "./TodoDisplay.js" import { getToolRenderer } from "./tools/index.js" +const renderLogger = new DebugLogger("RENDER") + /** * Tool categories for styling */ @@ -215,6 +218,27 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { ) case "tool": { + // Parse rawContent to get content for logging + let parsedContent = "" + try { + const parsed = JSON.parse(content) as Record + parsedContent = ((parsed.content as string) || "").substring(0, 50) + } catch { + // Not JSON + } + + renderLogger.debug("ChatHistoryItem:tool", { + id: message.id, + toolName: message.toolName, + hasToolData: !!message.toolData, + toolDataTool: message.toolData?.tool, + toolDataPath: message.toolData?.path, + toolDataContent: message.toolData?.content?.substring(0, 50), + rawContentLen: content.length, + parsedContent, + partial: message.partial, + }) + // Special rendering for update_todo_list tool - show full TODO list if ( (message.toolName === "update_todo_list" || message.toolName === "updateTodoList") && @@ -249,4 +273,5 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { } } -export default memo(ChatHistoryItem) +// Temporarily disable memo to debug streaming rendering issues +export default ChatHistoryItem diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx index fe6b25ceb10..a543c0c13db 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx @@ -41,7 +41,6 @@ export function createHelpTrigger(): AutocompleteTrigger { id: "help", triggerChar: "?", position: "line-start", - consumeTrigger: true, detectTrigger: (lineText: string): TriggerDetectionResult | null => { // Check if line starts with ? (after optional whitespace) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx index 4080180d493..607ed3d38c5 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx @@ -157,13 +157,5 @@ describe("HelpTrigger", () => { expect(trigger.emptyMessage).toBe("No matching shortcuts") expect(trigger.debounceMs).toBe(0) }) - - it("should have consumeTrigger set to true", () => { - const trigger = createHelpTrigger() - - // The ? character should be consumed (not inserted into input) - // when the help menu is triggered - expect(trigger.consumeTrigger).toBe(true) - }) }) }) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx index 8e5906ac7cd..b7b9c388609 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx @@ -210,13 +210,6 @@ describe("HistoryTrigger", () => { expect(trigger.debounceMs).toBe(100) }) - it("should not have consumeTrigger set (# character appears in input)", () => { - const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) - - // The # character should remain in the input like other triggers - expect(trigger.consumeTrigger).toBeUndefined() - }) - it("should call getHistory when searching", () => { const getHistoryMock = vi.fn(() => mockHistoryItems) const trigger = createHistoryTrigger({ getHistory: getHistoryMock }) diff --git a/apps/cli/src/ui/components/autocomplete/types.ts b/apps/cli/src/ui/components/autocomplete/types.ts index bd51803fe73..33c431b479c 100644 --- a/apps/cli/src/ui/components/autocomplete/types.ts +++ b/apps/cli/src/ui/components/autocomplete/types.ts @@ -97,14 +97,6 @@ export interface AutocompleteTrigger( return lines[lines.length - 1] || "" }, []) - /** - * Get the input value with the trigger character removed. - * Used when a trigger has consumeTrigger: true. - */ - const getConsumedValue = useCallback((value: string, lastLine: string, triggerIndex: number): string => { - const lines = value.split("\n") - const lastLineIndex = lines.length - 1 - // Remove the trigger character from the last line - const newLastLine = lastLine.slice(0, triggerIndex) + lastLine.slice(triggerIndex + 1) - lines[lastLineIndex] = newLastLine - return lines.join("\n") - }, []) - /** * Handle input value changes - detects triggers and initiates search. * Returns an object indicating if the input should be modified (for consumeTrigger). @@ -120,10 +107,6 @@ export function useAutocompletePicker( if (query === lastQuery && state.isOpen && state.activeTrigger?.id === foundTrigger.id) { // Same query, same trigger - no need to search again - // Still return consumed value if trigger consumes input - if (foundTrigger.consumeTrigger) { - return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) } - } return {} } @@ -207,14 +190,9 @@ export function useAutocompletePicker( debounceTimersRef.current.set(foundTrigger.id, timer) - // Return consumed value if trigger consumes input - if (foundTrigger.consumeTrigger) { - return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) } - } - return {} }, - [triggers, state.isOpen, state.activeTrigger?.id, getLastLine, getConsumedValue], + [triggers, state.isOpen, state.activeTrigger?.id, getLastLine], ) /** diff --git a/apps/cli/src/ui/components/tools/FileWriteTool.tsx b/apps/cli/src/ui/components/tools/FileWriteTool.tsx index 0523f2f696a..abfa32eb836 100644 --- a/apps/cli/src/ui/components/tools/FileWriteTool.tsx +++ b/apps/cli/src/ui/components/tools/FileWriteTool.tsx @@ -4,11 +4,11 @@ import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" -import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js" +import { sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js" -const MAX_DIFF_LINES = 15 +const MAX_PREVIEW_LINES = 5 -export function FileWriteTool({ toolData }: ToolRendererProps) { +export function FileWriteTool({ toolData, rawContent }: ToolRendererProps) { const iconName = getToolIconName(toolData.tool) const displayName = getToolDisplayName(toolData.tool) const path = toolData.path || "" @@ -18,6 +18,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) { const isOutsideWorkspace = toolData.isOutsideWorkspace const isNewFile = toolData.tool === "newFileCreated" || toolData.tool === "write_to_file" + // For streaming: rawContent is updated with each message, so parse it for live content + // toolData.content may be stale during streaming due to debounce optimization + let liveContent = toolData.content || "" + if (rawContent && isNewFile) { + try { + const parsed = JSON.parse(rawContent) as Record + if (parsed.content && typeof parsed.content === "string") { + liveContent = parsed.content + } + } catch { + // Use toolData.content if rawContent isn't valid JSON + } + } + // Handle batch diff operations if (toolData.batchDiffs && toolData.batchDiffs.length > 0) { return ( @@ -57,9 +71,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) { } // Single file write - const { text: previewDiff, truncated, hiddenLines } = truncateText(diff, MAX_DIFF_LINES) + // For new files, display streaming content; for edits, show diff const diffHunks = diff ? parseDiff(diff) : [] + // Process content for display - split into lines and truncate + const sanitizedContent = isNewFile && liveContent ? sanitizeContent(liveContent) : "" + const contentLines = sanitizedContent ? sanitizedContent.split("\n") : [] + const displayLines = contentLines.slice(0, MAX_PREVIEW_LINES) + const truncatedLineCount = contentLines.length - MAX_PREVIEW_LINES + const isContentTruncated = truncatedLineCount > 0 + + // Stats for the header + const totalLines = contentLines.length + const totalChars = liveContent.length + return ( {/* Header row with path on same line */} @@ -76,15 +101,15 @@ export function FileWriteTool({ toolData }: ToolRendererProps) { )} - {isNewFile && ( + {isNewFile && !diffStats && ( {" "} NEW )} - {/* Diff stats badge */} - {diffStats && ( + {/* Stats - show line/char count for streaming, or diff stats when complete */} + {diffStats ? ( <> @@ -95,6 +120,14 @@ export function FileWriteTool({ toolData }: ToolRendererProps) { -{diffStats.removed} + ) : ( + isNewFile && + totalChars > 0 && ( + + {" "} + ({totalLines} lines, {totalChars} chars) + + ) )} {/* Warning badges */} @@ -107,7 +140,23 @@ export function FileWriteTool({ toolData }: ToolRendererProps) { )} - {/* Diff preview */} + {/* Streaming content preview for new files (before diff is available) */} + {isNewFile && !diff && displayLines.length > 0 && ( + + {displayLines.map((line, index) => ( + + {line} + + ))} + {isContentTruncated && ( + + ... ({truncatedLineCount} more lines) + + )} + + )} + + {/* Diff preview for edits */} {diffHunks.length > 0 && ( {diffHunks.slice(0, 2).map((hunk, hunkIndex) => ( @@ -149,13 +198,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) { )} - {/* Fallback to raw diff if no hunks parsed */} - {diffHunks.length === 0 && previewDiff && ( + {/* Fallback: show raw diff content if no hunks parsed and not streaming new file */} + {!isNewFile && diffHunks.length === 0 && diff && ( - {previewDiff} - {truncated && ( + {diff + .split("\n") + .slice(0, MAX_PREVIEW_LINES) + .map((line, index) => ( + + {line} + + ))} + {diff.split("\n").length > MAX_PREVIEW_LINES && ( - ... ({hiddenLines} more lines) + ... ({diff.split("\n").length - MAX_PREVIEW_LINES} more lines) )} diff --git a/apps/cli/src/ui/hooks/index.ts b/apps/cli/src/ui/hooks/index.ts index 9e12cd9b0e7..a46803f06c5 100644 --- a/apps/cli/src/ui/hooks/index.ts +++ b/apps/cli/src/ui/hooks/index.ts @@ -6,17 +6,19 @@ export { useInputHistory } from "./useInputHistory.js" // Export new extracted hooks export { useFollowupCountdown } from "./useFollowupCountdown.js" export { useFocusManagement } from "./useFocusManagement.js" -export { useMessageHandlers } from "./useMessageHandlers.js" export { useExtensionHost } from "./useExtensionHost.js" export { useTaskSubmit } from "./useTaskSubmit.js" export { useGlobalInput } from "./useGlobalInput.js" export { usePickerHandlers } from "./usePickerHandlers.js" +export { useClientEvents } from "./useClientEvents.js" +export { useExtensionState } from "./useExtensionState.js" // Export types export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js" export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js" -export type { UseMessageHandlersOptions, UseMessageHandlersReturn } from "./useMessageHandlers.js" export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js" export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js" export type { UseGlobalInputOptions } from "./useGlobalInput.js" export type { UsePickerHandlersOptions, UsePickerHandlersReturn } from "./usePickerHandlers.js" +export type { UseClientEventsOptions, UseClientEventsReturn } from "./useClientEvents.js" +export type { UseExtensionStateReturn } from "./useExtensionState.js" diff --git a/apps/cli/src/ui/hooks/useClientEvents.ts b/apps/cli/src/ui/hooks/useClientEvents.ts new file mode 100644 index 00000000000..30758858bec --- /dev/null +++ b/apps/cli/src/ui/hooks/useClientEvents.ts @@ -0,0 +1,508 @@ +/** + * useClientEvents - Bridge ExtensionClient events to TUI state + * + * This hook subscribes to ExtensionClient events (the same events used by + * non-TUI mode) and transforms them into TUI messages/state updates. + * + * This unifies the message handling logic between TUI and non-TUI modes: + * - Non-TUI: ExtensionClient events → OutputManager/AskDispatcher + * - TUI: ExtensionClient events → useClientEvents → Zustand store + */ + +import { useEffect, useRef, useCallback } from "react" +import type { ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types" +import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands, DebugLogger } from "@roo-code/core/cli" + +// Debug logger using same pattern as extension-host.ts +const tuiLogger = new DebugLogger("TUI") + +import type { ExtensionClient } from "@/agent/index.js" +import type { WaitingForInputEvent, CommandExecutionOutputEvent } from "@/agent/events.js" + +import type { TUIMessage, ToolData, PendingAsk } from "../types.js" +import { useCLIStore } from "../store.js" +import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js" + +export interface UseClientEventsOptions { + client: ExtensionClient | null + nonInteractive: boolean +} + +export interface UseClientEventsReturn { + /** Reset tracking state (call when starting new task) */ + reset: () => void +} + +/** + * Hook that subscribes to ExtensionClient events and updates TUI state. + * + * Key events: + * - `message`: New ClineMessage → transform to TUIMessage and add to store + * - `messageUpdated`: Updated ClineMessage → update existing TUIMessage + * - `waitingForInput`: Ask needing input → set pendingAsk + */ +export function useClientEvents({ client, nonInteractive }: UseClientEventsOptions): UseClientEventsReturn { + const { addMessage, setPendingAsk, setLoading, setTokenUsage, currentTodos, setTodos } = useCLIStore() + + // Track seen message timestamps to filter duplicates + const seenMessageIds = useRef>(new Set()) + const firstTextMessageSkipped = useRef(false) + + // Track pending command for injecting into command_output toolData + const pendingCommandRef = useRef(null) + + // Track the message ID of the current command being executed (for streaming updates) + const currentCommandMessageIdRef = useRef(null) + + // Track if we've streamed command output (to skip duplicate command_output say message) + const hasStreamedCommandOutputRef = useRef(false) + + // Track the message ID of partial tool asks (for streaming file write updates) + const partialToolMessageIdRef = useRef(null) + + /** + * Reset tracking state (call when starting new task) + */ + const reset = useCallback(() => { + seenMessageIds.current.clear() + firstTextMessageSkipped.current = false + pendingCommandRef.current = null + currentCommandMessageIdRef.current = null + hasStreamedCommandOutputRef.current = false + partialToolMessageIdRef.current = null + }, []) + + /** + * Transform a ClineMessage to TUIMessage and add to store + */ + const processClineMessage = useCallback( + (msg: ClineMessage) => { + const ts = msg.ts + const messageId = ts.toString() + const text = msg.text || "" + const partial = msg.partial || false + const isResuming = useCLIStore.getState().isResumingTask + + // DEBUG: Log all ask messages to trace partial handling + if (msg.type === "ask") { + tuiLogger.debug("ask:received", { + ask: msg.ask, + partial, + textLen: text.length, + id: messageId, + }) + } + + if (msg.type === "say" && msg.say) { + processSayMessage(messageId, msg.say, text, partial, isResuming) + } else if (msg.type === "ask" && msg.ask) { + processAskMessage(messageId, msg.ask, text, partial) + } + }, + [nonInteractive, currentTodos], + ) + + /** + * Process "say" type messages + */ + const processSayMessage = useCallback( + (messageId: string, say: ClineSay, text: string, partial: boolean, isResuming: boolean) => { + // Skip certain message types + if (say === "checkpoint_saved" || say === "api_req_started" || say === "user_feedback") { + seenMessageIds.current.add(messageId) + return + } + + // Skip first text message for new tasks (it's the user's prompt echo) + if (say === "text" && !firstTextMessageSkipped.current && !isResuming) { + firstTextMessageSkipped.current = true + seenMessageIds.current.add(messageId) + return + } + + // Skip if already seen (non-partial) + if (seenMessageIds.current.has(messageId) && !partial) { + return + } + + let role: TUIMessage["role"] = "assistant" + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let toolData: ToolData | undefined + + if (say === "command_output") { + // Skip command_output say message if we've already streamed the output + // The streaming updates went to the command ask message directly + if (hasStreamedCommandOutputRef.current) { + seenMessageIds.current.add(messageId) + // Reset for next command + hasStreamedCommandOutputRef.current = false + currentCommandMessageIdRef.current = null + return + } + + // Non-streamed case: add the command output message + role = "tool" + toolName = "execute_command" + toolDisplayName = "bash" + toolDisplayOutput = text + const trackedCommand = pendingCommandRef.current + toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text } + pendingCommandRef.current = null + } else if (say === "reasoning") { + role = "thinking" + } + + seenMessageIds.current.add(messageId) + + addMessage({ + id: messageId, + role, + content: text || "", + toolName, + toolDisplayName, + toolDisplayOutput, + partial, + originalType: say, + toolData, + }) + }, + [addMessage], + ) + + /** + * Process "ask" type messages + */ + const processAskMessage = useCallback( + (messageId: string, ask: ClineAsk, text: string, partial: boolean) => { + // DEBUG: Log entry to processAskMessage + tuiLogger.debug("ask:process", { + ask, + partial, + nonInteractive, + id: messageId, + }) + + // Handle partial tool asks in nonInteractive mode - stream file content as it arrives + // This allows FileWriteTool to show immediately and update as content streams + if (partial && ask === "tool" && nonInteractive) { + // Parse tool info to extract streaming content + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let toolData: ToolData | undefined + let parseError = false + + try { + const toolInfo = JSON.parse(text) as Record + toolName = toolInfo.tool as string + toolDisplayName = toolInfo.tool as string + toolDisplayOutput = formatToolOutput(toolInfo) + toolData = extractToolData(toolInfo) + } catch { + // Use raw text if not valid JSON - may happen during early streaming + parseError = true + } + + tuiLogger.debug("ask:partial-tool", { + id: messageId, + textLen: text.length, + toolName: toolName || "none", + hasToolData: !!toolData, + parseError, + }) + + // Track that we're streaming this tool ask + partialToolMessageIdRef.current = messageId + + // Add/update the message with partial content + // Use raw JSON text as content so FileWriteTool can parse live content during streaming + addMessage({ + id: messageId, + role: "tool", + content: text, // Raw JSON text - needed for streaming content parsing + toolName, + toolDisplayName, + toolDisplayOutput, + partial: true, // Mark as partial for UI to show loading state + originalType: ask, + toolData, + }) + return + } + + // Skip other partial ask messages - wait for complete + if (partial) { + return + } + + // Skip if already processed (but allow updates to partial tool messages) + if (seenMessageIds.current.has(messageId) && partialToolMessageIdRef.current !== messageId) { + return + } + + // Skip command_output asks (non-blocking) + if (ask === "command_output") { + seenMessageIds.current.add(messageId) + return + } + + // Handle resume tasks - don't set pendingAsk + if (ask === "resume_task" || ask === "resume_completed_task") { + seenMessageIds.current.add(messageId) + setLoading(false) + useCLIStore.getState().setHasStartedTask(true) + useCLIStore.getState().setIsResumingTask(false) + return + } + + // Track pending command + if (ask === "command") { + pendingCommandRef.current = text + } + + // Handle completion result + if (ask === "completion_result") { + seenMessageIds.current.add(messageId) + // Completion is handled by taskCompleted event + // Just add the message for display + try { + const completionInfo = JSON.parse(text) as Record + const toolData: ToolData = { + tool: "attempt_completion", + result: completionInfo.result as string | undefined, + content: completionInfo.result as string | undefined, + } + + addMessage({ + id: messageId, + role: "tool", + content: text, + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }), + originalType: ask, + toolData, + }) + } catch { + addMessage({ + id: messageId, + role: "tool", + content: text || "Task completed", + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: "✅ Task completed", + originalType: ask, + toolData: { tool: "attempt_completion", content: text }, + }) + } + return + } + + // For tool/command asks in nonInteractive mode, add as message (auto-approved) + if (nonInteractive && ask !== "followup") { + seenMessageIds.current.add(messageId) + + if (ask === "tool") { + // Clear partial tracking - this is the final message + const wasPartial = partialToolMessageIdRef.current === messageId + partialToolMessageIdRef.current = null + + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let toolData: ToolData | undefined + let todos: TodoItem[] | undefined + let previousTodos: TodoItem[] | undefined + + try { + const toolInfo = JSON.parse(text) as Record + toolName = toolInfo.tool as string + toolDisplayName = toolInfo.tool as string + toolDisplayOutput = formatToolOutput(toolInfo) + toolData = extractToolData(toolInfo) + + // Handle todo list updates + if (toolName === "update_todo_list" || toolName === "updateTodoList") { + const parsedTodos = parseTodosFromToolInfo(toolInfo) + if (parsedTodos && parsedTodos.length > 0) { + todos = parsedTodos + previousTodos = [...currentTodos] + setTodos(parsedTodos) + } + } + } catch { + // Use raw text if not valid JSON + } + + addMessage({ + id: messageId, + role: "tool", + content: text, // Raw JSON text - needed for tool renderers to parse live content + toolName, + toolDisplayName, + toolDisplayOutput, + partial: false, // Final message - not partial + originalType: ask, + toolData, + todos, + previousTodos, + }) + + // If we were streaming, the update already happened via addMessage + if (wasPartial) { + return + } + } else if (ask === "command") { + // For command asks, add as tool message with command but no output yet + // Store the message ID so streaming can update it + currentCommandMessageIdRef.current = messageId + pendingCommandRef.current = text + addMessage({ + id: messageId, + role: "tool", + content: "", + toolName: "execute_command", + toolDisplayName: "bash", + originalType: ask, + toolData: { tool: "execute_command", command: text }, + }) + } else { + // Other asks - add as assistant message + addMessage({ + id: messageId, + role: "assistant", + content: text || "", + originalType: ask, + }) + } + return + } + + // Interactive mode - set pending ask for user input + seenMessageIds.current.add(messageId) + + let suggestions: Array<{ answer: string; mode?: string | null }> | undefined + let questionText = text + + if (ask === "followup") { + try { + const data = JSON.parse(text) + questionText = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : undefined + } catch { + // Use raw text + } + } else if (ask === "tool") { + try { + const toolInfo = JSON.parse(text) as Record + questionText = formatToolAskMessage(toolInfo) + } catch { + // Use raw text + } + } + + const pendingAsk: PendingAsk = { + id: messageId, + type: ask, + content: questionText, + suggestions, + } + setPendingAsk(pendingAsk) + }, + [addMessage, setPendingAsk, setLoading, nonInteractive, currentTodos, setTodos], + ) + + /** + * Handle waitingForInput event from ExtensionClient + * This is emitted when an ask message needs user input + */ + const handleWaitingForInput = useCallback( + (event: WaitingForInputEvent) => { + const msg = event.message + if (msg.type === "ask" && msg.ask) { + processAskMessage(msg.ts.toString(), msg.ask, msg.text || "", false) + } + }, + [processAskMessage], + ) + + // Subscribe to client events + useEffect(() => { + tuiLogger.debug("useEffect:client", { hasClient: !!client }) + if (!client) return + tuiLogger.debug("useEffect:subscribing", { clientId: "ExtensionClient" }) + + // Subscribe to message events + const unsubMessage = client.on("message", processClineMessage) + const unsubUpdated = client.on("messageUpdated", processClineMessage) + const unsubWaiting = client.on("waitingForInput", handleWaitingForInput) + + // Handle streaming terminal output during command execution. + // This updates the existing command message with live output. + const unsubCommandOutput = client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => { + // Mark that we've streamed output (to skip the final command_output say message) + hasStreamedCommandOutputRef.current = true + + // If we have a command message ID, update that message's output by re-adding with same ID + const msgId = currentCommandMessageIdRef.current + if (msgId) { + // Re-add the message with the same ID to update it (addMessage handles updates) + addMessage({ + id: msgId, + role: "tool", + content: event.output, + toolName: "execute_command", + toolDisplayName: "bash", + toolDisplayOutput: event.output, // This is what CommandTool displays + partial: false, // Non-partial to bypass debounce + originalType: "command", + toolData: { + tool: "execute_command", + command: pendingCommandRef.current || undefined, + output: event.output, + }, + }) + } else { + // Fallback: create a new message if we don't have a command message ID + const streamingMsgId = `streaming-cmd-${event.executionId}` + addMessage({ + id: streamingMsgId, + role: "tool", + content: event.output, + toolName: "execute_command", + toolDisplayName: "bash", + toolDisplayOutput: event.output, + partial: false, + originalType: "command_output", + toolData: { + tool: "execute_command", + command: pendingCommandRef.current || undefined, + output: event.output, + }, + }) + } + }) + + // Update token usage when messages change + const unsubStateChange = client.on("stateChange", () => { + const messages = client.getMessages() + if (messages.length > 1) { + const processed = consolidateApiRequests(consolidateCommands(messages.slice(1))) + const metrics = consolidateTokenUsage(processed) + setTokenUsage(metrics) + } + }) + + return () => { + unsubMessage() + unsubUpdated() + unsubWaiting() + unsubCommandOutput() + unsubStateChange() + } + }, [client, processClineMessage, handleWaitingForInput, setTokenUsage]) + + return { reset } +} diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index 91bdac2bf01..7f0451c7310 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -1,21 +1,27 @@ -import { useEffect, useRef, useCallback, useMemo } from "react" +import { useEffect, useRef, useState, useCallback, useMemo } from "react" import { useApp } from "ink" import { randomUUID } from "crypto" import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" -import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js" +import { ExtensionClient, ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js" import { useCLIStore } from "../store.js" export interface UseExtensionHostOptions extends ExtensionHostOptions { initialPrompt?: string exitOnComplete?: boolean - onExtensionMessage: (msg: ExtensionMessage) => void + /** + * Handle non-message extension state (modes, file search, commands, etc.) + * ClineMessage processing should use useClientEvents instead. + */ + onExtensionState?: (msg: ExtensionMessage) => void createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface } export interface UseExtensionHostReturn { isReady: boolean + /** ExtensionClient for subscribing to message events */ + client: ExtensionClient | null sendToExtension: ((msg: WebviewMessage) => void) | null runTask: ((prompt: string) => Promise) | null cleanup: () => Promise @@ -43,19 +49,23 @@ export function useExtensionHost({ nonInteractive, ephemeral, exitOnComplete, - onExtensionMessage, + onExtensionState, createExtensionHost, }: UseExtensionHostOptions): UseExtensionHostReturn { const { exit } = useApp() const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore() const hostRef = useRef(null) + // Use state for client so that consumers re-render when it becomes available. + // This is critical for useClientEvents which needs the client to subscribe to events. + const [client, setClient] = useState(null) const isReadyRef = useRef(false) const cleanup = useCallback(async () => { if (hostRef.current) { await hostRef.current.dispose() hostRef.current = null + setClient(null) isReadyRef.current = false } }, []) @@ -78,9 +88,15 @@ export function useExtensionHost({ }) hostRef.current = host + // Setting client via state triggers re-render so useClientEvents + // receives the valid client and can subscribe to events. + setClient(host.client) isReadyRef.current = true - host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage)) + // Handle non-message state updates (modes, file search, commands, task history) + if (onExtensionState) { + host.on("extensionWebviewMessage", (msg) => onExtensionState(msg as ExtensionMessage)) + } host.client.on("taskCompleted", async () => { setComplete(true) @@ -142,9 +158,9 @@ export function useExtensionHost({ return hostRef.current.runTask(prompt) }, []) - // Memoized return object to prevent unnecessary re-renders in consumers. + // Return object includes client state directly so consumers re-render when client changes. return useMemo( - () => ({ isReady: isReadyRef.current, sendToExtension, runTask, cleanup }), - [sendToExtension, runTask, cleanup], + () => ({ isReady: isReadyRef.current, client, sendToExtension, runTask, cleanup }), + [client, sendToExtension, runTask, cleanup], ) } diff --git a/apps/cli/src/ui/hooks/useExtensionState.ts b/apps/cli/src/ui/hooks/useExtensionState.ts new file mode 100644 index 00000000000..b1a4ec82b3a --- /dev/null +++ b/apps/cli/src/ui/hooks/useExtensionState.ts @@ -0,0 +1,83 @@ +/** + * useExtensionState - Handle non-message extension state updates + * + * This hook handles extension state that is NOT part of ClineMessage processing: + * - Mode changes (current mode, available modes) + * - File search results + * - Slash commands list + * - Task history + * - Router models + * + * ClineMessage processing is handled by useClientEvents, which subscribes to + * ExtensionClient events (the unified approach for both TUI and non-TUI modes). + */ + +import { useCallback } from "react" +import type { ExtensionMessage } from "@roo-code/types" + +import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js" +import { useCLIStore } from "../store.js" + +export interface UseExtensionStateReturn { + handleExtensionState: (msg: ExtensionMessage) => void +} + +/** + * Hook to handle non-message extension state updates. + * This is used alongside useClientEvents which handles ClineMessage events. + */ +export function useExtensionState(): UseExtensionStateReturn { + const { + setFileSearchResults, + setAllSlashCommands, + setAvailableModes, + setCurrentMode, + setTaskHistory, + setRouterModels, + } = useCLIStore() + + /** + * Handle extension messages that contain state updates. + * Only processes non-ClineMessage state. + */ + const handleExtensionState = useCallback( + (msg: ExtensionMessage) => { + if (msg.type === "state") { + const state = msg.state + + if (!state) { + return + } + + // Extract and update current mode from state + const newMode = state.mode + + if (newMode) { + setCurrentMode(newMode) + } + + // Extract and update task history from state + const newTaskHistory = state.taskHistory + + if (newTaskHistory && Array.isArray(newTaskHistory)) { + setTaskHistory(newTaskHistory) + } + + // Note: ClineMessages are handled by useClientEvents via ExtensionClient events + } else if (msg.type === "fileSearchResults") { + setFileSearchResults((msg.results as FileResult[]) || []) + } else if (msg.type === "commands") { + setAllSlashCommands((msg.commands as SlashCommandResult[]) || []) + } else if (msg.type === "modes") { + setAvailableModes((msg.modes as ModeResult[]) || []) + } else if (msg.type === "routerModels") { + if (msg.routerModels) { + setRouterModels(msg.routerModels) + } + } + }, + [setFileSearchResults, setAllSlashCommands, setAvailableModes, setCurrentMode, setTaskHistory, setRouterModels], + ) + + return { handleExtensionState } +} diff --git a/apps/cli/src/ui/hooks/useMessageHandlers.ts b/apps/cli/src/ui/hooks/useMessageHandlers.ts deleted file mode 100644 index 68695e39c74..00000000000 --- a/apps/cli/src/ui/hooks/useMessageHandlers.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { useCallback, useRef } from "react" -import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types" -import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/cli" - -import type { TUIMessage, ToolData } from "../types.js" -import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js" -import { useCLIStore } from "../store.js" -import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js" - -export interface UseMessageHandlersOptions { - nonInteractive: boolean -} - -export interface UseMessageHandlersReturn { - handleExtensionMessage: (msg: ExtensionMessage) => void - seenMessageIds: React.MutableRefObject> - pendingCommandRef: React.MutableRefObject - firstTextMessageSkipped: React.MutableRefObject -} - -/** - * Hook to handle messages from the extension. - * - * Processes three types of messages: - * 1. "say" messages - Information from the agent (text, tool output, reasoning) - * 2. "ask" messages - Requests for user input (approvals, followup questions) - * 3. Extension state updates - Mode changes, task history, file search results - * - * Transforms ClineMessage format to TUIMessage format and updates the store. - */ -export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn { - const { - addMessage, - setPendingAsk, - setComplete, - setLoading, - setHasStartedTask, - setFileSearchResults, - setAllSlashCommands, - setAvailableModes, - setCurrentMode, - setTokenUsage, - setRouterModels, - setTaskHistory, - currentTodos, - setTodos, - } = useCLIStore() - - // Track seen message timestamps to filter duplicates and the prompt echo - const seenMessageIds = useRef>(new Set()) - const firstTextMessageSkipped = useRef(false) - - // Track pending command for injecting into command_output toolData - const pendingCommandRef = useRef(null) - - /** - * Map extension "say" messages to TUI messages - */ - const handleSayMessage = useCallback( - (ts: number, say: ClineSay, text: string, partial: boolean) => { - const messageId = ts.toString() - const isResuming = useCLIStore.getState().isResumingTask - - if (say === "checkpoint_saved") { - return - } - - if (say === "api_req_started") { - return - } - - if (say === "user_feedback") { - seenMessageIds.current.add(messageId) - return - } - - // Skip first text message ONLY for new tasks, not resumed tasks - // When resuming, we want to show all historical messages including the first one - if (say === "text" && !firstTextMessageSkipped.current && !isResuming) { - firstTextMessageSkipped.current = true - seenMessageIds.current.add(messageId) - return - } - - if (seenMessageIds.current.has(messageId) && !partial) { - return - } - - let role: TUIMessage["role"] = "assistant" - let toolName: string | undefined - let toolDisplayName: string | undefined - let toolDisplayOutput: string | undefined - let toolData: ToolData | undefined - - if (say === "command_output") { - role = "tool" - toolName = "execute_command" - toolDisplayName = "bash" - toolDisplayOutput = text - const trackedCommand = pendingCommandRef.current - toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text } - pendingCommandRef.current = null - } else if (say === "reasoning") { - role = "thinking" - } - - seenMessageIds.current.add(messageId) - - addMessage({ - id: messageId, - role, - content: text || "", - toolName, - toolDisplayName, - toolDisplayOutput, - partial, - originalType: say, - toolData, - }) - }, - [addMessage], - ) - - /** - * Handle extension "ask" messages - */ - const handleAskMessage = useCallback( - (ts: number, ask: ClineAsk, text: string, partial: boolean) => { - const messageId = ts.toString() - - if (partial) { - return - } - - if (seenMessageIds.current.has(messageId)) { - return - } - - if (ask === "command_output") { - seenMessageIds.current.add(messageId) - return - } - - // Handle resume_task and resume_completed_task - stop loading and show text input - // Do not set pendingAsk - just stop loading so user sees normal input to type new message - if (ask === "resume_task" || ask === "resume_completed_task") { - seenMessageIds.current.add(messageId) - setLoading(false) - // Mark that a task has been started so subsequent messages continue the task - // (instead of starting a brand new task via runTask) - setHasStartedTask(true) - // Clear the resuming flag since we're now ready for interaction - // Historical messages should already be displayed from state processing - useCLIStore.getState().setIsResumingTask(false) - // Do not set pendingAsk - let the normal text input appear - return - } - - if (ask === "completion_result") { - seenMessageIds.current.add(messageId) - setComplete(true) - setLoading(false) - - // Parse the completion result and add a message for CompletionTool to render - try { - const completionInfo = JSON.parse(text) as Record - const toolData: ToolData = { - tool: "attempt_completion", - result: completionInfo.result as string | undefined, - content: completionInfo.result as string | undefined, - } - - addMessage({ - id: messageId, - role: "tool", - content: text, - toolName: "attempt_completion", - toolDisplayName: "Task Complete", - toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }), - originalType: ask, - toolData, - }) - } catch { - // If parsing fails, still add a basic completion message - addMessage({ - id: messageId, - role: "tool", - content: text || "Task completed", - toolName: "attempt_completion", - toolDisplayName: "Task Complete", - toolDisplayOutput: "✅ Task completed", - originalType: ask, - toolData: { - tool: "attempt_completion", - content: text, - }, - }) - } - return - } - - // Track pending command BEFORE nonInteractive handling - // This ensures we capture the command text for later injection into command_output toolData - if (ask === "command") { - pendingCommandRef.current = text - } - - if (nonInteractive && ask !== "followup") { - seenMessageIds.current.add(messageId) - - if (ask === "tool") { - let toolName: string | undefined - let toolDisplayName: string | undefined - let toolDisplayOutput: string | undefined - let formattedContent = text || "" - let toolData: ToolData | undefined - let todos: TodoItem[] | undefined - let previousTodos: TodoItem[] | undefined - - try { - const toolInfo = JSON.parse(text) as Record - toolName = toolInfo.tool as string - toolDisplayName = toolInfo.tool as string - toolDisplayOutput = formatToolOutput(toolInfo) - formattedContent = formatToolAskMessage(toolInfo) - // Extract structured toolData for rich rendering - toolData = extractToolData(toolInfo) - - // Special handling for update_todo_list tool - extract todos - if (toolName === "update_todo_list" || toolName === "updateTodoList") { - const parsedTodos = parseTodosFromToolInfo(toolInfo) - if (parsedTodos && parsedTodos.length > 0) { - todos = parsedTodos - // Capture previous todos before updating global state - previousTodos = [...currentTodos] - setTodos(parsedTodos) - } - } - } catch { - // Use raw text if not valid JSON - } - - addMessage({ - id: messageId, - role: "tool", - content: formattedContent, - toolName, - toolDisplayName, - toolDisplayOutput, - originalType: ask, - toolData, - todos, - previousTodos, - }) - } else { - addMessage({ - id: messageId, - role: "assistant", - content: text || "", - originalType: ask, - }) - } - return - } - - let suggestions: Array<{ answer: string; mode?: string | null }> | undefined - let questionText = text - - if (ask === "followup") { - try { - const data = JSON.parse(text) - questionText = data.question || text - suggestions = Array.isArray(data.suggest) ? data.suggest : undefined - } catch { - // Use raw text - } - } else if (ask === "tool") { - try { - const toolInfo = JSON.parse(text) as Record - questionText = formatToolAskMessage(toolInfo) - } catch { - // Use raw text if not valid JSON - } - } - // Note: ask === "command" is handled above before the nonInteractive block - - seenMessageIds.current.add(messageId) - - setPendingAsk({ - id: messageId, - type: ask, - content: questionText, - suggestions, - }) - }, - [addMessage, setPendingAsk, setComplete, setLoading, setHasStartedTask, nonInteractive, currentTodos, setTodos], - ) - - /** - * Handle all extension messages - */ - const handleExtensionMessage = useCallback( - (msg: ExtensionMessage) => { - if (msg.type === "state") { - const state = msg.state - - if (!state) { - return - } - - // Extract and update current mode from state - const newMode = state.mode - - if (newMode) { - setCurrentMode(newMode) - } - - // Extract and update task history from state - const newTaskHistory = state.taskHistory - - if (newTaskHistory && Array.isArray(newTaskHistory)) { - setTaskHistory(newTaskHistory) - } - - const clineMessages = state.clineMessages - - if (clineMessages) { - for (const clineMsg of clineMessages) { - const ts = clineMsg.ts - const type = clineMsg.type - const say = clineMsg.say - const ask = clineMsg.ask - const text = clineMsg.text || "" - const partial = clineMsg.partial || false - - if (type === "say" && say) { - handleSayMessage(ts, say, text, partial) - } else if (type === "ask" && ask) { - handleAskMessage(ts, ask, text, partial) - } - } - - // Compute token usage metrics from clineMessages - // Skip first message (task prompt) as per webview UI pattern - if (clineMessages.length > 1) { - const processed = consolidateApiRequests( - consolidateCommands(clineMessages.slice(1) as ClineMessage[]), - ) - - const metrics = consolidateTokenUsage(processed) - setTokenUsage(metrics) - } - } - - // After processing state, clear the resuming flag if it was set - // This ensures the flag is cleared even if no resume_task ask message is received - if (useCLIStore.getState().isResumingTask) { - useCLIStore.getState().setIsResumingTask(false) - } - } else if (msg.type === "messageUpdated") { - const clineMessage = msg.clineMessage - - if (!clineMessage) { - return - } - - const ts = clineMessage.ts - const type = clineMessage.type - const say = clineMessage.say - const ask = clineMessage.ask - const text = clineMessage.text || "" - const partial = clineMessage.partial || false - - if (type === "say" && say) { - handleSayMessage(ts, say, text, partial) - } else if (type === "ask" && ask) { - handleAskMessage(ts, ask, text, partial) - } - } else if (msg.type === "fileSearchResults") { - setFileSearchResults((msg.results as FileResult[]) || []) - } else if (msg.type === "commands") { - setAllSlashCommands((msg.commands as SlashCommandResult[]) || []) - } else if (msg.type === "modes") { - setAvailableModes((msg.modes as ModeResult[]) || []) - } else if (msg.type === "routerModels") { - if (msg.routerModels) { - setRouterModels(msg.routerModels) - } - } - }, - [ - handleSayMessage, - handleAskMessage, - setFileSearchResults, - setAllSlashCommands, - setAvailableModes, - setCurrentMode, - setTokenUsage, - setRouterModels, - setTaskHistory, - ], - ) - - return { - handleExtensionMessage, - seenMessageIds, - pendingCommandRef, - firstTextMessageSkipped, - } -} diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index 6c9566a0067..9ce3f314b79 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -1,10 +1,13 @@ import { create } from "zustand" import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types" +import { DebugLogger } from "@roo-code/core/cli" import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js" import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js" +const storeLogger = new DebugLogger("STORE") + /** * Shallow array equality check - compares array length and element references. * Used to prevent unnecessary state updates when array content hasn't changed. @@ -162,10 +165,24 @@ export const useCLIStore = create((set, get) => ({ // For NEW messages (not updates) - always apply immediately if (existingIndex === -1) { + storeLogger.debug("addMessage:new", { + id: msg.id, + role: msg.role, + toolName: msg.toolName || "none", + partial: msg.partial, + hasToolData: !!msg.toolData, + msgCount: state.messages.length + 1, + }) set({ messages: [...state.messages, msg] }) return } + storeLogger.debug("addMessage:update", { + id: msg.id, + partial: msg.partial, + existingIndex, + }) + // For UPDATES to existing messages: // If partial (streaming) and message exists, debounce the update if (msg.partial) { diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 11247ec03d6..ed708655a7d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -26,6 +26,12 @@ interface WriteToFileParams { export class WriteToFileTool extends BaseTool<"write_to_file"> { readonly name = "write_to_file" as const + /** + * Track whether we've sent the initial "tool starting" notification. + * This allows us to send an immediate notification before path stabilizes. + */ + private hasNotifiedToolStart = false + parseLegacy(params: Partial>): WriteToFileParams { return { path: params.path || "", @@ -124,6 +130,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.diffViewProvider.originalContent = "" } + // Send partial message immediately to indicate tool is starting (before file write) + const partialMessage = JSON.stringify(sharedMessageProps) + await task.ask("tool", partialMessage, true).catch(() => {}) + let unified = fileExists ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) @@ -200,11 +210,33 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } } + /** + * Reset partial state tracking, including the tool start notification flag. + */ + override resetPartialState(): void { + super.resetPartialState() + this.hasNotifiedToolStart = false + } + override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { const relPath: string | undefined = block.params.path let newContent: string | undefined = block.params.content - // Wait for path to stabilize before showing UI (prevents truncated paths) + // Send an immediate "tool starting" notification on first partial call + // This ensures CLI sees the tool start immediately, before path stabilizes + if (!this.hasNotifiedToolStart && relPath) { + this.hasNotifiedToolStart = true + const startMessage: ClineSayTool = { + tool: "newFileCreated", // Will be updated when we know if file exists + path: relPath, + content: "", + isOutsideWorkspace: false, + isProtected: false, + } + await task.ask("tool", JSON.stringify(startMessage), true).catch(() => {}) + } + + // Wait for path to stabilize before showing full UI (prevents truncated paths) if (!this.hasPathStabilized(relPath) || newContent === undefined) { return } @@ -216,10 +248,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, ) - if (isPreventFocusDisruptionEnabled) { - return - } - // relPath is guaranteed non-null after hasPathStabilized let fileExists: boolean const absolutePath = path.resolve(task.cwd, relPath!) @@ -248,9 +276,15 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { isProtected: isWriteProtected, } + // Always send partial messages to keep CLI informed during streaming const partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, block.partial).catch(() => {}) + // Skip diff view operations when experiment is enabled (prevents focus disruption in VSCode) + if (isPreventFocusDisruptionEnabled) { + return + } + if (newContent) { if (!task.diffViewProvider.isEditing) { await task.diffViewProvider.open(relPath!)