diff --git a/package-lock.json b/package-lock.json index ea8784fa65..2948b13940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13094,7 +13094,8 @@ "node_modules/fzf": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", - "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==" + "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", + "license": "BSD-3-Clause" }, "node_modules/gauge": { "version": "5.0.2", diff --git a/package.json b/package.json index 82b7b7d9ab..f319ee35bc 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,18 @@ "command": "roo-cline.setCustomStoragePath", "title": "Set Custom Storage Path", "category": "Roo Code" + }, + { + "command": "roo-cline.sendClipboardToHumanRelay", + "title": "Roo Code: Send Clipboard Content to Human Relay", + "category": "Roo Code" + } + ], + "keybindings": [ + { + "command": "roo-cline.sendClipboardToHumanRelay", + "key": "ctrl+alt+v", + "mac": "cmd+alt+v" } ], "menus": { diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js index c40d6dc680..77aa798616 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.js @@ -88,6 +88,10 @@ const vscode = { Directory: 2, SymbolicLink: 64, }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, // Match the structure in ClineProvider.test.ts + RefactorRewrite: { value: "refactor.rewrite" }, // Match the structure in ClineProvider.test.ts + }, TabInputText: class { constructor(uri) { this.uri = uri diff --git a/src/activate/humanRelay.ts b/src/activate/humanRelay.ts index ed87026aa7..363dfbb9e6 100644 --- a/src/activate/humanRelay.ts +++ b/src/activate/humanRelay.ts @@ -1,3 +1,6 @@ +import * as vscode from "vscode" +import { getPanel } from "./registerCommands" + // Callback mapping of human relay response. const humanRelayCallbacks = new Map void>() @@ -24,3 +27,36 @@ export const handleHumanRelayResponse = (response: { requestId: string; text?: s humanRelayCallbacks.delete(response.requestId) } } + +/** + * Validate if content contains any tags in format + */ +export function containsValidTags(content: string): boolean { + const tagPattern = /<[^>]+>/ + return tagPattern.test(content) +} + +export const sendClipboardToHumanRelay = async () => { + const panel = getPanel() + if (!panel) { + return + } + + try { + const clipboardText = await vscode.env.clipboard.readText() + if (!clipboardText) { + return + } + + const requestId = "SendAIResponse" + + panel?.webview.postMessage({ type: "closeHumanRelayDialog" }) + + vscode.commands.executeCommand("roo-cline.handleHumanRelayResponse", { + requestId, + text: clipboardText, + }) + } catch (error) { + console.error("Failed to process clipboard content:", error) + } +} diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 4af6b81c54..f92ab2d8d5 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -15,7 +15,12 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl return visibleProvider } -import { registerHumanRelayCallback, unregisterHumanRelayCallback, handleHumanRelayResponse } from "./humanRelay" +import { + registerHumanRelayCallback, + unregisterHumanRelayCallback, + handleHumanRelayResponse, + sendClipboardToHumanRelay, +} from "./humanRelay" import { handleNewTask } from "./handleTask" // Store panel references in both modes @@ -24,7 +29,7 @@ let tabPanel: vscode.WebviewPanel | undefined = undefined /** * Get the currently active panel - * @returns WebviewPanel或WebviewView + * @returns WebviewPanel or WebviewView */ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined { return tabPanel || sidebarPanel @@ -109,8 +114,10 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt "roo-cline.registerHumanRelayCallback": registerHumanRelayCallback, "roo-cline.unregisterHumanRelayCallback": unregisterHumanRelayCallback, "roo-cline.handleHumanRelayResponse": handleHumanRelayResponse, - "roo-cline.newTask": handleNewTask, + "roo-cline.sendClipboardToHumanRelay": sendClipboardToHumanRelay, // Keep local change + "roo-cline.newTask": handleNewTask, // Keep remote change "roo-cline.setCustomStoragePath": async () => { + // Keep remote change const { promptForCustomStoragePath } = await import("../shared/storagePathManager") await promptForCustomStoragePath() }, diff --git a/src/api/providers/human-relay.ts b/src/api/providers/human-relay.ts index b8bd4c2829..9a8be94e99 100644 --- a/src/api/providers/human-relay.ts +++ b/src/api/providers/human-relay.ts @@ -1,15 +1,14 @@ -// filepath: e:\Project\Roo-Code\src\api\providers\human-relay.ts import { Anthropic } from "@anthropic-ai/sdk" import { ApiHandlerOptions, ModelInfo } from "../../shared/api" import { ApiHandler, SingleCompletionHandler } from "../index" import { ApiStream } from "../transform/stream" import * as vscode from "vscode" -import { ExtensionMessage } from "../../shared/ExtensionMessage" -import { getPanel } from "../../activate/registerCommands" // Import the getPanel function +import { getPanel } from "../../activate/registerCommands" +import { containsValidTags } from "../../activate/humanRelay" /** * Human Relay API processor - * This processor does not directly call the API, but interacts with the model through human operations copy and paste. + * This processor does not directly call the API, but interacts with the model through human operations like copy and paste. */ export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions @@ -17,24 +16,22 @@ export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { constructor(options: ApiHandlerOptions) { this.options = options } + countTokens(content: Array): Promise { return Promise.resolve(0) } /** * Create a message processing flow, display a dialog box to request human assistance - * @param systemPrompt System prompt words - * @param messages Message list */ async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - // Get the most recent user message const latestMessage = messages[messages.length - 1] if (!latestMessage) { throw new Error("No message to relay") } - // If it is the first message, splice the system prompt word with the user message + // Concatenate system prompt with user message if this is the first message let promptText = "" if (messages.length === 1) { promptText = `${systemPrompt}\n\n${getMessageContent(latestMessage)}` @@ -45,15 +42,13 @@ export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { // Copy to clipboard await vscode.env.clipboard.writeText(promptText) - // A dialog box pops up to request user action - const response = await showHumanRelayDialog(promptText) + // Display a dialog box to request user action + const response = await showHumanRelayDialog(promptText, this.options) if (!response) { - // The user canceled the operation throw new Error("Human relay operation cancelled") } - // Return to the user input reply yield { type: "text", text: response } } @@ -61,7 +56,6 @@ export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { * Get model information */ getModel(): { id: string; info: ModelInfo } { - // Human relay does not depend on a specific model, here is a default configuration return { id: "human-relay", info: { @@ -78,15 +72,12 @@ export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { } /** - * Implementation of a single prompt - * @param prompt Prompt content + * Implementation of a single prompt completion */ async completePrompt(prompt: string): Promise { - // Copy to clipboard await vscode.env.clipboard.writeText(prompt) - // A dialog box pops up to request user action - const response = await showHumanRelayDialog(prompt) + const response = await showHumanRelayDialog(prompt, this.options) if (!response) { throw new Error("Human relay operation cancelled") @@ -97,8 +88,7 @@ export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { } /** - * Extract text content from message object - * @param message + * Extract text content from a message object */ function getMessageContent(message: Anthropic.Messages.MessageParam): string { if (typeof message.content === "string") { @@ -111,29 +101,142 @@ function getMessageContent(message: Anthropic.Messages.MessageParam): string { } return "" } + +// Global variables +let normalizedPrompt: string | null = null +let currentPrompt: string | null = null +let normalizedLastResponse: string | null = null +let globalClipboardInterval: NodeJS.Timeout | null = null + +/** + * Normalize text by removing extra spaces + */ +function normalizeText(text: string | null): string { + if (!text) return "" + return text.replace(/\s+/g, " ").trim() +} + /** - * Displays the human relay dialog and waits for user response. - * @param promptText The prompt text that needs to be copied. - * @returns The user's input response or undefined (if canceled). + * Compare if two strings are equal (ignoring whitespace differences) */ -async function showHumanRelayDialog(promptText: string): Promise { +function isTextEqual(str1: string | null, str2: string | null): boolean { + if (str1 === str2) return true + if (!str1 || !str2) return false + return normalizeText(str1) === normalizeText(str2) +} + +/** + * Stop clipboard monitoring + */ +function stopClipboardMonitoring() { + if (globalClipboardInterval) { + clearInterval(globalClipboardInterval) + globalClipboardInterval = null + } +} + +/** + * Start clipboard monitoring + */ +async function startClipboardMonitoring(requestId: string, options?: ApiHandlerOptions) { + // Stop any existing monitoring + stopClipboardMonitoring() + vscode.env.clipboard.writeText(currentPrompt ?? "") + + // Start new monitoring + const monitorInterval = Math.min(Math.max(100, options?.humanRelayMonitorInterval ?? 500), 2000) + + globalClipboardInterval = setInterval(async () => { + try { + const currentClipboardContent = await vscode.env.clipboard.readText() + if (!currentClipboardContent) { + return + } + + const normalizedClipboard = normalizeText(currentClipboardContent) + + const panel = getPanel() + + // Check if response is duplicate + if (normalizedClipboard === normalizedLastResponse) { + panel?.webview.postMessage({ + type: "showHumanRelayResponseAlert", + requestId: "lastInteraction", + }) + return + } + if (!containsValidTags(currentClipboardContent)) { + panel?.webview.postMessage({ + type: "showHumanRelayResponseAlert", + requestId: "invalidResponse", + }) + return + } + + // Process valid new response + if (normalizedClipboard !== normalizedPrompt) { + normalizedLastResponse = normalizedClipboard + + // Clear timer + stopClipboardMonitoring() + + // Close dialog and send response + panel?.webview.postMessage({ type: "closeHumanRelayDialog" }) + vscode.commands.executeCommand("roo-cline.handleHumanRelayResponse", { + requestId, + text: currentClipboardContent, + }) + } + } catch (error) { + console.error("Error monitoring clipboard:", error) + } + }, monitorInterval) +} + +/** + * Display human relay dialog and wait for user response + */ +async function showHumanRelayDialog(promptText: string, options?: ApiHandlerOptions): Promise { + currentPrompt = promptText + normalizedPrompt = normalizeText(promptText) + return new Promise((resolve) => { - // Create a unique request ID - const requestId = Date.now().toString() + // Create unique request ID + const requestId = "SendAIResponse" - // Register a global callback function + // Register global callback function vscode.commands.executeCommand( "roo-cline.registerHumanRelayCallback", requestId, (response: string | undefined) => { + stopClipboardMonitoring() resolve(response) }, ) - // Open the dialog box directly using the current panel + // Get panel and register message handler + const panel = getPanel() + if (panel) { + panel.webview.onDidReceiveMessage((message) => { + if (message.type === "toggleHumanRelayMonitor" && message.requestId === requestId) { + if (message.bool) { + startClipboardMonitoring(requestId, options) + } else { + stopClipboardMonitoring() + } + } + }) + } + + // Open dialog vscode.commands.executeCommand("roo-cline.showHumanRelayDialog", { requestId, promptText, }) + + // Start polling clipboard changes if enabled + if (options?.humanRelayMonitorClipboard) { + startClipboardMonitoring(requestId, options) + } }) } diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index fbd18ab6b3..89e1f9bc51 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -178,6 +178,8 @@ type ProviderSettings = { modelMaxThinkingTokens?: number | undefined includeMaxTokens?: boolean | undefined fakeAi?: unknown | undefined + humanRelayMonitorClipboard?: boolean | undefined + humanRelayMonitorInterval?: number | undefined } type GlobalSettings = { @@ -335,6 +337,8 @@ type GlobalSettings = { } | undefined enhancementApiConfigId?: string | undefined + humanRelayMonitorClipboard?: boolean | undefined + humanRelayMonitorInterval?: number | undefined } type ClineMessage = { diff --git a/src/exports/types.ts b/src/exports/types.ts index 3a7a96998a..6042dfdd57 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -179,6 +179,8 @@ type ProviderSettings = { modelMaxThinkingTokens?: number | undefined includeMaxTokens?: boolean | undefined fakeAi?: unknown | undefined + humanRelayMonitorClipboard?: boolean | undefined + humanRelayMonitorInterval?: number | undefined } export type { ProviderSettings } @@ -338,6 +340,8 @@ type GlobalSettings = { } | undefined enhancementApiConfigId?: string | undefined + humanRelayMonitorClipboard?: boolean | undefined + humanRelayMonitorInterval?: number | undefined } export type { GlobalSettings } diff --git a/src/schemas/index.ts b/src/schemas/index.ts index a5c060e2d6..dca31439b6 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -387,6 +387,9 @@ export const providerSettingsSchema = z.object({ includeMaxTokens: z.boolean().optional(), // Fake AI fakeAi: z.unknown().optional(), + // Human Relay + humanRelayMonitorClipboard: z.boolean().optional(), + humanRelayMonitorInterval: z.number().optional(), }) export type ProviderSettings = z.infer @@ -471,6 +474,9 @@ const providerSettingsRecord: ProviderSettingsRecord = { includeMaxTokens: undefined, // Fake AI fakeAi: undefined, + // Human Relay + humanRelayMonitorClipboard: undefined, + humanRelayMonitorInterval: undefined, } export const PROVIDER_SETTINGS_KEYS = Object.keys(providerSettingsRecord) as Keys[] @@ -544,6 +550,8 @@ export const globalSettingsSchema = z.object({ customModePrompts: customModePromptsSchema.optional(), customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), + humanRelayMonitorClipboard: z.boolean().optional(), + humanRelayMonitorInterval: z.number().optional(), }) export type GlobalSettings = z.infer @@ -615,6 +623,8 @@ const globalSettingsRecord: GlobalSettingsRecord = { customSupportPrompts: undefined, enhancementApiConfigId: undefined, cachedChromeHostUrl: undefined, + humanRelayMonitorClipboard: undefined, + humanRelayMonitorInterval: undefined, } export const GLOBAL_SETTINGS_KEYS = Object.keys(globalSettingsRecord) as Keys[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 2cb1658988..f7eccf5880 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -16,6 +16,7 @@ export interface WebviewMessage { | "saveApiConfiguration" | "upsertApiConfiguration" | "deleteApiConfiguration" + | "toggleHumanRelayMonitor" | "loadApiConfiguration" | "loadApiConfigurationById" | "renameApiConfiguration" @@ -111,6 +112,8 @@ export interface WebviewMessage { | "maxWorkspaceFiles" | "humanRelayResponse" | "humanRelayCancel" + | "closeHumanRelayDialog" + | "showHumanRelayResponseAlert" | "browserToolEnabled" | "telemetrySetting" | "showRooIgnoredFiles" diff --git a/src/shared/api.ts b/src/shared/api.ts index 979d8aa53f..e754a94b40 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -2,6 +2,7 @@ import { ModelInfo, ProviderName, ProviderSettings } from "../schemas" export type { ModelInfo, ProviderName as ApiProvider } +// Keep the remote version which uses generated types export type ApiHandlerOptions = Omit export type ApiConfiguration = ProviderSettings diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 59a4047251..b10a4355b3 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -27,8 +27,15 @@ const tabsByMessageAction: Partial { - const { didHydrateState, showWelcome, shouldShowAnnouncement, telemetrySetting, telemetryKey, machineId } = - useExtensionState() + const { + didHydrateState, + showWelcome, + shouldShowAnnouncement, + telemetrySetting, + telemetryKey, + machineId, + apiConfiguration, + } = useExtensionState() const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") @@ -118,6 +125,8 @@ const App = () => { onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))} onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })} onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })} + monitorClipboard={apiConfiguration?.humanRelayMonitorClipboard} + monitorInterval={apiConfiguration?.humanRelayMonitorInterval} /> ) diff --git a/webview-ui/src/components/human-relay/HumanRelayDialog.tsx b/webview-ui/src/components/human-relay/HumanRelayDialog.tsx index a19a0037d0..b931d5167a 100644 --- a/webview-ui/src/components/human-relay/HumanRelayDialog.tsx +++ b/webview-ui/src/components/human-relay/HumanRelayDialog.tsx @@ -1,9 +1,12 @@ import * as React from "react" +import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" import { Button } from "../ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog" import { Textarea } from "../ui/textarea" import { useClipboard } from "../ui/hooks" -import { Check, Copy, X } from "lucide-react" +import { AlertTriangle, Check, Copy, Power, X } from "lucide-react" +import { useState as useReactState } from "react" +import { vscode } from "../../utils/vscode" import { useAppTranslation } from "@/i18n/TranslationContext" interface HumanRelayDialogProps { @@ -13,6 +16,9 @@ interface HumanRelayDialogProps { promptText: string onSubmit: (requestId: string, text: string) => void onCancel: (requestId: string) => void + monitorClipboard?: boolean + monitorInterval?: number + onToggleMonitor?: (enabled: boolean) => void } /** @@ -26,19 +32,67 @@ export const HumanRelayDialog: React.FC = ({ promptText, onSubmit, onCancel, + monitorClipboard = false, + monitorInterval = 500, + onToggleMonitor, }) => { const { t } = useAppTranslation() const [response, setResponse] = React.useState("") const { copy } = useClipboard() const [isCopyClicked, setIsCopyClicked] = React.useState(false) + const [showDuplicateWarning, setShowDuplicateWarning] = React.useState(false) + const [warningMessage, setWarningMessage] = React.useState("") + const [isMonitoring, setIsMonitoring] = useReactState(monitorClipboard) // Clear input when dialog opens React.useEffect(() => { if (isOpen) { setResponse("") setIsCopyClicked(false) + setIsMonitoring(monitorClipboard) } - }, [isOpen]) + setShowDuplicateWarning(false) + }, [isOpen, monitorClipboard, setIsMonitoring]) + + // Handle monitor toggle + const handleToggleMonitor = () => { + const newState = !isMonitoring + setIsMonitoring(newState) + + // Send message to backend to control clipboard monitoring + vscode.postMessage({ + type: "toggleHumanRelayMonitor", + bool: newState, + requestId: requestId, + }) + + if (onToggleMonitor) { + onToggleMonitor(newState) + } + } + + React.useEffect(() => { + // Handle messages from extension + const messageHandler = (event: MessageEvent) => { + const message = event.data + if (message.type === "closeHumanRelayDialog") { + onClose() + } + // Handle duplicate response warning + else if (message.type === "showHumanRelayResponseAlert") { + if (message.requestId === "lastInteraction") setWarningMessage(t("humanRelay:warning.lastInteraction")) + else if (message.requestId === "invalidResponse") + setWarningMessage(t("humanRelay:warning.invalidResponse")) + setShowDuplicateWarning(true) + } + } + + window.addEventListener("message", messageHandler) + + return () => { + window.removeEventListener("message", messageHandler) + } + }, [onClose, t]) // Copy to clipboard and show success message const handleCopy = () => { @@ -75,7 +129,7 @@ export const HumanRelayDialog: React.FC = ({