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>
This commit is contained in:
RussellZager 2026-02-19 23:19:56 -08:00 committed by GitHub
parent 27095553ca
commit 618aa6652b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 289 additions and 31 deletions

20
pnpm-lock.yaml generated
View file

@ -1200,6 +1200,9 @@ importers:
'@vscode/webview-ui-toolkit':
specifier: ^1.4.0
version: 1.4.0(react@18.3.1)
ansi-to-html:
specifier: ^0.7.2
version: 0.7.2
axios:
specifier: ^1.12.0
version: 1.12.0
@ -4936,6 +4939,11 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
ansi-to-html@0.7.2:
resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==}
engines: {node: '>=8.0.0'}
hasBin: true
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@ -6229,6 +6237,9 @@ packages:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@ -6879,6 +6890,7 @@ packages:
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
global-agent@3.0.0:
@ -10030,7 +10042,7 @@ packages:
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
@ -15162,6 +15174,10 @@ snapshots:
ansi-styles@6.2.3: {}
ansi-to-html@0.7.2:
dependencies:
entities: 2.2.0
any-promise@1.3.0: {}
anymatch@3.1.3:
@ -16420,6 +16436,8 @@ snapshots:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@2.2.0: {}
entities@4.5.0: {}
entities@6.0.0: {}

View file

@ -1,4 +1,3 @@
import stripAnsi from "strip-ansi"
import * as vscode from "vscode"
import { inspect } from "util"
@ -245,7 +244,7 @@ export class TerminalProcess extends BaseTerminalProcess {
// command is finished, we still want to consider it 'hot' in case
// so that api request stalls to let diagnostics catch up").
this.stopHotTimer()
this.emit("completed", this.removeEscapeSequences(this.fullOutput))
this.emit("completed", this.stripCursorSequences(this.removeVSCodeShellIntegration(this.fullOutput)))
this.emit("continue")
}
@ -311,7 +310,7 @@ export class TerminalProcess extends BaseTerminalProcess {
outputToProcess = outputToProcess.slice(0, endIndex)
// Clean and return output
return this.removeEscapeSequences(outputToProcess)
return this.stripCursorSequences(this.removeVSCodeShellIntegration(outputToProcess))
}
private emitRemainingBufferIfListening() {
@ -375,17 +374,45 @@ export class TerminalProcess extends BaseTerminalProcess {
return data.slice(contentStart, endIndex)
}
// Removes ANSI escape sequences and VSCode-specific terminal control codes from output.
// While stripAnsi handles most ANSI codes, VSCode's shell integration adds custom
// escape sequences (OSC 633) that need special handling. These sequences control
// terminal features like marking command start/end and setting prompts.
//
// This method could be extended to handle other escape sequences, but any additions
// should be carefully considered to ensure they only remove control codes and don't
// alter the actual content or behavior of the output stream.
private removeEscapeSequences(str: string): string {
// eslint-disable-next-line no-control-regex
return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, ""))
/**
* Remove only VSCode shell integration sequences (OSC 633/133) while
* preserving standard ANSI SGR escape codes for color/formatting.
*
* VSCode shell integration uses OSC 633 and OSC 133 sequences to mark
* prompt boundaries, command starts/ends, etc. These are not useful
* for inline display and should be stripped.
*
* Standard ANSI SGR sequences (e.g., \x1B[32m for green) are preserved
* so the frontend can render them as styled HTML.
*/
private removeVSCodeShellIntegration(text: string): string {
// Remove OSC 633 sequences: \x1B]633;....\x07 or \x1B]633;....\x1B\\
// Remove OSC 133 sequences: \x1B]133;....\x07 or \x1B]133;....\x1B\\
return (
text
// eslint-disable-next-line no-control-regex
.replace(/\x1B\]633;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "")
// eslint-disable-next-line no-control-regex
.replace(/\x1B\]133;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "")
// eslint-disable-next-line no-control-regex
.replace(/\x1B\][0-9]+;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "")
) // Also remove other common OSC sequences that aren't color-related
}
private stripCursorSequences(text: string): string {
return (
text
// eslint-disable-next-line no-control-regex
.replace(/\x1B\[\d*[ABCDEFGHJ]/g, "") // Remove cursor movement: up, down, forward, back
// eslint-disable-next-line no-control-regex
.replace(/\x1B\[su/g, "") // Remove cursor position save/restore
// eslint-disable-next-line no-control-regex
.replace(/\x1B\[\d*[KJ]/g, "") // Remove erase in line/display
// eslint-disable-next-line no-control-regex
.replace(/\x1B\[\?25[hl]/g, "") // Remove cursor show/hide
// eslint-disable-next-line no-control-regex
.replace(/\x1B\[\d*;\d*r/g, "") // Remove scroll region
)
}
/**

View file

@ -0,0 +1,87 @@
import * as vscode from "vscode"
import { TerminalProcess } from "../TerminalProcess"
import { Terminal } from "../Terminal"
// Mock dependencies
vi.mock("vscode", () => ({
window: {
createTerminal: vi.fn(),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({
get: vi.fn(),
}),
},
ThemeIcon: vi.fn(),
}))
describe("TerminalProcess ANSI Handling", () => {
let terminalProcess: any // Using any to access private methods
let mockTerminal: any
beforeEach(() => {
mockTerminal = {
shellIntegration: {
executeCommand: vi.fn(),
},
name: "Test Terminal",
processId: Promise.resolve(123),
creationOptions: {},
exitStatus: undefined,
state: { isInteractedWith: true },
dispose: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
sendText: vi.fn(),
}
const terminalInfo = new Terminal(1, mockTerminal, "/tmp")
terminalProcess = new TerminalProcess(terminalInfo)
})
describe("removeVSCodeShellIntegration", () => {
it("should preserve standard ANSI SGR sequences", () => {
const input = "\x1B[32mgreen text\x1B[0m"
const result = terminalProcess.removeVSCodeShellIntegration(input)
expect(result).toBe("\x1B[32mgreen text\x1B[0m")
})
it("should remove OSC 633 sequences", () => {
const input = "\x1B]633;A\x07some text"
const result = terminalProcess.removeVSCodeShellIntegration(input)
expect(result).toBe("some text")
})
it("should remove OSC 133 sequences", () => {
const input = "\x1B]133;A\x07some text"
const result = terminalProcess.removeVSCodeShellIntegration(input)
expect(result).toBe("some text")
})
it("should handle mixed sequences", () => {
const input = "\x1B]633;C\x07\x1B[1m\x1B[32m✓\x1B[39m\x1B[22m test passed"
const result = terminalProcess.removeVSCodeShellIntegration(input)
expect(result).toBe("\x1B[1m\x1B[32m✓\x1B[39m\x1B[22m test passed")
})
it("should remove other OSC sequences", () => {
const input = "\x1B]0;Console Title\x07Content"
const result = terminalProcess.removeVSCodeShellIntegration(input)
expect(result).toBe("Content")
})
})
describe("stripCursorSequences", () => {
it("should remove cursor movement codes", () => {
const input = "text\x1B[1Aup\x1B[2Kclear"
const result = terminalProcess.stripCursorSequences(input)
expect(result).toBe("textupclear")
})
it("should preserve colors while removing cursor codes", () => {
const input = "\x1B[31mred\x1B[1B\x1B[32mgreen"
const result = terminalProcess.stripCursorSequences(input)
expect(result).toBe("\x1B[31mred\x1B[32mgreen")
})
})
})

View file

@ -354,9 +354,12 @@ describe("TerminalProcess with Bash Command Output", () => {
expect(capturedOutput).toBe("Red Text\r\n")
} else {
// Use printf instead of echo -e for more consistent behavior across platforms
// Note: ANSI escape sequences are stripped in the output processing
const { capturedOutput } = await testTerminalCommand('printf "\\033[31mRed Text\\033[0m\\n"', "Red Text\n")
expect(capturedOutput).toBe("Red Text\n")
// Note: ANSI escape sequences are now preserved in the output processing
const { capturedOutput } = await testTerminalCommand(
'printf "\\033[31mRed Text\\033[0m\\n"',
"\x1B[31mRed Text\x1B[0m\n",
)
expect(capturedOutput).toBe("\x1B[31mRed Text\x1B[0m\n")
}
})

View file

@ -35,6 +35,7 @@
"@tanstack/react-query": "^5.68.0",
"@vscode/codicons": "^0.0.36",
"@vscode/webview-ui-toolkit": "^1.4.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -55,8 +56,8 @@
"posthog-js": "^1.227.2",
"pretty-bytes": "^7.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-compiler-runtime": "^1.0.0",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-markdown": "^9.0.3",
@ -84,7 +85,6 @@
"zod": "^3.25.61"
},
"devDependencies": {
"babel-plugin-react-compiler": "^1.0.0",
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@testing-library/jest-dom": "^6.6.3",
@ -101,6 +101,7 @@
"@types/vscode-webview": "^1.57.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^3.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^26.0.0",
"vite": "6.3.6",

View file

@ -18,6 +18,7 @@ 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
@ -225,7 +226,7 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean;
"max-h-0": !isExpanded,
"max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded,
})}>
{output.length > 0 && <CodeBlock source={output} language="log" />}
{output.length > 0 && <TerminalOutput content={output} />}
</div>
)

View file

@ -0,0 +1,77 @@
import React, { useMemo } from "react"
import Convert from "ansi-to-html"
interface TerminalOutputProps {
content: string
className?: string
}
// Create a single converter instance with sensible defaults
const converter = new Convert({
fg: "var(--vscode-terminal-foreground, #cccccc)",
bg: "var(--vscode-terminal-background, transparent)",
// Map ANSI colors to VSCode terminal color CSS variables for theme compatibility
colors: {
0: "var(--vscode-terminal-ansiBlack, #000000)",
1: "var(--vscode-terminal-ansiRed, #cd3131)",
2: "var(--vscode-terminal-ansiGreen, #0dbc79)",
3: "var(--vscode-terminal-ansiYellow, #e5e510)",
4: "var(--vscode-terminal-ansiBlue, #2472c8)",
5: "var(--vscode-terminal-ansiMagenta, #bc3fbc)",
6: "var(--vscode-terminal-ansiCyan, #11a8cd)",
7: "var(--vscode-terminal-ansiWhite, #e5e5e5)",
8: "var(--vscode-terminal-ansiBrightBlack, #666666)",
9: "var(--vscode-terminal-ansiBrightRed, #f14c4c)",
10: "var(--vscode-terminal-ansiBrightGreen, #23d18b)",
11: "var(--vscode-terminal-ansiBrightYellow, #f5f543)",
12: "var(--vscode-terminal-ansiBrightBlue, #3b8eea)",
13: "var(--vscode-terminal-ansiBrightMagenta, #d670d6)",
14: "var(--vscode-terminal-ansiBrightCyan, #29b8db)",
15: "var(--vscode-terminal-ansiBrightWhite, #e5e5e5)",
},
escapeXML: true, // Prevent XSS — escape HTML entities in the content
newline: false, // We handle newlines ourselves via <pre>
})
/**
* Renders terminal output with ANSI color/formatting support.
*
* Uses ansi-to-html to convert ANSI escape sequences into styled <span> elements.
* Colors are mapped to VSCode terminal theme CSS variables for consistent theming.
*
* The component uses a monospace font and preserves whitespace/newlines
* to match terminal rendering behavior.
*/
export const TerminalOutput: React.FC<TerminalOutputProps> = ({ content, className }) => {
const html = useMemo(() => {
try {
return converter.toHtml(content)
} catch {
// Fallback: if conversion fails, show raw text (stripped of ANSI)
// eslint-disable-next-line no-control-regex
return content.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")
}
}, [content])
return (
<pre
className={className}
style={{
fontFamily:
"var(--vscode-editor-font-family, 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', 'Monaco', 'Courier New', monospace)",
fontSize: "var(--vscode-editor-font-size, 13px)",
lineHeight: "var(--vscode-editor-line-height, 1.4)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
margin: 0,
padding: "8px 12px",
backgroundColor: "var(--vscode-terminal-background, transparent)",
color: "var(--vscode-terminal-foreground, inherit)",
overflow: "auto",
// Support Unicode box-drawing characters and extended ASCII
unicodeBidi: "embed",
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}

View file

@ -23,6 +23,11 @@ vi.mock("../../common/CodeBlock", () => ({
default: ({ source }: { source: string }) => <div data-testid="code-block">{source}</div>,
}))
// Mock TerminalOutput
vi.mock("../TerminalOutput", () => ({
TerminalOutput: ({ content }: { content: string }) => <div data-testid="terminal-output">{content}</div>,
}))
vi.mock("../CommandPatternSelector", () => ({
CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
<div data-testid="command-pattern-selector">
@ -72,6 +77,9 @@ describe("CommandExecution", () => {
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks[0]).toHaveTextContent("npm install")
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toHaveTextContent("Installing packages...")
})
it("should render with custom icon and title", () => {
@ -230,7 +238,9 @@ Suggested patterns: npm, npm install, npm run`
// First check that the command was parsed correctly
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks[0]).toHaveTextContent("npm install")
expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run")
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toHaveTextContent("Suggested patterns: npm, npm install, npm run")
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
@ -292,8 +302,10 @@ Output here`
// Output should be visible when shell integration is disabled
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks).toHaveLength(2) // Command and output blocks
expect(codeBlocks[1]).toHaveTextContent("Output here")
expect(codeBlocks).toHaveLength(1) // Only command block
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toHaveTextContent("Output here")
})
it("should handle undefined allowedCommands and deniedCommands", () => {
@ -563,9 +575,10 @@ Output:
// Should show a command pattern
expect(selector.textContent).toMatch(/wc/)
// The output should still be displayed in the code block
expect(codeBlocks.length).toBeGreaterThan(1)
expect(codeBlocks[1].textContent).toContain("45 total")
// The output should still be displayed
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toBeInTheDocument()
expect(terminalOutput.textContent).toContain("45 total")
})
it("should handle commands with zero output", () => {
@ -586,10 +599,10 @@ Output:
// Should show a command pattern
expect(selector.textContent).toMatch(/wc/)
// The output should still be displayed in the code block
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks.length).toBeGreaterThan(1)
expect(codeBlocks[1]).toHaveTextContent("0 total")
// The output should still be displayed
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toBeInTheDocument()
expect(terminalOutput).toHaveTextContent("0 total")
})
})
})

View file

@ -0,0 +1,31 @@
import { render } from "@testing-library/react";
import { TerminalOutput } from "../TerminalOutput";
describe("TerminalOutput", () => {
it("renders plain text without ANSI codes", () => {
const { container } = render(<TerminalOutput content="hello world" />);
expect(container.textContent).toBe("hello world");
});
it("converts ANSI color codes to styled spans", () => {
const { container } = render(
<TerminalOutput content={"\x1B[32mgreen\x1B[0m"} />
);
const span = container.querySelector("span");
expect(span).toBeTruthy();
expect(span?.textContent).toBe("green");
});
it("escapes HTML in terminal output to prevent XSS", () => {
const { container } = render(
<TerminalOutput content={'<script>alert("xss")</script>'} />
);
expect(container.innerHTML).not.toContain("<script>");
expect(container.textContent).toContain('<script>alert("xss")</script>');
});
it("handles empty content", () => {
const { container } = render(<TerminalOutput content="" />);
expect(container.textContent).toBe("");
});
});