mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
* 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>
250 lines
7.5 KiB
TypeScript
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),
|
|
}
|
|
}
|