Roo-Code/webview-ui/src/components/chat/CommandExecution.tsx
RussellZager 618aa6652b
Inline terminal rendering parity with the VSCode Terminal (#11361)
* fix: render ANSI escape codes in inline terminal output

Fixes #10699

## Problem
The inline terminal output displayed raw ANSI bracket codes ([1m, [32m, etc.)
instead of rendering colors and formatting. This was caused by:
1. Backend: strip-ansi removing the ESC byte but leaving bracket remnants
2. Frontend: CodeBlock/Shiki having no ANSI rendering capability

## Solution
1. Backend: Replace strip-ansi with targeted removal of only VSCode shell
   integration sequences (OSC 633/133), preserving standard ANSI SGR codes
2. Frontend: Add new TerminalOutput component using ansi-to-html library
   that converts ANSI sequences to styled HTML spans
3. Map ANSI colors to VSCode terminal theme CSS variables for consistent
   theming across light/dark themes

## Testing
- Verified XSS prevention (escapeXML: true)
- Verified theme compatibility
- Added unit tests for both backend and frontend changes
- Updated existing tests to expect ANSI codes in output

Bundle size impact: ~3KB gzipped (ansi-to-html library)

Co-authored-by: Zman771 <605281+Zman771@users.noreply.github.com>

* fix: add eslint-disable for intentional ANSI control regex

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Zman771 <605281+Zman771@users.noreply.github.com>
Co-authored-by: Russell Zager <rzager@google.com>
2026-02-20 00:19:56 -07:00

250 lines
7.5 KiB
TypeScript

import { useCallback, useState, memo, useMemo } from "react"
import { useEvent } from "react-use"
import { t } from "i18next"
import { ChevronDown, OctagonX } from "lucide-react"
import { type ExtensionMessage, type CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types"
import { safeJsonParse } from "@roo/core"
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
import { parseCommand } from "@roo/parse-command"
import { vscode } from "@src/utils/vscode"
import { extractPatternsFromCommand } from "@src/utils/command-parser"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { cn } from "@src/lib/utils"
import { Button, StandardTooltip } from "@src/components/ui"
import CodeBlock from "@src/components/common/CodeBlock"
import { CommandPatternSelector } from "./CommandPatternSelector"
import { TerminalOutput } from "./TerminalOutput"
interface CommandPattern {
pattern: string
description?: string
}
interface CommandExecutionProps {
executionId: string
text?: string
icon?: JSX.Element | null
title?: JSX.Element | null
}
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
const {
terminalShellIntegrationDisabled = false,
allowedCommands = [],
deniedCommands = [],
setAllowedCommands,
setDeniedCommands,
} = useExtensionState()
const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text])
// 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 [streamingOutput, setStreamingOutput] = useState("")
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
// The command's output can either come from the text associated with the
// task message (this is the case for completed commands) or from the
// streaming output (this is the case for running commands).
const output = streamingOutput || parsedOutput
// Extract command patterns from the actual command that was executed
const commandPatterns = useMemo<CommandPattern[]>(() => {
// First get all individual commands (including subshell commands) using parseCommand
const allCommands = parseCommand(command)
// Then extract patterns from each command using the existing pattern extraction logic
const allPatterns = new Set<string>()
// Add all individual commands first
allCommands.forEach((cmd) => {
if (cmd.trim()) {
allPatterns.add(cmd.trim())
}
})
// Then add extracted patterns for each command
allCommands.forEach((cmd) => {
const patterns = extractPatternsFromCommand(cmd)
patterns.forEach((pattern) => allPatterns.add(pattern))
})
return Array.from(allPatterns).map((pattern) => ({
pattern,
}))
}, [command])
// Handle pattern changes
const handleAllowPatternChange = (pattern: string) => {
const isAllowed = allowedCommands.includes(pattern)
const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern]
const newDenied = deniedCommands.filter((p) => p !== pattern)
setAllowedCommands(newAllowed)
setDeniedCommands(newDenied)
vscode.postMessage({
type: "updateSettings",
updatedSettings: { allowedCommands: newAllowed, deniedCommands: newDenied },
})
}
const handleDenyPatternChange = (pattern: string) => {
const isDenied = deniedCommands.includes(pattern)
const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern]
const newAllowed = allowedCommands.filter((p) => p !== pattern)
setAllowedCommands(newAllowed)
setDeniedCommands(newDenied)
vscode.postMessage({
type: "updateSettings",
updatedSettings: { allowedCommands: newAllowed, deniedCommands: newDenied },
})
}
const onMessage = useCallback(
(event: MessageEvent) => {
const message: ExtensionMessage = event.data
if (message.type === "commandExecutionStatus") {
const result = commandExecutionStatusSchema.safeParse(safeJsonParse(message.text, {}))
if (result.success) {
const data = result.data
if (data.executionId !== executionId) {
return
}
switch (data.status) {
case "started":
setStatus(data)
break
case "output":
setStreamingOutput(data.output)
break
case "fallback":
setIsExpanded(true)
break
default:
setStatus(data)
break
}
}
}
},
[executionId],
)
useEvent("message", onMessage)
return (
<>
<div className="flex flex-row items-center justify-between gap-2 mb-1">
<div className="flex flex-row items-center gap-2">
{icon}
{title}
{status?.status === "exited" && (
<div className="flex flex-row items-center gap-2 font-mono text-xs">
<StandardTooltip
content={t("chat.commandExecution.exitStatus", { exitStatus: status.exitCode })}>
<div
className={cn(
"rounded-full size-2",
status.exitCode === 0 ? "bg-green-600" : "bg-red-600",
)}
/>
</StandardTooltip>
</div>
)}
</div>
<div className=" flex flex-row items-center justify-between gap-2 px-1">
<div className="flex flex-row items-center gap-1">
{status?.status === "started" && (
<div className="flex flex-row items-center gap-2 font-mono text-xs">
{status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
<StandardTooltip content={t("chat:commandExecution.abort")}>
<Button
variant="ghost"
size="icon"
onClick={() =>
vscode.postMessage({
type: "terminalOperation",
terminalOperation: "abort",
})
}>
<OctagonX className="size-4" />
</Button>
</StandardTooltip>
</div>
)}
{output.length > 0 && (
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
<ChevronDown
className={cn(
"size-4 transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
</Button>
)}
</div>
</div>
</div>
<div className="bg-vscode-editor-background border border-vscode-border rounded-xs ml-6 mt-2">
<div className="p-2">
<CodeBlock source={command} language="shell" />
<OutputContainer isExpanded={isExpanded} output={output} />
</div>
{command && command.trim() && (
<CommandPatternSelector
patterns={commandPatterns}
allowedCommands={allowedCommands}
deniedCommands={deniedCommands}
onAllowPatternChange={handleAllowPatternChange}
onDenyPatternChange={handleDenyPatternChange}
/>
)}
</div>
</>
)
}
CommandExecution.displayName = "CommandExecution"
const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
<div
className={cn("overflow-hidden", {
"max-h-0": !isExpanded,
"max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded,
})}>
{output.length > 0 && <TerminalOutput content={output} />}
</div>
)
const OutputContainer = memo(OutputContainerInternal)
const parseCommandAndOutput = (text: string | undefined) => {
if (!text) {
return { command: "", output: "" }
}
const index = text.indexOf(COMMAND_OUTPUT_STRING)
if (index === -1) {
return { command: text, output: "" }
}
return {
command: text.slice(0, index),
output: text.slice(index + COMMAND_OUTPUT_STRING.length),
}
}