Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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("")).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)
})
})
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