diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7e643479a6f..08423d536d6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -805,7 +805,7 @@ importers:
version: 1.14.0(typescript@5.8.3)
'@requesty/ai-sdk':
specifier: ^3.0.0
- version: 3.0.0(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)
+ version: 3.0.0(vite@6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)
'@roo-code/cloud':
specifier: workspace:^
version: link:../packages/cloud
@@ -1209,6 +1209,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
@@ -4954,6 +4957,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==}
@@ -6273,6 +6281,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'}
@@ -6902,6 +6913,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:
@@ -10051,7 +10063,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==}
@@ -13818,11 +13830,11 @@ snapshots:
dependencies:
'@redis/client': 5.5.5
- '@requesty/ai-sdk@3.0.0(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)':
+ '@requesty/ai-sdk@3.0.0(vite@6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 3.0.20(zod@3.25.76)
- vite: 6.3.5(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
+ vite: 6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
zod: 3.25.76
'@resvg/resvg-wasm@2.4.0': {}
@@ -14970,7 +14982,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@@ -15165,6 +15177,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:
@@ -16451,6 +16467,8 @@ snapshots:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
+ entities@2.2.0: {}
+
entities@4.5.0: {}
entities@6.0.0: {}
diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts
index 7aba55173f0..d202191b958 100644
--- a/src/integrations/terminal/TerminalProcess.ts
+++ b/src/integrations/terminal/TerminalProcess.ts
@@ -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
+ )
}
/**
diff --git a/src/integrations/terminal/__tests__/TerminalProcess.test.ts b/src/integrations/terminal/__tests__/TerminalProcess.test.ts
new file mode 100644
index 00000000000..cf2e8dbb80b
--- /dev/null
+++ b/src/integrations/terminal/__tests__/TerminalProcess.test.ts
@@ -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")
+ })
+ })
+})
diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts
index e6b9483d0f2..720fb427a51 100644
--- a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts
+++ b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts
@@ -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")
}
})
diff --git a/webview-ui/package.json b/webview-ui/package.json
index d72c6a1a2c6..ccb367782a7 100644
--- a/webview-ui/package.json
+++ b/webview-ui/package.json
@@ -36,6 +36,7 @@
"@types/qrcode": "^1.5.5",
"@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",
@@ -57,8 +58,8 @@
"pretty-bytes": "^7.0.0",
"qrcode": "^1.5.4",
"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",
@@ -86,7 +87,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",
@@ -103,6 +103,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",
diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx
index e5763213cc4..af1d72c6a54 100644
--- a/webview-ui/src/components/chat/CommandExecution.tsx
+++ b/webview-ui/src/components/chat/CommandExecution.tsx
@@ -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 &&
+})
+
+/**
+ * Renders terminal output with ANSI color/formatting support.
+ *
+ * Uses ansi-to-html to convert ANSI escape sequences into styled 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 = ({ 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 (
+
+ )
+}
diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
index c8027edda35..f40987d269d 100644
--- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
@@ -23,6 +23,11 @@ vi.mock("../../common/CodeBlock", () => ({
default: ({ source }: { source: string }) => {source},
}))
+// Mock TerminalOutput
+vi.mock("../TerminalOutput", () => ({
+ TerminalOutput: ({ content }: { content: string }) => {content},
+}))
+
vi.mock("../CommandPatternSelector", () => ({
CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
@@ -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")
})
})
})
diff --git a/webview-ui/src/components/chat/__tests__/TerminalOutput.spec.tsx b/webview-ui/src/components/chat/__tests__/TerminalOutput.spec.tsx
new file mode 100644
index 00000000000..f6bfc0b6a44
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/TerminalOutput.spec.tsx
@@ -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( );
+ expect(container.textContent).toBe("hello world");
+ });
+
+ it("converts ANSI color codes to styled spans", () => {
+ const { container } = render(
+
+ );
+ 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(
+ alert("xss")'} />
+ );
+ expect(container.innerHTML).not.toContain("');
+ });
+
+ it("handles empty content", () => {
+ const { container } = render( );
+ expect(container.textContent).toBe("");
+ });
+});