feat: add button to open markdown in VSCode preview (#10773)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Bruno Bergher 2026-01-16 15:56:19 +00:00 committed by GitHub
parent 8b9f02aa0d
commit 9bf7173725
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 179 additions and 4 deletions

View file

@ -504,6 +504,7 @@ export interface WebviewMessage {
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "openMarkdownPreview"
| "updateSettings"
| "allowedCommands"
| "getTaskWithAggregatedCosts"

View file

@ -3233,6 +3233,27 @@ export const webviewMessageHandler = async (
break
}
case "openMarkdownPreview": {
if (message.text) {
try {
const tmpDir = os.tmpdir()
const timestamp = Date.now()
const tempFileName = `roo-preview-${timestamp}.md`
const tempFilePath = path.join(tmpDir, tempFileName)
await fs.writeFile(tempFilePath, message.text, "utf8")
const doc = await vscode.workspace.openTextDocument(tempFilePath)
await vscode.commands.executeCommand("markdown.showPreview", doc.uri)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
provider.log(`Error opening markdown preview: ${errorMessage}`)
vscode.window.showErrorMessage(`Failed to open markdown preview: ${errorMessage}`)
}
}
break
}
case "requestClaudeCodeRateLimits": {
try {
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")

View file

@ -70,6 +70,7 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import { PathTooltip } from "../ui/PathTooltip"
import { OpenMarkdownPreviewButton } from "./OpenMarkdownPreviewButton"
// Helper function to get previous todos before a specific message
function getPreviousTodos(messages: ClineMessage[], currentMessageTs: number): any[] {
@ -1205,10 +1206,12 @@ export const ChatRowContent = ({
return null // we should never see this message type
case "text":
return (
<div>
<div className="group">
<div style={headerStyle}>
<MessageCircle className="w-4 shrink-0" aria-label="Speech bubble icon" />
<span style={{ fontWeight: "bold" }}>{t("chat:text.rooSaid")}</span>
<div style={{ flexGrow: 1 }} />
<OpenMarkdownPreviewButton markdown={message.text} />
</div>
<div className="pl-6">
<Markdown markdown={message.text} partial={message.partial} />
@ -1343,15 +1346,17 @@ export const ChatRowContent = ({
)
case "completion_result":
return (
<>
<div className="group">
<div style={headerStyle}>
{icon}
{title}
<div style={{ flexGrow: 1 }} />
<OpenMarkdownPreviewButton markdown={message.text} />
</div>
<div className="border-l border-green-600/30 ml-2 pl-4 pb-1">
<Markdown markdown={message.text} />
</div>
</>
</div>
)
case "shell_integration_warning":
return <CommandExecutionError />
@ -1602,10 +1607,12 @@ export const ChatRowContent = ({
case "completion_result":
if (message.text) {
return (
<div>
<div className="group">
<div style={headerStyle}>
{icon}
{title}
<div style={{ flexGrow: 1 }} />
<OpenMarkdownPreviewButton markdown={message.text} />
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} partial={message.partial} />

View file

@ -0,0 +1,38 @@
import React, { memo } from "react"
import { SquareArrowOutUpRight } from "lucide-react"
import { vscode } from "@src/utils/vscode"
import { hasComplexMarkdown } from "@src/utils/markdown"
import { StandardTooltip } from "@src/components/ui"
interface OpenMarkdownPreviewButtonProps {
markdown: string | undefined
className?: string
}
export const OpenMarkdownPreviewButton = memo(({ markdown, className }: OpenMarkdownPreviewButtonProps) => {
if (!hasComplexMarkdown(markdown)) {
return null
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (markdown) {
vscode.postMessage({
type: "openMarkdownPreview",
text: markdown,
})
}
}
return (
<StandardTooltip content="Open in preview">
<button
onClick={handleClick}
className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${className ?? ""}`}
aria-label="Open markdown in preview">
<SquareArrowOutUpRight className="w-4 h-4" />
</button>
</StandardTooltip>
)
})

View file

@ -0,0 +1,53 @@
import React from "react"
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { TooltipProvider } from "@radix-ui/react-tooltip"
import { OpenMarkdownPreviewButton } from "../OpenMarkdownPreviewButton"
const { postMessageMock } = vi.hoisted(() => ({
postMessageMock: vi.fn(),
}))
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: postMessageMock,
},
}))
describe("OpenMarkdownPreviewButton", () => {
const complex = "# One\n## Two"
const simple = "Just text"
beforeEach(() => {
postMessageMock.mockClear()
})
it("does not render when markdown has fewer than 2 headings", () => {
render(
<TooltipProvider>
<OpenMarkdownPreviewButton markdown={simple} />
</TooltipProvider>,
)
expect(screen.queryByLabelText("Open markdown in preview")).toBeNull()
})
it("renders when markdown has 2+ headings", () => {
render(
<TooltipProvider>
<OpenMarkdownPreviewButton markdown={complex} />
</TooltipProvider>,
)
expect(screen.getByLabelText("Open markdown in preview")).toBeInTheDocument()
})
it("posts message on click", () => {
render(
<TooltipProvider>
<OpenMarkdownPreviewButton markdown={complex} />
</TooltipProvider>,
)
fireEvent.click(screen.getByLabelText("Open markdown in preview"))
expect(postMessageMock).toHaveBeenCalledWith({ type: "openMarkdownPreview", text: complex })
})
})

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest"
import { countMarkdownHeadings, hasComplexMarkdown } from "../markdown"
describe("markdown heading helpers", () => {
it("returns 0 for empty or undefined", () => {
expect(countMarkdownHeadings(undefined)).toBe(0)
expect(countMarkdownHeadings("")).toBe(0)
})
it("counts single and multiple headings", () => {
expect(countMarkdownHeadings("# One")).toBe(1)
expect(countMarkdownHeadings("# One\nContent")).toBe(1)
expect(countMarkdownHeadings("# One\n## Two")).toBe(2)
expect(countMarkdownHeadings("# One\n## Two\n### Three")).toBe(3)
})
it("handles all heading levels", () => {
const md = `# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6`
expect(countMarkdownHeadings(md)).toBe(6)
})
it("ignores headings inside code fences", () => {
const md = "# real\n```\n# not a heading\n```\n## real"
expect(countMarkdownHeadings(md)).toBe(2)
})
it("hasComplexMarkdown requires at least two headings", () => {
expect(hasComplexMarkdown("# One")).toBe(false)
expect(hasComplexMarkdown("# One\n## Two")).toBe(true)
})
})

View file

@ -0,0 +1,23 @@
/**
* Counts the number of markdown headings in the given text.
* Matches headings from level 1 to 6 (e.g. #, ##, ###, etc.).
* Code fences are stripped before matching to avoid false positives.
*/
export function countMarkdownHeadings(text: string | undefined): number {
if (!text) return 0
// Remove fenced code blocks to avoid counting headings inside code
const withoutCodeBlocks = text.replace(/```[\s\S]*?```/g, "")
// Up to 3 leading spaces are allowed before the hashes per the markdown spec
const headingRegex = /^\s{0,3}#{1,6}\s+.+$/gm
const matches = withoutCodeBlocks.match(headingRegex)
return matches ? matches.length : 0
}
/**
* Returns true if the markdown contains at least two headings.
*/
export function hasComplexMarkdown(text: string | undefined): boolean {
return countMarkdownHeadings(text) >= 2
}