Improve command execution component (#3057)

This commit is contained in:
Chris Estreich 2025-04-29 23:56:59 -07:00 committed by GitHub
parent 7863303aac
commit 1d7569b14c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 325 additions and 247 deletions

View file

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

View file

@ -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) {

View file

@ -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") {

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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) => {

View file

@ -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 })
}

View file

@ -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))

View file

@ -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,

View file

@ -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 {

View file

@ -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(),
})

View file

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

View file

@ -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":

View file

@ -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>
)}
</>

View file

@ -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>
)
}

View file

@ -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 })} />
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 className={cn("h-[200px]", { hidden: !isExpanded })}>
<Virtuoso
className="h-full mt-2"
totalCount={lines.length}
itemContent={(i) => <Line>{lines[i]}</Line>}
followOutput="auto"
/>
</div>
</div>
)
})
)
},
)
type LineProps = HTMLAttributes<HTMLDivElement>

View file

@ -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()
})
})

View file

@ -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",

View file

@ -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(() => {

View file

@ -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
}