From 8507cdf9c3cdf5522def040b4c8112fab9201ee5 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Fri, 16 Jan 2026 12:38:56 +0000 Subject: [PATCH 1/2] Markdown preview --- packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/webviewMessageHandler.ts | 21 ++++++++ webview-ui/src/components/chat/ChatRow.tsx | 15 ++++-- .../chat/OpenMarkdownPreviewButton.tsx | 38 +++++++++++++ .../OpenMarkdownPreviewButton.spec.tsx | 53 +++++++++++++++++++ .../src/utils/__tests__/markdown.spec.ts | 32 +++++++++++ webview-ui/src/utils/markdown.ts | 23 ++++++++ 7 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx create mode 100644 webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx create mode 100644 webview-ui/src/utils/__tests__/markdown.spec.ts create mode 100644 webview-ui/src/utils/markdown.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f3116141f04..86d8b2ddbbe 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -504,6 +504,7 @@ export interface WebviewMessage { | "editQueuedMessage" | "dismissUpsell" | "getDismissedUpsells" + | "openMarkdownPreview" | "updateSettings" | "allowedCommands" | "getTaskWithAggregatedCosts" diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e93be3278d2..dbeb380d162 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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") diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 24749bb4191..a609d2dc7ec 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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 ( -
+
{t("chat:text.rooSaid")} +
+
@@ -1343,15 +1346,17 @@ export const ChatRowContent = ({ ) case "completion_result": return ( - <> +
{icon} {title} +
+
- +
) case "shell_integration_warning": return @@ -1602,10 +1607,12 @@ export const ChatRowContent = ({ case "completion_result": if (message.text) { return ( -
+
{icon} {title} +
+
diff --git a/webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx b/webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx new file mode 100644 index 00000000000..2393e9005d7 --- /dev/null +++ b/webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx @@ -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 ( + + + + ) +}) diff --git a/webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx b/webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx new file mode 100644 index 00000000000..95f7aad21b0 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx @@ -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( + + + , + ) + expect(screen.queryByLabelText("Open markdown in preview")).toBeNull() + }) + + it("renders when markdown has 2+ headings", () => { + render( + + + , + ) + expect(screen.getByLabelText("Open markdown in preview")).toBeInTheDocument() + }) + + it("posts message on click", () => { + render( + + + , + ) + fireEvent.click(screen.getByLabelText("Open markdown in preview")) + expect(postMessageMock).toHaveBeenCalledWith({ type: "openMarkdownPreview", text: complex }) + }) +}) diff --git a/webview-ui/src/utils/__tests__/markdown.spec.ts b/webview-ui/src/utils/__tests__/markdown.spec.ts new file mode 100644 index 00000000000..3ddff1ed546 --- /dev/null +++ b/webview-ui/src/utils/__tests__/markdown.spec.ts @@ -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("")) + }) + + 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")) + }) +}) diff --git a/webview-ui/src/utils/markdown.ts b/webview-ui/src/utils/markdown.ts new file mode 100644 index 00000000000..7a77b9866db --- /dev/null +++ b/webview-ui/src/utils/markdown.ts @@ -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 +} From c59be6ad8442f0fd7f5bdd0b7b5f109143e00d01 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 16 Jan 2026 13:11:44 +0000 Subject: [PATCH 2/2] fix: add missing matchers to test assertions in markdown.spec.ts --- .../src/utils/__tests__/markdown.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/webview-ui/src/utils/__tests__/markdown.spec.ts b/webview-ui/src/utils/__tests__/markdown.spec.ts index 3ddff1ed546..97b3fdaaf2f 100644 --- a/webview-ui/src/utils/__tests__/markdown.spec.ts +++ b/webview-ui/src/utils/__tests__/markdown.spec.ts @@ -5,28 +5,28 @@ import { countMarkdownHeadings, hasComplexMarkdown } from "../markdown" describe("markdown heading helpers", () => { it("returns 0 for empty or undefined", () => { expect(countMarkdownHeadings(undefined)).toBe(0) - expect(countMarkdownHeadings("")) + expect(countMarkdownHeadings("")).toBe(0) }) 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")) + 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)) + 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)) + expect(countMarkdownHeadings(md)).toBe(2) }) it("hasComplexMarkdown requires at least two headings", () => { - expect(hasComplexMarkdown("# One")) - expect(hasComplexMarkdown("# One\n## Two")) + expect(hasComplexMarkdown("# One")).toBe(false) + expect(hasComplexMarkdown("# One\n## Two")).toBe(true) }) })