diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index e518972a1c..8d9f19c726 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -170,6 +170,7 @@ export const clineSays = [ "user_edit_todos", "too_many_tools_warning", "tool", + "steering_advice", ] as const export const clineSaySchema = z.enum(clineSays) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ab06f0dabb..fb5ea11e1e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -580,6 +580,7 @@ export interface WebviewMessage { | "moveSkill" | "updateSkillModes" | "openSkillFile" + | "steeringAdvice" text?: string taskId?: string editedMessageContent?: string diff --git a/src/core/steering/SteeringQueue.ts b/src/core/steering/SteeringQueue.ts new file mode 100644 index 0000000000..e82a2e916c --- /dev/null +++ b/src/core/steering/SteeringQueue.ts @@ -0,0 +1,92 @@ +/** + * SteeringQueue + * + * A simple queue for reactive steering advice. When the user sends steering + * advice while the agent is actively working, it is enqueued here. At the + * next API call the pending advice is drained, formatted, and injected into + * the prompt so the LLM can naturally incorporate it. + * + * Design constraints: + * - Zero overhead when no advice is pending (no extra tokens). + * - Capped at MAX_PENDING to avoid unbounded growth. + * - TTL-based expiration so stale advice doesn't pollute future calls. + */ + +export interface SteeringAdvice { + text: string + timestamp: number +} + +const MAX_PENDING = 5 +const TTL_MS = 5 * 60 * 1000 // 5 minutes + +export class SteeringQueue { + private queue: SteeringAdvice[] = [] + + /** + * Enqueue a piece of steering advice from the user. + * Drops the oldest item if the queue is at capacity. + */ + enqueue(text: string): void { + if (!text.trim()) { + return + } + + if (this.queue.length >= MAX_PENDING) { + this.queue.shift() + } + + this.queue.push({ text: text.trim(), timestamp: Date.now() }) + } + + /** + * Drain all non-expired advice and format it as an injection block. + * Returns `undefined` when the queue is empty (zero-overhead path). + */ + drain(): string | undefined { + if (this.queue.length === 0) { + return undefined + } + + const now = Date.now() + const valid = this.queue.filter((a) => now - a.timestamp < TTL_MS) + this.queue = [] + + if (valid.length === 0) { + return undefined + } + + const lines = valid.map((a, i) => `${i + 1}. ${a.text}`).join("\n") + + return [ + "", + "The user has sent the following real-time steering advice while you were working.", + "Please incorporate this guidance into your next actions. If any advice conflicts", + "with your current approach, use your judgment to determine the best path forward.", + "", + lines, + "", + ].join("\n") + } + + /** + * Whether the queue has any pending advice. + */ + get hasPending(): boolean { + return this.queue.length > 0 + } + + /** + * Number of items currently in the queue. + */ + get size(): number { + return this.queue.length + } + + /** + * Clear all pending advice. + */ + clear(): void { + this.queue = [] + } +} diff --git a/src/core/steering/__tests__/SteeringQueue.test.ts b/src/core/steering/__tests__/SteeringQueue.test.ts new file mode 100644 index 0000000000..5aae7844b2 --- /dev/null +++ b/src/core/steering/__tests__/SteeringQueue.test.ts @@ -0,0 +1,109 @@ +import { SteeringQueue } from "../SteeringQueue" + +describe("SteeringQueue", () => { + let queue: SteeringQueue + + beforeEach(() => { + queue = new SteeringQueue() + }) + + describe("enqueue", () => { + it("should add advice to the queue", () => { + queue.enqueue("Use Vitest instead of Jest") + expect(queue.size).toBe(1) + expect(queue.hasPending).toBe(true) + }) + + it("should ignore empty or whitespace-only advice", () => { + queue.enqueue("") + queue.enqueue(" ") + expect(queue.size).toBe(0) + expect(queue.hasPending).toBe(false) + }) + + it("should trim advice text", () => { + queue.enqueue(" Use Vitest ") + const result = queue.drain() + expect(result).toContain("Use Vitest") + expect(result).not.toContain(" Use Vitest ") + }) + + it("should drop oldest item when at capacity (5)", () => { + for (let i = 1; i <= 6; i++) { + queue.enqueue(`Advice ${i}`) + } + expect(queue.size).toBe(5) + const result = queue.drain()! + expect(result).not.toContain("Advice 1") + expect(result).toContain("Advice 2") + expect(result).toContain("Advice 6") + }) + }) + + describe("drain", () => { + it("should return undefined when empty", () => { + expect(queue.drain()).toBeUndefined() + }) + + it("should return formatted injection text", () => { + queue.enqueue("Use Vitest instead of Jest") + queue.enqueue("Use UTF-8 encoding") + const result = queue.drain()! + + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("1. Use Vitest instead of Jest") + expect(result).toContain("2. Use UTF-8 encoding") + }) + + it("should clear the queue after draining", () => { + queue.enqueue("Some advice") + queue.drain() + expect(queue.size).toBe(0) + expect(queue.hasPending).toBe(false) + expect(queue.drain()).toBeUndefined() + }) + + it("should skip expired advice (older than 5 minutes)", () => { + queue.enqueue("Old advice") + + // Manually expire the item by backdating its timestamp + const internalQueue = (queue as any).queue as Array<{ text: string; timestamp: number }> + internalQueue[0].timestamp = Date.now() - 6 * 60 * 1000 // 6 min ago + + queue.enqueue("Fresh advice") + + const result = queue.drain()! + expect(result).not.toContain("Old advice") + expect(result).toContain("Fresh advice") + }) + + it("should return undefined when all advice is expired", () => { + queue.enqueue("Stale advice") + const internalQueue = (queue as any).queue as Array<{ text: string; timestamp: number }> + internalQueue[0].timestamp = Date.now() - 6 * 60 * 1000 + + expect(queue.drain()).toBeUndefined() + expect(queue.size).toBe(0) + }) + }) + + describe("clear", () => { + it("should remove all pending advice", () => { + queue.enqueue("Advice 1") + queue.enqueue("Advice 2") + queue.clear() + expect(queue.size).toBe(0) + expect(queue.hasPending).toBe(false) + }) + }) + + describe("zero-overhead path", () => { + it("should have no overhead when empty (drain returns undefined)", () => { + // This verifies the zero-overhead contract: when no advice is pending, + // drain() returns undefined and no injection text is generated. + expect(queue.drain()).toBeUndefined() + expect(queue.hasPending).toBe(false) + }) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1d4320493a..6e2bbfda82 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -128,6 +128,7 @@ import { import { processUserContentMentions } from "../mentions/processUserContentMentions" import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHistory } from "../condense" import { MessageQueueService } from "../message-queue/MessageQueueService" +import { SteeringQueue } from "../steering/SteeringQueue" import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval" import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" @@ -335,6 +336,9 @@ export class Task extends EventEmitter implements TaskLike { public readonly messageQueueService: MessageQueueService private messageQueueStateChangedHandler: (() => void) | undefined + // Steering Queue - allows users to send real-time advice during task execution + public readonly steeringQueue: SteeringQueue = new SteeringQueue() + // Streaming isWaitingForFirstChunk = false isStreaming = false @@ -2639,6 +2643,15 @@ export class Task extends EventEmitter implements TaskLike { // Add environment details as its own text block, separate from tool // results. let finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] + + // Inject any pending steering advice into the prompt. + // This is the zero-overhead path: when no advice is pending, drain() + // returns undefined and nothing is added. + const steeringInjection = this.steeringQueue.drain() + if (steeringInjection) { + finalUserContent.push({ type: "text" as const, text: steeringInjection }) + } + // Only add user message to conversation history if: // 1. This is the first attempt (retryAttempt === 0), AND // 2. The original userContent was not empty (empty signals delegation resume where diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 89f29ae2e1..a4fa46d601 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3164,6 +3164,16 @@ export const webviewMessageHandler = async ( * Chat Message Queue */ + case "steeringAdvice": { + const task = provider.getCurrentTask() + if (task && message.text) { + task.steeringQueue.enqueue(message.text) + // Show the advice in chat as a steering_advice message + await task.say("steering_advice", message.text) + } + break + } + case "queueMessage": { const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) provider.getCurrentTask()?.messageQueueService.addMessage(resolved.text, resolved.images) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 1f3f8f5a4f..702c88cedb 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -71,6 +71,7 @@ import { Split, ArrowRight, Check, + Compass, } from "lucide-react" import { cn } from "@/lib/utils" import { PathTooltip } from "../ui/PathTooltip" @@ -1274,6 +1275,23 @@ export const ChatRowContent = ({ ) + case "steering_advice": + return ( + + + + {t("chat:steeringAdvice.youAdvised")} + {t("chat:steeringAdvice.label")} + + + {message.text} + + + ) case "user_feedback_diff": const tool = safeJsonParse(message.text) return ( diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index e72c1726f3..8a6d81cb42 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" -import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square } from "lucide-react" +import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square, Compass } from "lucide-react" import type { ExtensionMessage } from "@roo-code/types" @@ -56,6 +56,7 @@ interface ChatTextAreaProps { isStreaming?: boolean onStop?: () => void onEnqueueMessage?: () => void + onSendSteeringAdvice?: () => void } export const ChatTextArea = forwardRef( @@ -79,6 +80,7 @@ export const ChatTextArea = forwardRef( isStreaming = false, onStop, onEnqueueMessage, + onSendSteeringAdvice, }, ref, ) => { @@ -1196,6 +1198,29 @@ export const ChatTextArea = forwardRef( )} + {/* Steering advice button - shown when streaming and user has typed content */} + {!isEditMode && isStreaming && hasInputContent && onSendSteeringAdvice && ( + + + + + + )} {/* Queue button - shown when streaming and user has typed content */} {!isEditMode && isStreaming && hasInputContent && onEnqueueMessage && ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fd0aca66cb..1b4aded7d3 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -722,6 +722,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const text = inputValue.trim() + if (text) { + vscode.postMessage({ + type: "steeringAdvice", + text, + }) + setInputValue("") + setSelectedImages([]) + } + }, [inputValue]) + // This logic depends on the useEffect[messages] above to set clineAsk, // after which buttons are shown and we then send an askResponse to the // extension. @@ -1761,6 +1774,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction {isProfileDisabled && ( diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 4899859e3a..9c046b373d 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -120,6 +120,11 @@ "tooltip": "Stop the current task" }, "enqueueMessage": "Add message to queue (will be sent after current task completes)", + "sendSteeringAdvice": "Send steering advice (will be included in the next API call)", + "steeringAdvice": { + "label": "Steering", + "youAdvised": "You advised" + }, "editMessage": { "placeholder": "Edit your message..." },