Просмотр исходного кода

Improve command execution component (#3057)

Chris Estreich 8 месяцев назад
Родитель
Сommit
1d7569b14c

+ 5 - 0
.changeset/cyan-countries-sniff.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Add a kill command button to the execute command component

+ 3 - 3
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) {

+ 19 - 3
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") {

+ 2 - 0
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
 					  }

+ 2 - 0
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
 					  }

+ 2 - 1
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<string> | undefined): void {
+	public setActiveStream(stream: AsyncIterable<string> | 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

+ 1 - 0
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<void>((resolve, reject) => {

+ 2 - 5
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 })
 			}

+ 1 - 0
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))
 

+ 2 - 4
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,

+ 5 - 8
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<string> | undefined): void
+	setActiveStream(stream: AsyncIterable<string> | 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<string>]
+	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<string>]
 }
 
 export interface ExitCodeDetails {

+ 24 - 0
src/schemas/index.ts

@@ -286,6 +286,29 @@ export const customSupportPromptsSchema = z.record(z.string(), z.string().option
 
 export type CustomSupportPrompts = z.infer<typeof customSupportPromptsSchema>
 
+/**
+ * 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<typeof commandExecutionStatusSchema>
+
 /**
  * ExperimentId
  */
@@ -784,6 +807,7 @@ export type ClineSay = z.infer<typeof clineSaySchema>
  */
 
 export const toolProgressStatusSchema = z.object({
+	id: z.string().optional(),
 	icon: z.string().optional(),
 	text: z.string().optional(),
 })

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -67,6 +67,7 @@ export interface ExtensionMessage {
 		| "toggleApiConfigPin"
 		| "acceptInput"
 		| "setHistoryPreviewCollapsed"
+		| "commandExecutionStatus"
 	text?: string
 	action?:
 		| "chatButtonClicked"

+ 5 - 1
webview-ui/src/components/chat/ChatRow.tsx

@@ -987,7 +987,11 @@ export const ChatRowContent = ({
 								{icon}
 								{title}
 							</div>
-							<CommandExecution command={command} output={output} />
+							<CommandExecution
+								executionId={message.progressStatus?.id}
+								command={command}
+								output={output}
+							/>
 						</>
 					)
 				case "use_mcp_server":

+ 107 - 123
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<ChatViewRef, ChatViewPro
 					}
 					break
 				case "say":
-					// don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
+					// Don't want to reset since there could be a "say" after
+					// an "ask" while ask is waiting for response.
 					switch (lastMessage.say) {
 						case "api_req_retry_delayed":
 							setTextAreaDisabled(true)
@@ -301,13 +303,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 					}
 					break
 			}
-		} else {
-			// this would get called after sending the first message, so we have to watch messages.length instead
-			// No messages, so user has to submit a task
-			// setTextAreaDisabled(false)
-			// setClineAsk(undefined)
-			// setPrimaryButtonText(undefined)
-			// setSecondaryButtonText(undefined)
 		}
 	}, [lastMessage, secondLastMessage])
 
@@ -321,23 +316,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		}
 	}, [messages.length])
 
-	useEffect(() => {
-		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<ChatViewRef, ChatViewPro
 				lastApiReqStarted.say === "api_req_started"
 			) {
 				const cost = JSON.parse(lastApiReqStarted.text).cost
+
 				if (cost === undefined) {
-					// api request has not finished yet
-					return true
+					return true // API request has not finished yet.
 				}
 			}
 		}
@@ -371,6 +375,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	const handleSendMessage = useCallback(
 		(text: string, images: string[]) => {
 			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<ChatViewRef, ChatViewPro
 						// There is no other case that a textfield should be enabled.
 					}
 				}
+
 				handleChatReset()
 			}
 		},
@@ -412,16 +418,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		[inputValue, selectedImages],
 	)
 
-	const startNewTask = useCallback(() => {
-		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<ChatViewRef, ChatViewPro
 					vscode.postMessage({ type: "terminalOperation", terminalOperation: "continue" })
 					break
 			}
+
 			setTextAreaDisabled(true)
 			setClineAsk(undefined)
 			setEnableButtons(false)
@@ -508,15 +514,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		[clineAsk, startNewTask, isStreaming],
 	)
 
-	const handleTaskCloseButtonClick = useCallback(() => {
-		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<ChatViewRef, ChatViewPro
 	const handleMessage = useCallback(
 		(e: MessageEvent) => {
 			const message: ExtensionMessage = e.data
+
 			switch (message.type) {
 				case "action":
 					switch (message.action!) {
@@ -564,7 +567,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 							break
 					}
 			}
-			// textAreaRef.current is not explicitly required here since react gaurantees that ref will be stable across re-renders, and we're not using its value but its reference.
+			// textAreaRef.current is not explicitly required here since React
+			// guarantees that ref will be stable across re-renders, and we're
+			// not using its value but its reference.
 		},
 		[
 			isHidden,
@@ -580,10 +585,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 
 	useEvent("message", handleMessage)
 
-	useMount(() => {
-		// 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<ChatViewRef, ChatViewPro
 				textAreaRef.current?.focus()
 			}
 		}, 50)
+
 		return () => {
 			clearTimeout(timer)
 		}
@@ -600,23 +604,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		return modifiedMessages.filter((message) => {
 			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<ChatViewRef, ChatViewPro
 					}
 					return message === last1
 				case "text":
-					// Sometimes cline returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that)
+					// Sometimes cline returns an empty text message, we don't
+					// want to render these. (We also use a say text for user
+					// messages, so in case they just sent images we still
+					// render that.)
 					if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
 						return false
 					}
@@ -641,7 +653,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			if (!message.text) {
 				return true
 			}
+
 			const tool = JSON.parse(message.text)
+
 			return [
 				"readFile",
 				"listFiles",
@@ -651,6 +665,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				"searchFiles",
 			].includes(tool.tool)
 		}
+
 		return false
 	}, [])
 
@@ -659,7 +674,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			if (!message.text) {
 				return true
 			}
+
 			const tool = JSON.parse(message.text)
+
 			return [
 				"editedExistingFile",
 				"appliedDiff",
@@ -668,6 +685,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				"insertContent",
 			].includes(tool.tool)
 		}
+
 		return false
 	}, [])
 
@@ -677,19 +695,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				if (!message.text) {
 					return true
 				}
+
 				const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
+
 				if (mcpServerUse.type === "use_mcp_tool") {
 					const server = mcpServers?.find((s: McpServer) => 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<ChatViewRef, ChatViewPro
 			// For read/write operations, check if it's outside workspace and if we have permission for that
 			if (message.ask === "tool") {
 				let tool: any = {}
+
 				try {
 					tool = JSON.parse(message.text || "{}")
 				} catch (error) {
@@ -731,6 +753,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 					if (tool.content === "create_mode") {
 						return alwaysAllowModeSwitch
 					}
+
 					if (tool.content === "create_mcp_server") {
 						return alwaysAllowMcp
 					}
@@ -776,9 +799,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	)
 
 	useEffect(() => {
-		// 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<ChatViewRef, ChatViewPro
 			}
 		}
 
-		// Only execute when isStreaming changes from true to false
+		// Only execute when isStreaming changes from true to false.
 		if (wasStreaming && !isStreaming && lastMessage) {
-			// Play appropriate sound based on lastMessage content
+			// Play appropriate sound based on lastMessage content.
 			if (lastMessage.type === "ask") {
 				// Don't play sounds for auto-approved actions
 				if (!isAutoApproved(lastMessage)) {
@@ -834,18 +857,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				}
 			}
 		}
-		// Update previous value
+
+		// Update previous value.
 		setWasStreaming(isStreaming)
 	}, [isStreaming, lastMessage, wasStreaming, isAutoApproved, messages.length])
 
 	const isBrowserSessionMessage = (message: ClineMessage): boolean => {
-		// 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<ChatViewRef, ChatViewPro
 
 		visibleMessages.forEach((message) => {
 			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<ChatViewRef, ChatViewPro
 
 	const scrollToBottomSmooth = useMemo(
 		() =>
-			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<ChatViewRef, ChatViewPro
 				lastGroup?.say === "api_req_started" &&
 				!expandedRows[lastGroup.ts]
 
-			setExpandedRows((prev) => ({
-				...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<ChatViewRef, ChatViewPro
 							align: "start",
 						})
 					}, 0)
+
 					return () => clearTimeout(timer)
 				}
 			}
@@ -999,9 +1017,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				if (isTaller) {
 					scrollToBottomSmooth()
 				} else {
-					setTimeout(() => {
-						scrollToBottomAuto()
-					}, 0)
+					setTimeout(() => scrollToBottomAuto(), 0)
 				}
 			}
 		},
@@ -1010,22 +1026,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 
 	useEffect(() => {
 		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<ChatViewRef, ChatViewPro
 		}
 	}, [modifiedMessages.length, isStreaming])
 
-	// Checkpoint warning component
-	const CheckpointWarningMessage = useCallback(
-		() => (
-			<div className="flex items-center p-3 my-3 bg-vscode-inputValidation-warningBackground border border-vscode-inputValidation-warningBorder rounded">
-				<span className="codicon codicon-loading codicon-modifier-spin mr-2" />
-				<span className="text-vscode-foreground">
-					<Trans
-						i18nKey="chat:checkpoint.initializingWarning"
-						components={{
-							settingsLink: (
-								<VSCodeLink
-									href="#"
-									onClick={(e) => {
-										e.preventDefault()
-										window.postMessage(
-											{
-												type: "action",
-												action: "settingsButtonClicked",
-												values: { section: "checkpoints" },
-											},
-											"*",
-										)
-									}}
-									className="inline px-0.5"
-								/>
-							),
-						}}
-					/>
-				</span>
-			</div>
-		),
-		[],
-	)
-
 	const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask")
 
 	const itemContent = useCallback(
@@ -1142,15 +1125,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	)
 
 	useEffect(() => {
-		// 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<ChatViewRef, ChatViewPro
 						onClose={handleTaskCloseButtonClick}
 					/>
 
-					{/* System prompt override warning */}
 					{hasSystemPromptOverride && (
 						<div className="px-3">
 							<SystemPromptWarning />
 						</div>
 					)}
 
-					{/* Checkpoint warning message */}
 					{showCheckpointWarning && (
 						<div className="px-3">
-							<CheckpointWarningMessage />
+							<CheckpointWarning />
 						</div>
 					)}
 				</>

+ 34 - 0
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 (
+		<div className="flex items-center p-3 my-3 bg-vscode-inputValidation-warningBackground border border-vscode-inputValidation-warningBorder rounded">
+			<span className="codicon codicon-loading codicon-modifier-spin mr-2" />
+			<span className="text-vscode-foreground">
+				<Trans
+					i18nKey="chat:checkpoint.initializingWarning"
+					components={{
+						settingsLink: (
+							<VSCodeLink
+								href="#"
+								onClick={(e) => {
+									e.preventDefault()
+									window.postMessage(
+										{
+											type: "action",
+											action: "settingsButtonClicked",
+											values: { section: "checkpoints" },
+										},
+										"*",
+									)
+								}}
+								className="inline px-0.5"
+							/>
+						),
+					}}
+				/>
+			</span>
+		</div>
+	)
+}

+ 101 - 28
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<HTMLDivElement, CommandExecutionProps>(({ command, output }, ref) => {
-	const { terminalShellIntegrationDisabled = false } = useExtensionState()
+export const CommandExecution = forwardRef<HTMLDivElement, CommandExecutionProps>(
+	({ 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<CommandExecutionStatus | null>(null)
 
-	return (
-		<div ref={ref} className="w-full p-2 rounded-xs bg-vscode-editor-background">
-			<div
-				className={cn("flex flex-row justify-between cursor-pointer active:opacity-75", {
-					"opacity-50": isExpanded,
-				})}
-				onClick={() => setIsExpanded(!isExpanded)}>
-				<Line>{command}</Line>
-				<ChevronDown className={cn("size-4 transition-transform duration-300", { "rotate-180": isExpanded })} />
-			</div>
-			<div className={cn("h-[200px]", { hidden: !isExpanded })}>
-				<Virtuoso
-					className="h-full mt-2"
-					totalCount={lines.length}
-					itemContent={(i) => <Line>{lines[i]}</Line>}
-					followOutput="auto"
-				/>
+		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 (
+			<div ref={ref} className="w-full p-2 rounded-xs bg-vscode-editor-background">
+				<div className="flex flex-row justify-between">
+					<Line>{command}</Line>
+					<div>
+						{status?.status === "running" && (
+							<div className="flex flex-row items-center gap-2 shrink-0 font-mono text-sm">
+								<div className="rounded-full size-1.5 bg-lime-400" />
+								<div>Running</div>
+								{status.pid && <div>(PID: {status.pid})</div>}
+								<Button
+									variant="ghost"
+									size="icon"
+									onClick={() =>
+										vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
+									}>
+									<Skull />
+								</Button>
+							</div>
+						)}
+						{status?.status === "exited" && (
+							<div className="flex flex-row items-center gap-2 shrink-0 font-mono text-sm">
+								<div className="rounded-full size-1.5 bg-red-400" />
+								<div>Exited ({status.exitCode})</div>
+							</div>
+						)}
+						{lines.length > 0 && (
+							<div className="flex flex-row items-center justify-end gap-2">
+								<Button variant="ghost" size="sm" onClick={() => setIsExpanded(!isExpanded)}>
+									<div>Output</div>
+									<ChevronDown
+										className={cn("size-4 transition-transform duration-300", {
+											"rotate-180": isExpanded,
+										})}
+									/>
+								</Button>
+							</div>
+						)}
+					</div>
+				</div>
+				<div className={cn("h-[200px] mt-1 pt-1 border-t border-border/25", { hidden: !isExpanded })}>
+					{lines.length > 0 && (
+						<Virtuoso
+							className="h-full"
+							totalCount={lines.length}
+							itemContent={(i) => <Line className="text-sm">{lines[i]}</Line>}
+							followOutput="auto"
+						/>
+					)}
+				</div>
 			</div>
-		</div>
-	)
-})
+		)
+	},
+)
 
 type LineProps = HTMLAttributes<HTMLDivElement>
 

+ 0 - 63
webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx

@@ -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: () => <div data-testid="chevron-down">ChevronDown</div>,
-}))
-
-jest.mock("react-virtuoso", () => ({
-	Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => (
-		<div ref={ref} data-testid="virtuoso-container">
-			{Array.from({ length: totalCount }).map((_, index) => (
-				<div key={index} data-testid={`virtuoso-item-${index}`}>
-					{itemContent(index)}
-				</div>
-			))}
-		</div>
-	)),
-	VirtuosoHandle: jest.fn(),
-}))
-
-describe("CommandExecution", () => {
-	const renderComponent = (command: string, output: string) => {
-		return render(
-			<ExtensionStateContextProvider>
-				<CommandExecution command={command} output={output} />
-			</ExtensionStateContextProvider>,
-		)
-	}
-
-	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()
-	})
-})

+ 0 - 1
webview-ui/src/components/common/CodeAccordian.tsx

@@ -117,7 +117,6 @@ const CodeAccordian = ({
 			)}
 			{(!(path || isFeedback || isConsoleLogs) || isExpanded) && (
 				<div
-					//className="code-block-scrollable" this doesn't seem to be necessary anymore, on silicon macs it shows the native mac scrollbar instead of the vscode styled one
 					style={{
 						overflowX: "auto",
 						overflowY: "hidden",

+ 6 - 7
webview-ui/src/components/common/CodeBlock.tsx

@@ -598,6 +598,10 @@ const CodeBlock = memo(
 			[source, rawSource, copyWithFeedback],
 		)
 
+		if (source?.length === 0) {
+			return null
+		}
+
 		return (
 			<CodeBlockContainer ref={codeBlockRef}>
 				<StyledPre
@@ -607,9 +611,7 @@ const CodeBlock = memo(
 					windowshade={windowShade ? "true" : "false"}
 					collapsedHeight={collapsedHeight}
 					onMouseDown={() => updateCodeBlockButtonPosition(true)}
-					onMouseUp={() => updateCodeBlockButtonPosition(false)}
-					// onScroll prop is removed - handled by the useEffect scroll listener now
-				>
+					onMouseUp={() => updateCodeBlockButtonPosition(false)}>
 					<div dangerouslySetInnerHTML={{ __html: highlightedCode }} />
 				</StyledPre>
 				{!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(() => {

+ 3 - 0
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
 }