diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md index a7b1d9eed4..ab3c145f4a 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 38edf50d28..854c0f2c78 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 8d57e4547c..9958212ece 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 9b374310ad..49a82a97c0 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 8ddbce2eb0..4a9cf93ccb 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,18 @@ 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 +249,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 +285,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) + }) } // ========================================================================== @@ -436,9 +467,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() @@ -450,23 +478,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 23cbaacb4d..f55fefad05 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 2b9fd13602..dce32a93e7 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 0863546f6c..f657b2802e 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 fdb8644f53..927c6a61c8 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 c51b0faddb..622ba5003f 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 fe6b25ceb1..a543c0c13d 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 4080180d49..607ed3d38c 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 8e5906ac7c..b7b9c38860 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 bd51803fe7..33c431b479 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 0523f2f696..abfa32eb83 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 9e12cd9b0e..a46803f06c 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 0000000000..30758858be --- /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 91bdac2bf0..7f0451c731 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 0000000000..b1a4ec82b3 --- /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 68695e39c7..0000000000 --- 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 6c9566a006..9ce3f314b7 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/apps/web-roo-code/src/app/cloud/page.tsx b/apps/web-roo-code/src/app/cloud/page.tsx index 2ef147fba7..a198b08f40 100644 --- a/apps/web-roo-code/src/app/cloud/page.tsx +++ b/apps/web-roo-code/src/app/cloud/page.tsx @@ -98,8 +98,7 @@ const features: Feature[] = [ { icon: Brain, title: "Model Agnostic", - description: - "Bring your own keys or use the Roo Code Router with access to all top models with no markup.", + description: "Bring your own keys or use the Roo Code Router with access to all top models with no markup.", }, { icon: Github, @@ -153,7 +152,7 @@ export default function CloudPage() { Your AI Team in the Cloud

- Create your agent team in the Cloud, give them access to GitHub, and start delegating tasks + Create your agent team in the Cloud, give them access to GitHub, and start delegating tasks from Web and Slack.

diff --git a/apps/web-roo-code/src/app/pricing/page.tsx b/apps/web-roo-code/src/app/pricing/page.tsx index a98549dc15..85e66aaa7c 100644 --- a/apps/web-roo-code/src/app/pricing/page.tsx +++ b/apps/web-roo-code/src/app/pricing/page.tsx @@ -234,8 +234,8 @@ export default function PricingPage() {

On any plan, you can use your own LLM provider API key or use the built-in Roo Code - Router – curated models to work with Roo with no markup, including the - latest Gemini, GPT and Claude. Paid with credits. + Router – curated models to work with Roo with no markup, including the latest + Gemini, GPT and Claude. Paid with credits. See per model pricing. diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 11247ec03d..ed708655a7 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!)