Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ export interface WebviewMessage {
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "openMarkdownPreview"
| "updateSettings"
| "allowedCommands"
| "getTaskWithAggregatedCosts"
Expand Down
21 changes: 21 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 11 additions & 4 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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} />
Expand Down Expand Up @@ -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 />
Expand Down Expand Up @@ -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} />
Expand Down
38 changes: 38 additions & 0 deletions webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
})
Original file line number Diff line number Diff line change
@@ -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 })
})
})
32 changes: 32 additions & 0 deletions webview-ui/src/utils/__tests__/markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -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(""))
Comment thread
brunobergher marked this conversation as resolved.
Outdated
})

it("counts single and multiple headings", () => {
expect(countMarkdownHeadings("# One"))
expect(countMarkdownHeadings("# One\nContent"))
expect(countMarkdownHeadings("# One\n## Two"))
expect(countMarkdownHeadings("# One\n## Two\n### Three"))
})

it("handles all heading levels", () => {
const md = `# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6`
expect(countMarkdownHeadings(md))
})

it("ignores headings inside code fences", () => {
const md = "# real\n```\n# not a heading\n```\n## real"
expect(countMarkdownHeadings(md))
})

it("hasComplexMarkdown requires at least two headings", () => {
expect(hasComplexMarkdown("# One"))
expect(hasComplexMarkdown("# One\n## Two"))
})
})
23 changes: 23 additions & 0 deletions webview-ui/src/utils/markdown.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading