Streaming comment execution & streaming file writes

This commit is contained in:
cte 2026-01-10 13:31:27 -08:00
parent 569055ea9a
commit a3f02bdefd
23 changed files with 1234 additions and 571 deletions

View file

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

View file

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

View file

@ -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<AskHandleResult> {
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<AskHandleResult> {
let toolName = "unknown"
let toolInfo: Record<string, unknown> = {}
// Skip output if we already streamed this tool request via partial messages
if (!this.outputManager.isAlreadyDisplayed(ts)) {
let toolName = "unknown"
let toolInfo: Record<string, unknown> = {}
try {
toolInfo = JSON.parse(text) as Record<string, unknown>
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}`)
}
// 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)
try {
toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = (toolInfo.tool as string) || "unknown"
} catch {
// Use raw text if not JSON
}
this.outputManager.output(` ${key}: ${displayValue}`)
}
const isProtected = toolInfo.isProtected === true
this.outputManager.markDisplayed(ts, text || "", false)
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}`)
}
// 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)
}
if (this.nonInteractive) {
// Auto-approved by extension settings (unless protected)

View file

@ -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
// =============================================================================

View file

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

View file

@ -1 +1,3 @@
export * from "./extension-host.js"
export { ExtensionClient } from "./extension-client.js"
export type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js"

View file

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

View file

@ -90,6 +90,16 @@ export class OutputManager {
*/
private loggedFirstPartial = new Set<number>()
/**
* Track streaming terminal output by execution ID.
*/
private terminalOutputByExecutionId = new Map<string, string>()
/**
* 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<number, string>()
/**
* Track which tool messages have already shown truncation marker.
*/
private toolContentTruncated = new Set<number>()
/**
* 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<string, unknown>
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)
}
}

View file

@ -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()
const { sendToExtension, runTask, cleanup } = useExtensionHost({
// Track seen message IDs and first text message skip for task submission
const seenMessageIds = useRef<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
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,

View file

@ -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) {
</Box>
)
case "tool": {
// Parse rawContent to get content for logging
let parsedContent = ""
try {
const parsed = JSON.parse(content) as Record<string, unknown>
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

View file

@ -41,7 +41,6 @@ export function createHelpTrigger(): AutocompleteTrigger<HelpShortcutResult> {
id: "help",
triggerChar: "?",
position: "line-start",
consumeTrigger: true,
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Check if line starts with ? (after optional whitespace)

View file

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

View file

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

View file

@ -97,14 +97,6 @@ export interface AutocompleteTrigger<T extends AutocompleteItem = AutocompleteIt
* @default 150
*/
debounceMs?: number
/**
* Whether the trigger character should be consumed (not shown in input).
* When true, the trigger character is treated as a control character
* that activates the picker but doesn't appear in the text input.
* @default false
*/
consumeTrigger?: boolean
}
/**

View file

@ -56,19 +56,6 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
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<T extends AutocompleteItem>(
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<T extends AutocompleteItem>(
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],
)
/**

View file

@ -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<string, unknown>
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 (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header row with path on same line */}
@ -76,15 +101,15 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
</Text>
</>
)}
{isNewFile && (
{isNewFile && !diffStats && (
<Text color={theme.successColor} bold>
{" "}
NEW
</Text>
)}
{/* Diff stats badge */}
{diffStats && (
{/* Stats - show line/char count for streaming, or diff stats when complete */}
{diffStats ? (
<>
<Text color={theme.dimText}> </Text>
<Text color={theme.successColor} bold>
@ -95,6 +120,14 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
-{diffStats.removed}
</Text>
</>
) : (
isNewFile &&
totalChars > 0 && (
<Text color={theme.dimText}>
{" "}
({totalLines} lines, {totalChars} chars)
</Text>
)
)}
{/* Warning badges */}
@ -107,7 +140,23 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
)}
</Box>
{/* Diff preview */}
{/* Streaming content preview for new files (before diff is available) */}
{isNewFile && !diff && displayLines.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{displayLines.map((line, index) => (
<Text key={index} color={theme.toolText}>
{line}
</Text>
))}
{isContentTruncated && (
<Text color={theme.dimText} dimColor>
... ({truncatedLineCount} more lines)
</Text>
)}
</Box>
)}
{/* Diff preview for edits */}
{diffHunks.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{diffHunks.slice(0, 2).map((hunk, hunkIndex) => (
@ -149,13 +198,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
</Box>
)}
{/* 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 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.toolText}>{previewDiff}</Text>
{truncated && (
{diff
.split("\n")
.slice(0, MAX_PREVIEW_LINES)
.map((line, index) => (
<Text key={index} color={theme.toolText}>
{line}
</Text>
))}
{diff.split("\n").length > MAX_PREVIEW_LINES && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
... ({diff.split("\n").length - MAX_PREVIEW_LINES} more lines)
</Text>
)}
</Box>

View file

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

View file

@ -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<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track pending command for injecting into command_output toolData
const pendingCommandRef = useRef<string | null>(null)
// Track the message ID of the current command being executed (for streaming updates)
const currentCommandMessageIdRef = useRef<string | null>(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<string | null>(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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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 }
}

View file

@ -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<void>) | null
cleanup: () => Promise<void>
@ -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<ExtensionHostInterface | null>(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<ExtensionClient | null>(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],
)
}

View file

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

View file

@ -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<Set<string>>
pendingCommandRef: React.MutableRefObject<string | null>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
/**
* 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<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track pending command for injecting into command_output toolData
const pendingCommandRef = useRef<string | null>(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<string, unknown>
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<string, unknown>
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<string, unknown>
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,
}
}

View file

@ -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<CLIState & CLIActions>((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) {

View file

@ -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<Record<string, string>>): 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<void> {
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!)