mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Streaming comment execution & streaming file writes
This commit is contained in:
parent
569055ea9a
commit
a3f02bdefd
23 changed files with 1234 additions and 571 deletions
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
export * from "./extension-host.js"
|
||||
export { ExtensionClient } from "./extension-client.js"
|
||||
export type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
508
apps/cli/src/ui/hooks/useClientEvents.ts
Normal file
508
apps/cli/src/ui/hooks/useClientEvents.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
83
apps/cli/src/ui/hooks/useExtensionState.ts
Normal file
83
apps/cli/src/ui/hooks/useExtensionState.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue