Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 9bf7173

Browse files
feat: add button to open markdown in VSCode preview (#10773)
Co-authored-by: Roo Code <roomote@roocode.com>
1 parent 8b9f02a commit 9bf7173

7 files changed

Lines changed: 179 additions & 4 deletions

File tree

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ export interface WebviewMessage {
504504
| "editQueuedMessage"
505505
| "dismissUpsell"
506506
| "getDismissedUpsells"
507+
| "openMarkdownPreview"
507508
| "updateSettings"
508509
| "allowedCommands"
509510
| "getTaskWithAggregatedCosts"

src/core/webview/webviewMessageHandler.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3233,6 +3233,27 @@ export const webviewMessageHandler = async (
32333233
break
32343234
}
32353235

3236+
case "openMarkdownPreview": {
3237+
if (message.text) {
3238+
try {
3239+
const tmpDir = os.tmpdir()
3240+
const timestamp = Date.now()
3241+
const tempFileName = `roo-preview-${timestamp}.md`
3242+
const tempFilePath = path.join(tmpDir, tempFileName)
3243+
3244+
await fs.writeFile(tempFilePath, message.text, "utf8")
3245+
3246+
const doc = await vscode.workspace.openTextDocument(tempFilePath)
3247+
await vscode.commands.executeCommand("markdown.showPreview", doc.uri)
3248+
} catch (error) {
3249+
const errorMessage = error instanceof Error ? error.message : String(error)
3250+
provider.log(`Error opening markdown preview: ${errorMessage}`)
3251+
vscode.window.showErrorMessage(`Failed to open markdown preview: ${errorMessage}`)
3252+
}
3253+
}
3254+
break
3255+
}
3256+
32363257
case "requestClaudeCodeRateLimits": {
32373258
try {
32383259
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
} from "lucide-react"
7171
import { cn } from "@/lib/utils"
7272
import { PathTooltip } from "../ui/PathTooltip"
73+
import { OpenMarkdownPreviewButton } from "./OpenMarkdownPreviewButton"
7374

7475
// Helper function to get previous todos before a specific message
7576
function getPreviousTodos(messages: ClineMessage[], currentMessageTs: number): any[] {
@@ -1205,10 +1206,12 @@ export const ChatRowContent = ({
12051206
return null // we should never see this message type
12061207
case "text":
12071208
return (
1208-
<div>
1209+
<div className="group">
12091210
<div style={headerStyle}>
12101211
<MessageCircle className="w-4 shrink-0" aria-label="Speech bubble icon" />
12111212
<span style={{ fontWeight: "bold" }}>{t("chat:text.rooSaid")}</span>
1213+
<div style={{ flexGrow: 1 }} />
1214+
<OpenMarkdownPreviewButton markdown={message.text} />
12121215
</div>
12131216
<div className="pl-6">
12141217
<Markdown markdown={message.text} partial={message.partial} />
@@ -1343,15 +1346,17 @@ export const ChatRowContent = ({
13431346
)
13441347
case "completion_result":
13451348
return (
1346-
<>
1349+
<div className="group">
13471350
<div style={headerStyle}>
13481351
{icon}
13491352
{title}
1353+
<div style={{ flexGrow: 1 }} />
1354+
<OpenMarkdownPreviewButton markdown={message.text} />
13501355
</div>
13511356
<div className="border-l border-green-600/30 ml-2 pl-4 pb-1">
13521357
<Markdown markdown={message.text} />
13531358
</div>
1354-
</>
1359+
</div>
13551360
)
13561361
case "shell_integration_warning":
13571362
return <CommandExecutionError />
@@ -1602,10 +1607,12 @@ export const ChatRowContent = ({
16021607
case "completion_result":
16031608
if (message.text) {
16041609
return (
1605-
<div>
1610+
<div className="group">
16061611
<div style={headerStyle}>
16071612
{icon}
16081613
{title}
1614+
<div style={{ flexGrow: 1 }} />
1615+
<OpenMarkdownPreviewButton markdown={message.text} />
16091616
</div>
16101617
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
16111618
<Markdown markdown={message.text} partial={message.partial} />
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React, { memo } from "react"
2+
import { SquareArrowOutUpRight } from "lucide-react"
3+
4+
import { vscode } from "@src/utils/vscode"
5+
import { hasComplexMarkdown } from "@src/utils/markdown"
6+
import { StandardTooltip } from "@src/components/ui"
7+
8+
interface OpenMarkdownPreviewButtonProps {
9+
markdown: string | undefined
10+
className?: string
11+
}
12+
13+
export const OpenMarkdownPreviewButton = memo(({ markdown, className }: OpenMarkdownPreviewButtonProps) => {
14+
if (!hasComplexMarkdown(markdown)) {
15+
return null
16+
}
17+
18+
const handleClick = (e: React.MouseEvent) => {
19+
e.stopPropagation()
20+
if (markdown) {
21+
vscode.postMessage({
22+
type: "openMarkdownPreview",
23+
text: markdown,
24+
})
25+
}
26+
}
27+
28+
return (
29+
<StandardTooltip content="Open in preview">
30+
<button
31+
onClick={handleClick}
32+
className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${className ?? ""}`}
33+
aria-label="Open markdown in preview">
34+
<SquareArrowOutUpRight className="w-4 h-4" />
35+
</button>
36+
</StandardTooltip>
37+
)
38+
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from "react"
2+
import { describe, expect, it, vi, beforeEach } from "vitest"
3+
import { render, screen, fireEvent } from "@testing-library/react"
4+
import { TooltipProvider } from "@radix-ui/react-tooltip"
5+
6+
import { OpenMarkdownPreviewButton } from "../OpenMarkdownPreviewButton"
7+
8+
const { postMessageMock } = vi.hoisted(() => ({
9+
postMessageMock: vi.fn(),
10+
}))
11+
12+
vi.mock("@src/utils/vscode", () => ({
13+
vscode: {
14+
postMessage: postMessageMock,
15+
},
16+
}))
17+
18+
describe("OpenMarkdownPreviewButton", () => {
19+
const complex = "# One\n## Two"
20+
const simple = "Just text"
21+
22+
beforeEach(() => {
23+
postMessageMock.mockClear()
24+
})
25+
26+
it("does not render when markdown has fewer than 2 headings", () => {
27+
render(
28+
<TooltipProvider>
29+
<OpenMarkdownPreviewButton markdown={simple} />
30+
</TooltipProvider>,
31+
)
32+
expect(screen.queryByLabelText("Open markdown in preview")).toBeNull()
33+
})
34+
35+
it("renders when markdown has 2+ headings", () => {
36+
render(
37+
<TooltipProvider>
38+
<OpenMarkdownPreviewButton markdown={complex} />
39+
</TooltipProvider>,
40+
)
41+
expect(screen.getByLabelText("Open markdown in preview")).toBeInTheDocument()
42+
})
43+
44+
it("posts message on click", () => {
45+
render(
46+
<TooltipProvider>
47+
<OpenMarkdownPreviewButton markdown={complex} />
48+
</TooltipProvider>,
49+
)
50+
fireEvent.click(screen.getByLabelText("Open markdown in preview"))
51+
expect(postMessageMock).toHaveBeenCalledWith({ type: "openMarkdownPreview", text: complex })
52+
})
53+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { countMarkdownHeadings, hasComplexMarkdown } from "../markdown"
4+
5+
describe("markdown heading helpers", () => {
6+
it("returns 0 for empty or undefined", () => {
7+
expect(countMarkdownHeadings(undefined)).toBe(0)
8+
expect(countMarkdownHeadings("")).toBe(0)
9+
})
10+
11+
it("counts single and multiple headings", () => {
12+
expect(countMarkdownHeadings("# One")).toBe(1)
13+
expect(countMarkdownHeadings("# One\nContent")).toBe(1)
14+
expect(countMarkdownHeadings("# One\n## Two")).toBe(2)
15+
expect(countMarkdownHeadings("# One\n## Two\n### Three")).toBe(3)
16+
})
17+
18+
it("handles all heading levels", () => {
19+
const md = `# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6`
20+
expect(countMarkdownHeadings(md)).toBe(6)
21+
})
22+
23+
it("ignores headings inside code fences", () => {
24+
const md = "# real\n```\n# not a heading\n```\n## real"
25+
expect(countMarkdownHeadings(md)).toBe(2)
26+
})
27+
28+
it("hasComplexMarkdown requires at least two headings", () => {
29+
expect(hasComplexMarkdown("# One")).toBe(false)
30+
expect(hasComplexMarkdown("# One\n## Two")).toBe(true)
31+
})
32+
})

webview-ui/src/utils/markdown.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Counts the number of markdown headings in the given text.
3+
* Matches headings from level 1 to 6 (e.g. #, ##, ###, etc.).
4+
* Code fences are stripped before matching to avoid false positives.
5+
*/
6+
export function countMarkdownHeadings(text: string | undefined): number {
7+
if (!text) return 0
8+
9+
// Remove fenced code blocks to avoid counting headings inside code
10+
const withoutCodeBlocks = text.replace(/```[\s\S]*?```/g, "")
11+
12+
// Up to 3 leading spaces are allowed before the hashes per the markdown spec
13+
const headingRegex = /^\s{0,3}#{1,6}\s+.+$/gm
14+
const matches = withoutCodeBlocks.match(headingRegex)
15+
return matches ? matches.length : 0
16+
}
17+
18+
/**
19+
* Returns true if the markdown contains at least two headings.
20+
*/
21+
export function hasComplexMarkdown(text: string | undefined): boolean {
22+
return countMarkdownHeadings(text) >= 2
23+
}

0 commit comments

Comments
 (0)