Skip to content

Commit 618aa66

Browse files
RussellZagergoogle-labs-jules[bot]rzager-wq
authored
Inline terminal rendering parity with the VSCode Terminal (RooCodeInc#11361)
* fix: render ANSI escape codes in inline terminal output Fixes RooCodeInc#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>
1 parent 2709555 commit 618aa66

9 files changed

Lines changed: 289 additions & 31 deletions

File tree

pnpm-lock.yaml

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/integrations/terminal/TerminalProcess.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import stripAnsi from "strip-ansi"
21
import * as vscode from "vscode"
32
import { inspect } from "util"
43

@@ -245,7 +244,7 @@ export class TerminalProcess extends BaseTerminalProcess {
245244
// command is finished, we still want to consider it 'hot' in case
246245
// so that api request stalls to let diagnostics catch up").
247246
this.stopHotTimer()
248-
this.emit("completed", this.removeEscapeSequences(this.fullOutput))
247+
this.emit("completed", this.stripCursorSequences(this.removeVSCodeShellIntegration(this.fullOutput)))
249248
this.emit("continue")
250249
}
251250

@@ -311,7 +310,7 @@ export class TerminalProcess extends BaseTerminalProcess {
311310
outputToProcess = outputToProcess.slice(0, endIndex)
312311

313312
// Clean and return output
314-
return this.removeEscapeSequences(outputToProcess)
313+
return this.stripCursorSequences(this.removeVSCodeShellIntegration(outputToProcess))
315314
}
316315

317316
private emitRemainingBufferIfListening() {
@@ -375,17 +374,45 @@ export class TerminalProcess extends BaseTerminalProcess {
375374
return data.slice(contentStart, endIndex)
376375
}
377376

378-
// Removes ANSI escape sequences and VSCode-specific terminal control codes from output.
379-
// While stripAnsi handles most ANSI codes, VSCode's shell integration adds custom
380-
// escape sequences (OSC 633) that need special handling. These sequences control
381-
// terminal features like marking command start/end and setting prompts.
382-
//
383-
// This method could be extended to handle other escape sequences, but any additions
384-
// should be carefully considered to ensure they only remove control codes and don't
385-
// alter the actual content or behavior of the output stream.
386-
private removeEscapeSequences(str: string): string {
387-
// eslint-disable-next-line no-control-regex
388-
return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, ""))
377+
/**
378+
* Remove only VSCode shell integration sequences (OSC 633/133) while
379+
* preserving standard ANSI SGR escape codes for color/formatting.
380+
*
381+
* VSCode shell integration uses OSC 633 and OSC 133 sequences to mark
382+
* prompt boundaries, command starts/ends, etc. These are not useful
383+
* for inline display and should be stripped.
384+
*
385+
* Standard ANSI SGR sequences (e.g., \x1B[32m for green) are preserved
386+
* so the frontend can render them as styled HTML.
387+
*/
388+
private removeVSCodeShellIntegration(text: string): string {
389+
// Remove OSC 633 sequences: \x1B]633;....\x07 or \x1B]633;....\x1B\\
390+
// Remove OSC 133 sequences: \x1B]133;....\x07 or \x1B]133;....\x1B\\
391+
return (
392+
text
393+
// eslint-disable-next-line no-control-regex
394+
.replace(/\x1B\]633;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "")
395+
// eslint-disable-next-line no-control-regex
396+
.replace(/\x1B\]133;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "")
397+
// eslint-disable-next-line no-control-regex
398+
.replace(/\x1B\][0-9]+;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "")
399+
) // Also remove other common OSC sequences that aren't color-related
400+
}
401+
402+
private stripCursorSequences(text: string): string {
403+
return (
404+
text
405+
// eslint-disable-next-line no-control-regex
406+
.replace(/\x1B\[\d*[ABCDEFGHJ]/g, "") // Remove cursor movement: up, down, forward, back
407+
// eslint-disable-next-line no-control-regex
408+
.replace(/\x1B\[su/g, "") // Remove cursor position save/restore
409+
// eslint-disable-next-line no-control-regex
410+
.replace(/\x1B\[\d*[KJ]/g, "") // Remove erase in line/display
411+
// eslint-disable-next-line no-control-regex
412+
.replace(/\x1B\[\?25[hl]/g, "") // Remove cursor show/hide
413+
// eslint-disable-next-line no-control-regex
414+
.replace(/\x1B\[\d*;\d*r/g, "") // Remove scroll region
415+
)
389416
}
390417

391418
/**
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as vscode from "vscode"
2+
import { TerminalProcess } from "../TerminalProcess"
3+
import { Terminal } from "../Terminal"
4+
5+
// Mock dependencies
6+
vi.mock("vscode", () => ({
7+
window: {
8+
createTerminal: vi.fn(),
9+
},
10+
workspace: {
11+
getConfiguration: vi.fn().mockReturnValue({
12+
get: vi.fn(),
13+
}),
14+
},
15+
ThemeIcon: vi.fn(),
16+
}))
17+
18+
describe("TerminalProcess ANSI Handling", () => {
19+
let terminalProcess: any // Using any to access private methods
20+
let mockTerminal: any
21+
22+
beforeEach(() => {
23+
mockTerminal = {
24+
shellIntegration: {
25+
executeCommand: vi.fn(),
26+
},
27+
name: "Test Terminal",
28+
processId: Promise.resolve(123),
29+
creationOptions: {},
30+
exitStatus: undefined,
31+
state: { isInteractedWith: true },
32+
dispose: vi.fn(),
33+
hide: vi.fn(),
34+
show: vi.fn(),
35+
sendText: vi.fn(),
36+
}
37+
38+
const terminalInfo = new Terminal(1, mockTerminal, "/tmp")
39+
terminalProcess = new TerminalProcess(terminalInfo)
40+
})
41+
42+
describe("removeVSCodeShellIntegration", () => {
43+
it("should preserve standard ANSI SGR sequences", () => {
44+
const input = "\x1B[32mgreen text\x1B[0m"
45+
const result = terminalProcess.removeVSCodeShellIntegration(input)
46+
expect(result).toBe("\x1B[32mgreen text\x1B[0m")
47+
})
48+
49+
it("should remove OSC 633 sequences", () => {
50+
const input = "\x1B]633;A\x07some text"
51+
const result = terminalProcess.removeVSCodeShellIntegration(input)
52+
expect(result).toBe("some text")
53+
})
54+
55+
it("should remove OSC 133 sequences", () => {
56+
const input = "\x1B]133;A\x07some text"
57+
const result = terminalProcess.removeVSCodeShellIntegration(input)
58+
expect(result).toBe("some text")
59+
})
60+
61+
it("should handle mixed sequences", () => {
62+
const input = "\x1B]633;C\x07\x1B[1m\x1B[32m✓\x1B[39m\x1B[22m test passed"
63+
const result = terminalProcess.removeVSCodeShellIntegration(input)
64+
expect(result).toBe("\x1B[1m\x1B[32m✓\x1B[39m\x1B[22m test passed")
65+
})
66+
67+
it("should remove other OSC sequences", () => {
68+
const input = "\x1B]0;Console Title\x07Content"
69+
const result = terminalProcess.removeVSCodeShellIntegration(input)
70+
expect(result).toBe("Content")
71+
})
72+
})
73+
74+
describe("stripCursorSequences", () => {
75+
it("should remove cursor movement codes", () => {
76+
const input = "text\x1B[1Aup\x1B[2Kclear"
77+
const result = terminalProcess.stripCursorSequences(input)
78+
expect(result).toBe("textupclear")
79+
})
80+
81+
it("should preserve colors while removing cursor codes", () => {
82+
const input = "\x1B[31mred\x1B[1B\x1B[32mgreen"
83+
const result = terminalProcess.stripCursorSequences(input)
84+
expect(result).toBe("\x1B[31mred\x1B[32mgreen")
85+
})
86+
})
87+
})

src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,12 @@ describe("TerminalProcess with Bash Command Output", () => {
354354
expect(capturedOutput).toBe("Red Text\r\n")
355355
} else {
356356
// Use printf instead of echo -e for more consistent behavior across platforms
357-
// Note: ANSI escape sequences are stripped in the output processing
358-
const { capturedOutput } = await testTerminalCommand('printf "\\033[31mRed Text\\033[0m\\n"', "Red Text\n")
359-
expect(capturedOutput).toBe("Red Text\n")
357+
// Note: ANSI escape sequences are now preserved in the output processing
358+
const { capturedOutput } = await testTerminalCommand(
359+
'printf "\\033[31mRed Text\\033[0m\\n"',
360+
"\x1B[31mRed Text\x1B[0m\n",
361+
)
362+
expect(capturedOutput).toBe("\x1B[31mRed Text\x1B[0m\n")
360363
}
361364
})
362365

webview-ui/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@tanstack/react-query": "^5.68.0",
3636
"@vscode/codicons": "^0.0.36",
3737
"@vscode/webview-ui-toolkit": "^1.4.0",
38+
"ansi-to-html": "^0.7.2",
3839
"axios": "^1.12.0",
3940
"class-variance-authority": "^0.7.1",
4041
"clsx": "^2.1.1",
@@ -55,8 +56,8 @@
5556
"posthog-js": "^1.227.2",
5657
"pretty-bytes": "^7.0.0",
5758
"react": "^18.3.1",
58-
"react-dom": "^18.3.1",
5959
"react-compiler-runtime": "^1.0.0",
60+
"react-dom": "^18.3.1",
6061
"react-i18next": "^15.4.1",
6162
"react-icons": "^5.5.0",
6263
"react-markdown": "^9.0.3",
@@ -84,7 +85,6 @@
8485
"zod": "^3.25.61"
8586
},
8687
"devDependencies": {
87-
"babel-plugin-react-compiler": "^1.0.0",
8888
"@roo-code/config-eslint": "workspace:^",
8989
"@roo-code/config-typescript": "workspace:^",
9090
"@testing-library/jest-dom": "^6.6.3",
@@ -101,6 +101,7 @@
101101
"@types/vscode-webview": "^1.57.5",
102102
"@vitejs/plugin-react": "^4.3.4",
103103
"@vitest/ui": "^3.2.3",
104+
"babel-plugin-react-compiler": "^1.0.0",
104105
"identity-obj-proxy": "^3.0.0",
105106
"jsdom": "^26.0.0",
106107
"vite": "6.3.6",

webview-ui/src/components/chat/CommandExecution.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Button, StandardTooltip } from "@src/components/ui"
1818
import CodeBlock from "@src/components/common/CodeBlock"
1919

2020
import { CommandPatternSelector } from "./CommandPatternSelector"
21+
import { TerminalOutput } from "./TerminalOutput"
2122

2223
interface CommandPattern {
2324
pattern: string
@@ -225,7 +226,7 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean;
225226
"max-h-0": !isExpanded,
226227
"max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded,
227228
})}>
228-
{output.length > 0 && <CodeBlock source={output} language="log" />}
229+
{output.length > 0 && <TerminalOutput content={output} />}
229230
</div>
230231
)
231232

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React, { useMemo } from "react"
2+
import Convert from "ansi-to-html"
3+
4+
interface TerminalOutputProps {
5+
content: string
6+
className?: string
7+
}
8+
9+
// Create a single converter instance with sensible defaults
10+
const converter = new Convert({
11+
fg: "var(--vscode-terminal-foreground, #cccccc)",
12+
bg: "var(--vscode-terminal-background, transparent)",
13+
// Map ANSI colors to VSCode terminal color CSS variables for theme compatibility
14+
colors: {
15+
0: "var(--vscode-terminal-ansiBlack, #000000)",
16+
1: "var(--vscode-terminal-ansiRed, #cd3131)",
17+
2: "var(--vscode-terminal-ansiGreen, #0dbc79)",
18+
3: "var(--vscode-terminal-ansiYellow, #e5e510)",
19+
4: "var(--vscode-terminal-ansiBlue, #2472c8)",
20+
5: "var(--vscode-terminal-ansiMagenta, #bc3fbc)",
21+
6: "var(--vscode-terminal-ansiCyan, #11a8cd)",
22+
7: "var(--vscode-terminal-ansiWhite, #e5e5e5)",
23+
8: "var(--vscode-terminal-ansiBrightBlack, #666666)",
24+
9: "var(--vscode-terminal-ansiBrightRed, #f14c4c)",
25+
10: "var(--vscode-terminal-ansiBrightGreen, #23d18b)",
26+
11: "var(--vscode-terminal-ansiBrightYellow, #f5f543)",
27+
12: "var(--vscode-terminal-ansiBrightBlue, #3b8eea)",
28+
13: "var(--vscode-terminal-ansiBrightMagenta, #d670d6)",
29+
14: "var(--vscode-terminal-ansiBrightCyan, #29b8db)",
30+
15: "var(--vscode-terminal-ansiBrightWhite, #e5e5e5)",
31+
},
32+
escapeXML: true, // Prevent XSS — escape HTML entities in the content
33+
newline: false, // We handle newlines ourselves via <pre>
34+
})
35+
36+
/**
37+
* Renders terminal output with ANSI color/formatting support.
38+
*
39+
* Uses ansi-to-html to convert ANSI escape sequences into styled <span> elements.
40+
* Colors are mapped to VSCode terminal theme CSS variables for consistent theming.
41+
*
42+
* The component uses a monospace font and preserves whitespace/newlines
43+
* to match terminal rendering behavior.
44+
*/
45+
export const TerminalOutput: React.FC<TerminalOutputProps> = ({ content, className }) => {
46+
const html = useMemo(() => {
47+
try {
48+
return converter.toHtml(content)
49+
} catch {
50+
// Fallback: if conversion fails, show raw text (stripped of ANSI)
51+
// eslint-disable-next-line no-control-regex
52+
return content.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")
53+
}
54+
}, [content])
55+
56+
return (
57+
<pre
58+
className={className}
59+
style={{
60+
fontFamily:
61+
"var(--vscode-editor-font-family, 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', 'Monaco', 'Courier New', monospace)",
62+
fontSize: "var(--vscode-editor-font-size, 13px)",
63+
lineHeight: "var(--vscode-editor-line-height, 1.4)",
64+
whiteSpace: "pre-wrap",
65+
wordBreak: "break-word",
66+
margin: 0,
67+
padding: "8px 12px",
68+
backgroundColor: "var(--vscode-terminal-background, transparent)",
69+
color: "var(--vscode-terminal-foreground, inherit)",
70+
overflow: "auto",
71+
// Support Unicode box-drawing characters and extended ASCII
72+
unicodeBidi: "embed",
73+
}}
74+
dangerouslySetInnerHTML={{ __html: html }}
75+
/>
76+
)
77+
}

0 commit comments

Comments
 (0)