소스 검색

feat: render markdown table in UI (#9056)

* feat: display markdown table in UI

Simplify the handlePartialBlock method in AttemptCompletionHandler by:
- Removing conditional logic for command vs no-command cases
- Always displaying partial result if present
- Deferring command handling to the final execution step
This fixes an issue where attempt completion response doesn't get streamed to the UI during partial result.

Also replaced react-remark with react-markdown and remark-gfm dependencies to MarkdownBlock in UI for enhanced markdown rendering support with GitHub Flavored Markdown features, including displaying table.

* add changeset

* Update src/core/task/tools/handlers/AttemptCompletionHandler.ts

handlePartialBlock hard-codes the partial flag to true when calling uiHelpers.say(...). For consistency with other tool handlers and to avoid incorrect behavior if this method is ever invoked with a non-partial block, pass block.partial through instead.

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Robin Newhouse <[email protected]>
Co-authored-by: Copilot <[email protected]>
Bee 2 달 전
부모
커밋
d116ac5

+ 5 - 0
.changeset/famous-poems-smell.md

@@ -0,0 +1,5 @@
+---
+"cline": patch
+---
+
+Supports rendering markdown table in chat view.

+ 4 - 13
src/core/task/tools/handlers/AttemptCompletionHandler.ts

@@ -25,22 +25,13 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
 
 
 	/**
 	/**
 	 * Handle partial block streaming for attempt_completion
 	 * Handle partial block streaming for attempt_completion
-	 * Matches the original conditional logic structure for command vs no-command cases
 	 */
 	 */
 	async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
 	async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
-		const result = block.params.result
-		const command = block.params.command
-
-		if (!command) {
-			// no command, still outputting partial result
-			await uiHelpers.say(
-				"completion_result",
-				uiHelpers.removeClosingTag(block, "result", result),
-				undefined,
-				undefined,
-				block.partial,
-			)
+		const result = uiHelpers.removeClosingTag(block, "result", block.params.result)
+		if (result) {
+			await uiHelpers.say("completion_result", result, undefined, undefined, block.partial)
 		}
 		}
+		// We will handle command in the final execution step
 	}
 	}
 
 
 	async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
 	async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1355 - 106
webview-ui/package-lock.json


+ 2 - 0
webview-ui/package.json

@@ -47,6 +47,7 @@
 		"pretty-bytes": "^6.1.1",
 		"pretty-bytes": "^6.1.1",
 		"react": "^18.3.1",
 		"react": "^18.3.1",
 		"react-dom": "^18.3.1",
 		"react-dom": "^18.3.1",
+		"react-markdown": "^10.1.0",
 		"react-remark": "^2.1.0",
 		"react-remark": "^2.1.0",
 		"react-textarea-autosize": "^8.5.7",
 		"react-textarea-autosize": "^8.5.7",
 		"react-use": "^17.6.0",
 		"react-use": "^17.6.0",
@@ -54,6 +55,7 @@
 		"rehype-highlight": "^7.0.1",
 		"rehype-highlight": "^7.0.1",
 		"rehype-parse": "^9.0.1",
 		"rehype-parse": "^9.0.1",
 		"rehype-remark": "^10.0.1",
 		"rehype-remark": "^10.0.1",
+		"remark-gfm": "^4.0.1",
 		"remark-stringify": "^11.0.0",
 		"remark-stringify": "^11.0.0",
 		"styled-components": "^6.1.15",
 		"styled-components": "^6.1.15",
 		"tailwind-merge": "^3.3.1",
 		"tailwind-merge": "^3.3.1",

+ 101 - 80
webview-ui/src/components/common/MarkdownBlock.tsx

@@ -1,10 +1,12 @@
 import { StringRequest } from "@shared/proto/cline/common"
 import { StringRequest } from "@shared/proto/cline/common"
 import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
 import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
 import { SquareArrowOutUpRightIcon } from "lucide-react"
 import { SquareArrowOutUpRightIcon } from "lucide-react"
+import { marked } from "marked"
 import type { ComponentProps } from "react"
 import type { ComponentProps } from "react"
-import React, { memo, useEffect, useRef, useState } from "react"
-import { useRemark } from "react-remark"
+import React, { memo, useEffect, useMemo, useRef, useState } from "react"
+import ReactMarkdown from "react-markdown"
 import rehypeHighlight, { Options } from "rehype-highlight"
 import rehypeHighlight, { Options } from "rehype-highlight"
+import remarkGfm from "remark-gfm"
 import type { Node } from "unist"
 import type { Node } from "unist"
 import { visit } from "unist-util-visit"
 import { visit } from "unist-util-visit"
 import MermaidBlock from "@/components/common/MermaidBlock"
 import MermaidBlock from "@/components/common/MermaidBlock"
@@ -14,6 +16,102 @@ import { cn } from "@/lib/utils"
 import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
 import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
 import { WithCopyButton } from "./CopyButton"
 import { WithCopyButton } from "./CopyButton"
 
 
+function parseMarkdownIntoBlocks(markdown: string): string[] {
+	try {
+		const tokens = marked.lexer(markdown)
+		return tokens?.map((token) => token.raw)
+	} catch {
+		return [markdown]
+	}
+}
+
+const MemoizedMarkdownBlock = memo(
+	({ content }: { content: string }) => {
+		return (
+			<ReactMarkdown
+				components={{
+					pre: ({ children, ...preProps }: React.HTMLAttributes<HTMLPreElement>) => {
+						if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) {
+							const child = children[0] as React.ReactElement<{ className?: string }>
+							if (child.props?.className?.includes("language-mermaid")) {
+								return child
+							}
+						}
+						return <PreWithCopyButton {...preProps}>{children}</PreWithCopyButton>
+					},
+					code: (props: ComponentProps<"code"> & { [key: string]: any }) => {
+						const className = props.className || ""
+						if (className.includes("language-mermaid")) {
+							const codeText = String(props.children || "")
+							return <MermaidBlock code={codeText} />
+						}
+
+						// Use the async file check component for potential file paths
+						return <InlineCodeWithFileCheck {...props} />
+					},
+					strong: (props: ComponentProps<"strong">) => {
+						// Check if this is an "Act Mode" strong element by looking for the keyboard shortcut
+						// Handle both string children and array of children cases
+						const childrenText = React.Children.toArray(props.children)
+							.map((child) => {
+								if (typeof child === "string") {
+									return child
+								}
+								if (typeof child === "object" && "props" in child && child.props.children) {
+									return String(child.props.children)
+								}
+								return ""
+							})
+							.join("")
+
+						// Case-insensitive check for "Act Mode (⌘⇧A)" pattern
+						// This ensures we only style the exact "Act Mode" mentions with keyboard shortcut
+						// Using case-insensitive flag to catch all capitalization variations
+						if (/^act mode\s*\(⌘⇧A\)$/i.test(childrenText)) {
+							return <ActModeHighlight />
+						}
+
+						return <strong {...props} />
+					},
+				}}
+				rehypePlugins={[[rehypeHighlight as any, {} as Options]]}
+				remarkPlugins={[
+					[remarkGfm, { singleTilde: false }],
+					remarkPreventBoldFilenames,
+					remarkUrlToLink,
+					remarkHighlightActMode,
+					remarkMarkPotentialFilePaths,
+					() => {
+						return (tree: any) => {
+							visit(tree, "code", (node: any) => {
+								if (!node.lang) {
+									node.lang = "javascript"
+								} else if (node.lang.includes(".")) {
+									node.lang = node.lang.split(".").slice(-1)[0]
+								}
+							})
+						}
+					},
+				]}>
+				{content}
+			</ReactMarkdown>
+		)
+	},
+	(prevProps, nextProps) => {
+		if (prevProps.content !== nextProps.content) return false
+		return true
+	},
+)
+
+MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock"
+
+const MemoizedMarkdown = memo(({ content, id }: { content: string; id: string }) => {
+	const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content])
+	return blocks?.map((block, index) => <MemoizedMarkdownBlock content={block} key={`${id}-block_${index}`} />)
+})
+
+MemoizedMarkdown.displayName = "MemoizedMarkdown"
+
 /**
 /**
  * A component for Act Mode text that contains a clickable toggle and keyboard shortcut hint.
  * A component for Act Mode text that contains a clickable toggle and keyboard shortcut hint.
  */
  */
@@ -312,83 +410,6 @@ const InlineCodeWithFileCheck: React.FC<ComponentProps<"code"> & { [key: string]
 }
 }
 
 
 const MarkdownBlock = memo(({ markdown, compact, showCursor }: MarkdownBlockProps) => {
 const MarkdownBlock = memo(({ markdown, compact, showCursor }: MarkdownBlockProps) => {
-	const [reactContent, setMarkdown] = useRemark({
-		remarkPlugins: [
-			remarkPreventBoldFilenames,
-			remarkUrlToLink,
-			remarkHighlightActMode,
-			remarkMarkPotentialFilePaths,
-			() => {
-				return (tree) => {
-					visit(tree, "code", (node: any) => {
-						if (!node.lang) {
-							node.lang = "javascript"
-						} else if (node.lang.includes(".")) {
-							node.lang = node.lang.split(".").slice(-1)[0]
-						}
-					})
-				}
-			},
-		],
-		rehypePlugins: [
-			rehypeHighlight as any,
-			{
-				// languages: {},
-			} as Options,
-		],
-		rehypeReactOptions: {
-			components: {
-				pre: ({ children, ...preProps }: React.HTMLAttributes<HTMLPreElement>) => {
-					if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) {
-						const child = children[0] as React.ReactElement<{ className?: string }>
-						if (child.props?.className?.includes("language-mermaid")) {
-							return child
-						}
-					}
-					return <PreWithCopyButton {...preProps}>{children}</PreWithCopyButton>
-				},
-				code: (props: ComponentProps<"code"> & { [key: string]: any }) => {
-					const className = props.className || ""
-					if (className.includes("language-mermaid")) {
-						const codeText = String(props.children || "")
-						return <MermaidBlock code={codeText} />
-					}
-
-					// Use the async file check component for potential file paths
-					return <InlineCodeWithFileCheck {...props} />
-				},
-				strong: (props: ComponentProps<"strong">) => {
-					// Check if this is an "Act Mode" strong element by looking for the keyboard shortcut
-					// Handle both string children and array of children cases
-					const childrenText = React.Children.toArray(props.children)
-						.map((child) => {
-							if (typeof child === "string") {
-								return child
-							}
-							if (typeof child === "object" && "props" in child && child.props.children) {
-								return String(child.props.children)
-							}
-							return ""
-						})
-						.join("")
-
-					// Case-insensitive check for "Act Mode (⌘⇧A)" pattern
-					// This ensures we only style the exact "Act Mode" mentions with keyboard shortcut
-					// Using case-insensitive flag to catch all capitalization variations
-					if (/^act mode\s*\(⌘⇧A\)$/i.test(childrenText)) {
-						return <ActModeHighlight />
-					}
-
-					return <strong {...props} />
-				},
-			},
-		},
-	})
-
-	useEffect(() => {
-		setMarkdown(markdown || "")
-	}, [markdown, setMarkdown])
-
 	return (
 	return (
 		<div className="inline-markdown-block">
 		<div className="inline-markdown-block">
 			<span
 			<span
@@ -396,7 +417,7 @@ const MarkdownBlock = memo(({ markdown, compact, showCursor }: MarkdownBlockProp
 					"inline-cursor-container": showCursor,
 					"inline-cursor-container": showCursor,
 					"[&>p]:m-0": compact,
 					"[&>p]:m-0": compact,
 				})}>
 				})}>
-				{reactContent}
+				{markdown ? <MemoizedMarkdown content={markdown} id="markdown-block" /> : markdown}
 			</span>
 			</span>
 		</div>
 		</div>
 	)
 	)

+ 16 - 0
webview-ui/src/theme.css

@@ -331,6 +331,22 @@
 		li > ol {
 		li > ol {
 			@apply my-1 ml-2;
 			@apply my-1 ml-2;
 		}
 		}
+		table {
+			@apply border-collapse w-full my-3;
+		}
+		th,
+		td {
+			@apply p-2 border border-border-panel text-left;
+		}
+		th {
+			@apply bg-code/20 font-semibold;
+		}
+		tr:nth-child(even) {
+			@apply bg-list-hover;
+		}
+		del {
+			@apply line-through opacity-70;
+		}
 
 
 		code:not(pre > code) {
 		code:not(pre > code) {
 			@apply font-mono text-preformat bg-code-block-background border border-text-separator rounded-xs py-0.5 whitespace-pre-line break-words text-editor-size;
 			@apply font-mono text-preformat bg-code-block-background border border-text-separator rounded-xs py-0.5 whitespace-pre-line break-words text-editor-size;

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.