diff --git a/.changeset/cyan-countries-sniff.md b/.changeset/cyan-countries-sniff.md new file mode 100644 index 0000000000..7c44bd0681 --- /dev/null +++ b/.changeset/cyan-countries-sniff.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add a kill command button to the execute command component diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index c838834d62..b65ce34b19 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -76,14 +76,14 @@ export async function attemptCompletionTool( } // Complete command message. - const didApprove = await askApproval("command", command) + const executionId = Date.now().toString() + const didApprove = await askApproval("command", command, { id: executionId }) if (!didApprove) { return } - const options: ExecuteCommandOptions = { command } - + const options: ExecuteCommandOptions = { executionId, command } const [userRejected, execCommandResult] = await executeCommand(cline, options) if (userRejected) { diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 2d723ffc0d..579627997c 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -4,6 +4,7 @@ import * as path from "path" import delay from "delay" import { Cline } from "../Cline" +import { CommandExecutionStatus } from "../../schemas" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { unescapeHtmlEntities } from "../../utils/text-normalization" @@ -47,8 +48,9 @@ export async function executeCommandTool( cline.consecutiveMistakeCount = 0 + const executionId = Date.now().toString() command = unescapeHtmlEntities(command) // Unescape HTML entities. - const didApprove = await askApproval("command", command) + const didApprove = await askApproval("command", command, { id: executionId }) if (!didApprove) { return @@ -59,6 +61,7 @@ export async function executeCommandTool( const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {} const options: ExecuteCommandOptions = { + executionId, command, customCwd, terminalShellIntegrationDisabled, @@ -74,8 +77,10 @@ export async function executeCommandTool( pushToolResult(result) } catch (error: unknown) { - await cline.say("shell_integration_warning") + const status: CommandExecutionStatus = { executionId, status: "fallback" } + clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) clineProvider?.setValue("terminalShellIntegrationDisabled", true) + await cline.say("shell_integration_warning") if (error instanceof ShellIntegrationError) { const [rejected, result] = await executeCommand(cline, { @@ -102,6 +107,7 @@ export async function executeCommandTool( } export type ExecuteCommandOptions = { + executionId: string command: string customCwd?: string terminalShellIntegrationDisabled?: boolean @@ -111,6 +117,7 @@ export type ExecuteCommandOptions = { export async function executeCommand( cline: Cline, { + executionId, command, customCwd, terminalShellIntegrationDisabled = false, @@ -141,6 +148,7 @@ export async function executeCommand( let shellIntegrationError: string | undefined const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode" + const clineProvider = await cline.providerRef.deref() const callbacks: RooTerminalCallbacks = { onLine: async (output: string, process: RooTerminalProcess) => { @@ -165,7 +173,15 @@ export async function executeCommand( result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit) completed = true }, - onShellExecutionComplete: (details: ExitCodeDetails) => (exitDetails = details), + onShellExecutionStarted: (pid: number | undefined) => { + const status: CommandExecutionStatus = { executionId, status: "running", pid } + clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + }, + onShellExecutionComplete: (details: ExitCodeDetails) => { + const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode } + clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + exitDetails = details + }, } if (terminalProvider === "vscode") { diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 2c552b1702..04d713005e 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -347,6 +347,7 @@ type ClineMessage = { | undefined progressStatus?: | { + id?: string | undefined icon?: string | undefined text?: string | undefined } @@ -422,6 +423,7 @@ type RooCodeEvents = { | undefined progressStatus?: | { + id?: string | undefined icon?: string | undefined text?: string | undefined } diff --git a/src/exports/types.ts b/src/exports/types.ts index 1072469509..7eee7d3cdf 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -352,6 +352,7 @@ type ClineMessage = { | undefined progressStatus?: | { + id?: string | undefined icon?: string | undefined text?: string | undefined } @@ -431,6 +432,7 @@ type RooCodeEvents = { | undefined progressStatus?: | { + id?: string | undefined icon?: string | undefined text?: string | undefined } diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index 4c097c7862..88e9b0f3aa 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -44,7 +44,7 @@ export abstract class BaseTerminal implements RooTerminal { * @param stream The stream to set, or undefined to clean up * @throws Error if process is undefined when a stream is provided */ - public setActiveStream(stream: AsyncIterable | undefined): void { + public setActiveStream(stream: AsyncIterable | undefined, pid?: number): void { if (stream) { if (!this.process) { this.running = false @@ -58,6 +58,7 @@ export abstract class BaseTerminal implements RooTerminal { this.running = true this.streamClosed = false + this.process.emit("shell_execution_started", pid) this.process.emit("stream_available", stream) } else { this.streamClosed = true diff --git a/src/integrations/terminal/ExecaTerminal.ts b/src/integrations/terminal/ExecaTerminal.ts index 13319a58ba..652f3ca39e 100644 --- a/src/integrations/terminal/ExecaTerminal.ts +++ b/src/integrations/terminal/ExecaTerminal.ts @@ -24,6 +24,7 @@ export class ExecaTerminal extends BaseTerminal { process.on("line", (line) => callbacks.onLine(line, process)) process.once("completed", (output) => callbacks.onCompleted(output, process)) + process.once("shell_execution_started", (pid) => callbacks.onShellExecutionStarted(pid, process)) process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process)) const promise = new Promise((resolve, reject) => { diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index 3a703f9097..d7695bdf6d 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -40,7 +40,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { cancelSignal: this.controller.signal, })`${command}` - this.terminal.setActiveStream(subprocess) + this.terminal.setActiveStream(subprocess, subprocess.pid) this.emit("line", "") for await (const line of subprocess) { @@ -60,10 +60,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { } catch (error) { if (error instanceof ExecaError) { console.error(`[ExecaTerminalProcess] shell execution error: ${error.message}`) - this.emit("shell_execution_complete", { - exitCode: error.exitCode ?? 1, - signalName: error.signal, - }) + this.emit("shell_execution_complete", { exitCode: error.exitCode ?? 1, signalName: error.signal }) } else { this.emit("shell_execution_complete", { exitCode: 1 }) } diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index fa6d12bf45..4b35e92bbf 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -55,6 +55,7 @@ export class Terminal extends BaseTerminal { // configured before the process starts. process.on("line", (line) => callbacks.onLine(line, process)) process.once("completed", (output) => callbacks.onCompleted(output, process)) + process.once("shell_execution_started", (pid) => callbacks.onShellExecutionStarted(pid, process)) process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process)) process.once("no_shell_integration", (msg) => callbacks.onNoShellIntegration?.(msg, process)) diff --git a/src/integrations/terminal/TerminalRegistry.ts b/src/integrations/terminal/TerminalRegistry.ts index 34b72df812..d31368541e 100644 --- a/src/integrations/terminal/TerminalRegistry.ts +++ b/src/integrations/terminal/TerminalRegistry.ts @@ -52,8 +52,7 @@ export class TerminalRegistry { const stream = e.execution.read() const terminal = this.getTerminalByVSCETerminal(e.terminal) - console.info("[onDidStartTerminalShellExecution] Shell execution started:", { - hasExecution: !!e.execution, + console.info("[onDidStartTerminalShellExecution]", { command: e.execution?.commandLine?.value, terminalId: terminal?.id, }) @@ -79,8 +78,7 @@ export class TerminalRegistry { const process = terminal?.process const exitDetails = TerminalProcess.interpretExitCode(e.exitCode) - console.info("[TerminalRegistry] Shell execution ended:", { - hasExecution: !!e.execution, + console.info("[onDidEndTerminalShellExecution]", { command: e.execution?.commandLine?.value, terminalId: terminal?.id, ...exitDetails, diff --git a/src/integrations/terminal/types.ts b/src/integrations/terminal/types.ts index 6d8001adc5..65d521ba6e 100644 --- a/src/integrations/terminal/types.ts +++ b/src/integrations/terminal/types.ts @@ -12,7 +12,7 @@ export interface RooTerminal { getCurrentWorkingDirectory(): string isClosed: () => boolean runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise - setActiveStream(stream: AsyncIterable | undefined): void + setActiveStream(stream: AsyncIterable | undefined, pid?: number): void shellExecutionComplete(exitDetails: ExitCodeDetails): void getProcessesWithOutput(): RooTerminalProcess[] getUnretrievedOutput(): string @@ -23,6 +23,7 @@ export interface RooTerminal { export interface RooTerminalCallbacks { onLine: (line: string, process: RooTerminalProcess) => void onCompleted: (output: string | undefined, process: RooTerminalProcess) => void + onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void onNoShellIntegration?: (message: string, process: RooTerminalProcess) => void } @@ -43,15 +44,11 @@ export interface RooTerminalProcessEvents { line: [line: string] continue: [] completed: [output?: string] + stream_available: [stream: AsyncIterable] + shell_execution_started: [pid: number | undefined] + shell_execution_complete: [exitDetails: ExitCodeDetails] error: [error: Error] no_shell_integration: [message: string] - /** - * Emitted when a shell execution completes - * @param id The terminal ID - * @param exitDetails Contains exit code and signal information if process was terminated by signal - */ - shell_execution_complete: [exitDetails: ExitCodeDetails] - stream_available: [stream: AsyncIterable] } export interface ExitCodeDetails { diff --git a/src/schemas/index.ts b/src/schemas/index.ts index a3ec338131..00c7919018 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -286,6 +286,29 @@ export const customSupportPromptsSchema = z.record(z.string(), z.string().option export type CustomSupportPrompts = z.infer +/** + * CommandExecutionStatus + */ + +export const commandExecutionStatusSchema = z.discriminatedUnion("status", [ + z.object({ + executionId: z.string(), + status: z.literal("running"), + pid: z.number().optional(), + }), + z.object({ + executionId: z.string(), + status: z.literal("exited"), + exitCode: z.number().optional(), + }), + z.object({ + executionId: z.string(), + status: z.literal("fallback"), + }), +]) + +export type CommandExecutionStatus = z.infer + /** * ExperimentId */ @@ -784,6 +807,7 @@ export type ClineSay = z.infer */ export const toolProgressStatusSchema = z.object({ + id: z.string().optional(), icon: z.string().optional(), text: z.string().optional(), }) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index bc4fbb66cb..0b61dcaa0a 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -67,6 +67,7 @@ export interface ExtensionMessage { | "toggleApiConfigPin" | "acceptInput" | "setHistoryPreviewCollapsed" + | "commandExecutionStatus" text?: string action?: | "chatButtonClicked" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 8b539f88a2..ea85bda79c 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -987,7 +987,11 @@ export const ChatRowContent = ({ {icon} {title} - + ) case "use_mcp_server": diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a5eef1582e..2c4dff3423 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,10 +1,10 @@ -import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import debounce from "debounce" import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent, useMount } from "react-use" +import debounce from "debounce" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" import { Trans } from "react-i18next" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { ClineAsk, @@ -28,6 +28,7 @@ import { validateCommand } from "@src/utils/command-validation" import { useAppTranslation } from "@src/i18n/TranslationContext" import TelemetryBanner from "../common/TelemetryBanner" +import { useTaskSearch } from "../history/useTaskSearch" import HistoryPreview from "../history/HistoryPreview" import RooHero from "@src/components/welcome/RooHero" import RooTips from "@src/components/welcome/RooTips" @@ -38,7 +39,7 @@ import ChatTextArea from "./ChatTextArea" import TaskHeader from "./TaskHeader" import AutoApproveMenu from "./AutoApproveMenu" import SystemPromptWarning from "./SystemPromptWarning" -import { useTaskSearch } from "../history/useTaskSearch" +import { CheckpointWarning } from "./CheckpointWarning" export interface ChatViewProps { isHidden: boolean @@ -267,7 +268,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - setExpandedRows({}) - }, [task?.ts]) + useEffect(() => setExpandedRows({}), [task?.ts]) const isStreaming = useMemo(() => { - const isLastAsk = !!modifiedMessages.at(-1)?.ask // checking clineAsk isn't enough since messages effect may be called again for a tool for example, set clineAsk to its value, and if the next message is not an ask then it doesn't reset. This is likely due to how much more often we're updating messages as compared to before, and should be resolved with optimizations as it's likely a rendering bug. but as a final guard for now, the cancel button will show if the last message is not an ask + // Checking clineAsk isn't enough since messages effect may be called + // again for a tool for example, set clineAsk to its value, and if the + // next message is not an ask then it doesn't reset. This is likely due + // to how much more often we're updating messages as compared to before, + // and should be resolved with optimizations as it's likely a rendering + // bug. But as a final guard for now, the cancel button will show if the + // last message is not an ask. + const isLastAsk = !!modifiedMessages.at(-1)?.ask + const isToolCurrentlyAsking = isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined + if (isToolCurrentlyAsking) { return false } const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true + if (isLastMessagePartial) { return true } else { const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started") + if ( lastApiReqStarted && lastApiReqStarted.text !== null && @@ -345,9 +349,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { text = text.trim() + if (text || images.length > 0) { if (messages.length === 0) { vscode.postMessage({ type: "newTask", text, images }) @@ -391,6 +396,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - vscode.postMessage({ type: "clearTask" }) - }, []) + const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), []) - /* - 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. - */ + // 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. const handlePrimaryButtonClick = useCallback( (text?: string, images?: string[]) => { const trimmedInput = text?.trim() + switch (clineAsk) { case "api_req_failed": case "command": @@ -454,6 +459,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - startNewTask() - }, [startNewTask]) + const handleTaskCloseButtonClick = useCallback(() => startNewTask(), [startNewTask]) const { info: model } = useSelectedModel(apiConfiguration) - const selectImages = useCallback(() => { - vscode.postMessage({ type: "selectImages" }) - }, []) + const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), []) const shouldDisableImages = !model?.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE @@ -524,6 +526,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { const message: ExtensionMessage = e.data + switch (message.type) { case "action": switch (message.action!) { @@ -564,7 +567,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // NOTE: the vscode window needs to be focused for this to work - textAreaRef.current?.focus() - }) + // NOTE: the VSCode window needs to be focused for this to work. + useMount(() => textAreaRef.current?.focus()) useEffect(() => { const timer = setTimeout(() => { @@ -591,6 +594,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { clearTimeout(timer) } @@ -600,23 +604,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction { switch (message.ask) { case "completion_result": - // don't show a chat row for a completion_result ask without text. This specific type of message only occurs if cline wants to execute a command as part of its completion result, in which case we interject the completion_result tool with the execute_command tool. + // Don't show a chat row for a completion_result ask without + // text. This specific type of message only occurs if cline + // wants to execute a command as part of its completion + // result, in which case we interject the completion_result + // tool with the execute_command tool. if (message.text === "") { return false } break - case "api_req_failed": // this message is used to update the latest api_req_started that the request failed + case "api_req_failed": // This message is used to update the latest `api_req_started` that the request failed. case "resume_task": case "resume_completed_task": return false } switch (message.say) { - case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways - case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried - case "api_req_deleted": // aggregated api_req metrics from deleted messages + case "api_req_finished": // `combineApiRequests` removes this from `modifiedMessages` anyways. + case "api_req_retried": // This message is used to update the latest `api_req_started` that the request was retried. + case "api_req_deleted": // Aggregated `api_req` metrics from deleted messages. return false case "api_req_retry_delayed": - // Only show the retry message if it's the last message or the last messages is api_req_retry_delayed+resume_task + // Only show the retry message if it's the last message or + // the last messages is api_req_retry_delayed+resume_task. const last1 = modifiedMessages.at(-1) const last2 = modifiedMessages.at(-2) if (last1?.ask === "resume_task" && last2 === message) { @@ -624,7 +633,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction s.name === mcpServerUse.serverName) const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName) return tool?.alwaysAllow || false } } + return false }, [mcpServers], ) - // Check if a command message is allowed + // Check if a command message is allowed. const isAllowedCommand = useCallback( (message: ClineMessage | undefined): boolean => { if (message?.type !== "ask") return false @@ -717,6 +738,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // this ensures the first message is not read, future user messages are labelled as user_feedback + // This ensures the first message is not read, future user messages are + // labeled as `user_feedback`. if (lastMessage && messages.length > 1) { - //console.log(JSON.stringify(lastMessage)) if ( lastMessage.text && // has text (lastMessage.say === "text" || lastMessage.say === "completion_result") && // is a text message @@ -804,9 +827,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // which of visible messages are browser session messages, see above + // Which of visible messages are browser session messages, see above. if (message.type === "ask") { return ["browser_action_launch"].includes(message.ask!) } + if (message.type === "say") { return ["api_req_started", "text", "browser_action", "browser_action_result"].includes(message.say!) } + return false } @@ -864,20 +890,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction { if (message.ask === "browser_action_launch") { - // complete existing browser session if any + // Complete existing browser session if any. endBrowserSession() - // start new + // Start new. isInBrowserSession = true currentGroup.push(message) } else if (isInBrowserSession) { - // end session if api_req_started is cancelled + // End session if `api_req_started` is cancelled. if (message.say === "api_req_started") { - // get last api_req_started in currentGroup to check if it's cancelled. If it is then this api req is not part of the current browser session + // Get last `api_req_started` in currentGroup to check if + // it's cancelled. If it is then this api req is not part + // of the current browser session. const lastApiReqStarted = [...currentGroup].reverse().find((m) => m.say === "api_req_started") + if (lastApiReqStarted?.text !== null && lastApiReqStarted?.text !== undefined) { const info = JSON.parse(lastApiReqStarted.text) const isCancelled = info.cancelReason !== null && info.cancelReason !== undefined + if (isCancelled) { endBrowserSession() result.push(message) @@ -918,27 +948,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction - debounce( - () => { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, - behavior: "smooth", - }) - }, - 10, - { immediate: true }, - ), + debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, { + immediate: true, + }), [], ) const scrollToBottomAuto = useCallback(() => { virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, - behavior: "auto", // instant causes crash + behavior: "auto", // Instant causes crash. }) }, []) - // scroll when user toggles certain rows + // Scroll when user toggles certain rows. const toggleRowExpansion = useCallback( (ts: number) => { const isCollapsing = expandedRows[ts] ?? false @@ -955,29 +978,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ - ...prev, - [ts]: !prev[ts], - })) + setExpandedRows((prev) => ({ ...prev, [ts]: !prev[ts] })) - // disable auto scroll when user expands row + // Disable auto scroll when user expands row if (!isCollapsing) { disableAutoScrollRef.current = true } if (isCollapsing && isAtBottom) { - const timer = setTimeout(() => { - scrollToBottomAuto() - }, 0) + const timer = setTimeout(() => scrollToBottomAuto(), 0) return () => clearTimeout(timer) } else if (isLast || isSecondToLast) { if (isCollapsing) { if (isSecondToLast && !isLastCollapsedApiReq) { return } - const timer = setTimeout(() => { - scrollToBottomAuto() - }, 0) + + const timer = setTimeout(() => scrollToBottomAuto(), 0) return () => clearTimeout(timer) } else { const timer = setTimeout(() => { @@ -986,6 +1003,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction clearTimeout(timer) } } @@ -999,9 +1017,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - scrollToBottomAuto() - }, 0) + setTimeout(() => scrollToBottomAuto(), 0) } } }, @@ -1010,22 +1026,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction { if (!disableAutoScrollRef.current) { - setTimeout(() => { - scrollToBottomSmooth() - }, 50) - // return () => clearTimeout(timer) // dont cleanup since if visibleMessages.length changes it cancels. + setTimeout(() => scrollToBottomSmooth(), 50) + // Don't cleanup since if visibleMessages.length changes it cancels. + // return () => clearTimeout(timer) } }, [groupedMessages.length, scrollToBottomSmooth]) const handleWheel = useCallback((event: Event) => { const wheelEvent = event as WheelEvent + if (wheelEvent.deltaY && wheelEvent.deltaY < 0) { if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) { - // user scrolled up + // User scrolled up disableAutoScrollRef.current = true } } }, []) + useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance // Effect to handle showing the checkpoint warning after a delay @@ -1047,40 +1064,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction ( -
- - - { - e.preventDefault() - window.postMessage( - { - type: "action", - action: "settingsButtonClicked", - values: { section: "checkpoints" }, - }, - "*", - ) - }} - className="inline px-0.5" - /> - ), - }} - /> - -
- ), - [], - ) - const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask") const itemContent = useCallback( @@ -1142,15 +1125,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Only proceed if we have an ask and buttons are enabled - if (!clineAsk || !enableButtons) return + // Only proceed if we have an ask and buttons are enabled. + if (!clineAsk || !enableButtons) { + return + } const autoApprove = async () => { if (isAutoApproved(lastMessage)) { - // Add delay for write operations + // Add delay for write operations. if (lastMessage?.ask === "tool" && isWriteToolAction(lastMessage)) { await new Promise((resolve) => setTimeout(resolve, writeDelayMs)) } + handlePrimaryButtonClick() } } @@ -1235,17 +1221,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction - {/* System prompt override warning */} {hasSystemPromptOverride && (
)} - {/* Checkpoint warning message */} {showCheckpointWarning && (
- +
)} diff --git a/webview-ui/src/components/chat/CheckpointWarning.tsx b/webview-ui/src/components/chat/CheckpointWarning.tsx new file mode 100644 index 0000000000..1b6977d281 --- /dev/null +++ b/webview-ui/src/components/chat/CheckpointWarning.tsx @@ -0,0 +1,34 @@ +import { Trans } from "react-i18next" +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" + +export const CheckpointWarning = () => { + return ( +
+ + + { + e.preventDefault() + window.postMessage( + { + type: "action", + action: "settingsButtonClicked", + values: { section: "checkpoints" }, + }, + "*", + ) + }} + className="inline px-0.5" + /> + ), + }} + /> + +
+ ) +} diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index f2977849b7..f156e39577 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -1,45 +1,118 @@ -import { HTMLAttributes, forwardRef, useMemo, useState } from "react" +import { HTMLAttributes, forwardRef, useCallback, useMemo, useState } from "react" +import { useEvent } from "react-use" import { Virtuoso } from "react-virtuoso" -import { ChevronDown } from "lucide-react" +import { ChevronDown, Skull } from "lucide-react" +import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo/schemas" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" +import { safeJsonParse } from "@roo/shared/safeJsonParse" + +import { vscode } from "@src/utils/vscode" import { useExtensionState } from "@src/context/ExtensionStateContext" import { cn } from "@src/lib/utils" +import { Button } from "@src/components/ui" interface CommandExecutionProps { + executionId?: string command: string output: string } -export const CommandExecution = forwardRef(({ command, output }, ref) => { - const { terminalShellIntegrationDisabled = false } = useExtensionState() +export const CommandExecution = forwardRef( + ({ executionId, command, output }, ref) => { + const { terminalShellIntegrationDisabled = false } = useExtensionState() - // If we aren't opening the VSCode terminal for this command then we default - // to expanding the command execution output. - const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) + // If we aren't opening the VSCode terminal for this command then we default + // to expanding the command execution output. + const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) - const lines = useMemo(() => output.split("\n"), [output]) + const [status, setStatus] = useState(null) - return ( -
-
setIsExpanded(!isExpanded)}> - {command} - + const lines = useMemo(() => output.split("\n").filter((line) => line.trim() !== ""), [output]) + + const onMessage = useCallback( + (event: MessageEvent) => { + if (!executionId) { + return + } + + const message: ExtensionMessage = event.data + + if (message.type === "commandExecutionStatus") { + const result = commandExecutionStatusSchema.safeParse(safeJsonParse(message.text, {})) + + if (result.success) { + if (result.data.executionId !== executionId) { + return + } + + if (result.data.status === "fallback") { + setIsExpanded(true) + } else { + setStatus(result.data) + } + } + } + }, + [executionId], + ) + + useEvent("message", onMessage) + + return ( +
+
+ {command} +
+ {status?.status === "running" && ( +
+
+
Running
+ {status.pid &&
(PID: {status.pid})
} + +
+ )} + {status?.status === "exited" && ( +
+
+
Exited ({status.exitCode})
+
+ )} + {lines.length > 0 && ( +
+ +
+ )} +
+
+
+ {lines.length > 0 && ( + {lines[i]}} + followOutput="auto" + /> + )} +
-
- {lines[i]}} - followOutput="auto" - /> -
-
- ) -}) + ) + }, +) type LineProps = HTMLAttributes diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx deleted file mode 100644 index 1fa5c831e3..0000000000 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// npx jest src/components/chat/__tests__/CommandExecution.test.tsx - -import React from "react" -import { render, screen } from "@testing-library/react" - -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" - -import { CommandExecution } from "../CommandExecution" - -jest.mock("@src/lib/utils", () => ({ - cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "), -})) - -jest.mock("lucide-react", () => ({ - ChevronDown: () =>
ChevronDown
, -})) - -jest.mock("react-virtuoso", () => ({ - Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => ( -
- {Array.from({ length: totalCount }).map((_, index) => ( -
- {itemContent(index)} -
- ))} -
- )), - VirtuosoHandle: jest.fn(), -})) - -describe("CommandExecution", () => { - const renderComponent = (command: string, output: string) => { - return render( - - - , - ) - } - - it("renders command output with virtualized list", () => { - const testOutput = "Line 1\nLine 2\nLine 3" - renderComponent("ls", testOutput) - expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument() - expect(screen.getByText("Line 1")).toBeInTheDocument() - expect(screen.getByText("Line 2")).toBeInTheDocument() - expect(screen.getByText("Line 3")).toBeInTheDocument() - }) - - it("handles empty output", () => { - renderComponent("ls", "") - expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument() - expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument() - expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument() - }) - - it("handles large output", () => { - const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n") - renderComponent("ls", largeOutput) - expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument() - expect(screen.getByText("Line 1")).toBeInTheDocument() - expect(screen.getByText("Line 1000")).toBeInTheDocument() - }) -}) diff --git a/webview-ui/src/components/common/CodeAccordian.tsx b/webview-ui/src/components/common/CodeAccordian.tsx index b6002eed97..e1ea77826e 100644 --- a/webview-ui/src/components/common/CodeAccordian.tsx +++ b/webview-ui/src/components/common/CodeAccordian.tsx @@ -117,7 +117,6 @@ const CodeAccordian = ({ )} {(!(path || isFeedback || isConsoleLogs) || isExpanded) && (
updateCodeBlockButtonPosition(true)} - onMouseUp={() => updateCodeBlockButtonPosition(false)} - // onScroll prop is removed - handled by the useEffect scroll listener now - > + onMouseUp={() => updateCodeBlockButtonPosition(false)}>
{!isSelecting && ( @@ -685,10 +687,7 @@ const CodeBlock = memo( // After UI updates, ensure code block is visible and update button position setTimeout( () => { - codeBlock.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }) + codeBlock.scrollIntoView({ behavior: "smooth", block: "nearest" }) // Wait for scroll to complete before updating button position setTimeout(() => { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 3a2cde6282..f3a1f69969 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -185,6 +185,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode (value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [], ) + const handleMessage = useCallback( (event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -352,8 +353,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode export const useExtensionState = () => { const context = useContext(ExtensionStateContext) + if (context === undefined) { throw new Error("useExtensionState must be used within an ExtensionStateContextProvider") } + return context }