diff --git a/.changeset/old-dancers-smell.md b/.changeset/old-dancers-smell.md new file mode 100644 index 0000000000..45c0af9d1c --- /dev/null +++ b/.changeset/old-dancers-smell.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +Add rich MCP responses with images and link previews diff --git a/package-lock.json b/package-lock.json index 2bafe4a886..0dae4ee453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", + "open-graph-scraper": "^6.9.0", "openai": "^4.83.0", "os-name": "^6.0.0", "p-wait-for": "^5.0.2", @@ -10665,6 +10666,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-graph-scraper": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.9.0.tgz", + "integrity": "sha512-1KoV5v6GT0/MqlryrVGQROhEAD4u8wC3VjYOxsnhj3mWeGJ6N6nF/rbrcZREFr+kiYm9I5LMrzdK9t9hBMbL2Q==", + "license": "MIT", + "dependencies": { + "chardet": "^2.0.0", + "cheerio": "^1.0.0-rc.12", + "iconv-lite": "^0.6.3", + "undici": "^6.21.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/open-graph-scraper/node_modules/chardet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.0.0.tgz", + "integrity": "sha512-xVgPpulCooDjY6zH4m9YW3jbkaBe3FKIAvF5sj5t7aBNsVl2ljIE+xwJ4iNgiDZHFQvNIpjdKdVOQvvk5ZfxbQ==", + "license": "MIT" + }, "node_modules/openai": { "version": "4.83.0", "resolved": "https://registry.npmjs.org/openai/-/openai-4.83.0.tgz", diff --git a/package.json b/package.json index 74d72afe49..9fa64f199a 100644 --- a/package.json +++ b/package.json @@ -264,6 +264,7 @@ "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", + "open-graph-scraper": "^6.9.0", "openai": "^4.83.0", "os-name": "^6.0.0", "p-wait-for": "^5.0.2", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a042e92363..5c486c1521 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -10,6 +10,7 @@ import * as vscode from "vscode" import { buildApiHandler } from "../../api" import { downloadTask } from "../../integrations/misc/export-markdown" import { openFile, openImage } from "../../integrations/misc/open-file" +import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview" import { selectImages } from "../../integrations/misc/process-images" import { getTheme } from "../../integrations/theme/getTheme" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" @@ -663,6 +664,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { case "openImage": openImage(message.text!) break + case "openInBrowser": + if (message.url) { + vscode.env.openExternal(vscode.Uri.parse(message.url)) + } + break + case "fetchOpenGraphData": + this.fetchOpenGraphData(message.text!) + break + case "checkIsImageUrl": + this.checkIsImageUrl(message.text!) + break case "openFile": openFile(message.text!) break @@ -1955,6 +1967,53 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont return await this.context.secrets.get(key) } + // Open Graph Data + + async fetchOpenGraphData(url: string) { + try { + // Use the fetchOpenGraphData function from link-preview.ts + const ogData = await fetchOpenGraphData(url) + + // Send the data back to the webview + await this.postMessageToWebview({ + type: "openGraphData", + openGraphData: ogData, + url: url, + }) + } catch (error) { + console.error(`Error fetching Open Graph data for ${url}:`, error) + // Send an error response + await this.postMessageToWebview({ + type: "openGraphData", + error: `Failed to fetch Open Graph data: ${error}`, + url: url, + }) + } + } + + // Check if a URL is an image + async checkIsImageUrl(url: string) { + try { + // Check if the URL is an image + const isImage = await isImageUrl(url) + + // Send the result back to the webview + await this.postMessageToWebview({ + type: "isImageUrlResult", + isImage, + url, + }) + } catch (error) { + console.error(`Error checking if URL is an image: ${url}`, error) + // Send an error response + await this.postMessageToWebview({ + type: "isImageUrlResult", + isImage: false, + url, + }) + } + } + // dev async resetState() { diff --git a/src/integrations/misc/link-preview.ts b/src/integrations/misc/link-preview.ts new file mode 100644 index 0000000000..ad2bdea194 --- /dev/null +++ b/src/integrations/misc/link-preview.ts @@ -0,0 +1,107 @@ +import axios from "axios" +import ogs from "open-graph-scraper" + +export interface OpenGraphData { + title?: string + description?: string + image?: string + url?: string + siteName?: string + type?: string +} + +/** + * Fetches Open Graph metadata from a URL + * @param url The URL to fetch metadata from + * @returns Promise resolving to OpenGraphData + */ +export async function fetchOpenGraphData(url: string): Promise { + try { + const options = { + url: url, + timeout: 5000, + headers: { + "user-agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)", + }, + onlyGetOpenGraphInfo: false, // Get all metadata, not just Open Graph + fetchOptions: { + redirect: "follow", // Follow redirects + } as any, + } + + const { result } = await ogs(options) + + // Use type assertion to avoid TypeScript errors + const data = result as any + + // Handle image URLs + let imageUrl = data.ogImage?.[0]?.url || data.twitterImage?.[0]?.url + + // If the image URL is relative, make it absolute + if (imageUrl && (imageUrl.startsWith("/") || imageUrl.startsWith("./"))) { + try { + // Extract the base URL and make the relative URL absolute + const urlObj = new URL(url) + const baseUrl = `${urlObj.protocol}//${urlObj.hostname}` + imageUrl = new URL(imageUrl, baseUrl).href + } catch (error) { + console.error(`Error converting relative URL to absolute: ${imageUrl}`, error) + } + } + + return { + title: data.ogTitle || data.twitterTitle || data.dcTitle || data.title || new URL(url).hostname, + description: + data.ogDescription || + data.twitterDescription || + data.dcDescription || + data.description || + "No description available", + image: imageUrl, + url: data.ogUrl || url, + siteName: data.ogSiteName || new URL(url).hostname, + type: data.ogType, + } + } catch (error) { + console.error(`Error fetching Open Graph data for ${url}:`, error) + // Return basic information based on the URL + try { + const urlObj = new URL(url) + return { + title: urlObj.hostname, + description: url, + url: url, + siteName: urlObj.hostname, + } + } catch { + return { + title: url, + description: url, + url: url, + } + } + } +} + +/** + * Checks if a URL is an image by making a HEAD request and checking the content type + * @param url The URL to check + * @returns Promise resolving to boolean indicating if the URL is an image + */ +export async function isImageUrl(url: string): Promise { + try { + const response = await axios.head(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)", + }, + timeout: 3000, + }) + + const contentType = response.headers["content-type"] + return contentType && contentType.startsWith("image/") + } catch (error) { + console.error(`Error checking if URL is an image: ${url}`, error) + // If we can't determine, fall back to checking the file extension + return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url) + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 7ee199fffb..bcad63b788 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -31,6 +31,8 @@ export interface ExtensionMessage { | "mcpMarketplaceCatalog" | "mcpDownloadDetails" | "commitSearchResults" + | "openGraphData" + | "isImageUrlResult" text?: string action?: | "chatButtonClicked" @@ -55,6 +57,16 @@ export interface ExtensionMessage { error?: string mcpDownloadDetails?: McpDownloadResponse commits?: GitCommit[] + openGraphData?: { + title?: string + description?: string + image?: string + url?: string + siteName?: string + type?: string + } + url?: string + isImage?: boolean } export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index cd4e877604..873a7390f9 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -22,6 +22,7 @@ export interface WebviewMessage { | "requestOllamaModels" | "requestLmStudioModels" | "openImage" + | "openInBrowser" | "openFile" | "openMention" | "cancelTask" @@ -52,6 +53,9 @@ export interface WebviewMessage { | "fetchLatestMcpServersFromHub" | "telemetrySetting" | "openSettings" + | "updateMcpTimeout" + | "fetchOpenGraphData" + | "checkIsImageUrl" // | "relaunchChromeDebugMode" text?: string disabled?: boolean @@ -70,6 +74,9 @@ export interface WebviewMessage { serverName?: string toolName?: string autoApprove?: boolean + + // For openInBrowser + url?: string } export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 1fa57354ba..81b6f05e6f 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "@floating-ui/react": "^0.27.4", + "@types/dompurify": "^3.0.5", "@vscode/webview-ui-toolkit": "^1.4.0", "debounce": "^2.1.1", + "dompurify": "^3.2.4", "fast-deep-equal": "^3.1.3", "fuse.js": "^7.0.0", "fzf": "^0.5.2", @@ -4979,6 +4981,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -8981,6 +8992,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/webview-ui/package.json b/webview-ui/package.json index fd3743ad01..97df450bbe 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -4,8 +4,10 @@ "private": true, "dependencies": { "@floating-ui/react": "^0.27.4", + "@types/dompurify": "^3.0.5", "@vscode/webview-ui-toolkit": "^1.4.0", "debounce": "^2.1.1", + "dompurify": "^3.2.4", "fast-deep-equal": "^3.1.3", "fuse.js": "^7.0.0", "fzf": "^0.5.2", diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index f77019fc3f..88f82c1366 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -25,6 +25,7 @@ import McpResourceRow from "../mcp/McpResourceRow" import McpToolRow from "../mcp/McpToolRow" import { highlightMentions } from "./TaskHeader" import { CheckmarkControl } from "../common/CheckmarkControl" +import McpResponseDisplay from "../mcp/McpResponseDisplay" const ChatRowContainer = styled.div` padding: 10px 6px 10px 15px; @@ -46,62 +47,87 @@ interface ChatRowProps { interface ChatRowContentProps extends Omit {} -const ChatRow = memo( - (props: ChatRowProps) => { - const { isLast, onHeightChange, message, lastModifiedMessage } = props - // Store the previous height to compare with the current height - // This allows us to detect changes without causing re-renders - const prevHeightRef = useRef(0) - - // NOTE: for tools that are interrupted and not responded to (approved or rejected), there won't be a checkpoint hash - let shouldShowCheckpoints = - message.lastCheckpointHash != null && - (message.say === "tool" || - message.ask === "tool" || - message.say === "command" || - message.ask === "command" || - // message.say === "completion_result" || - // message.ask === "completion_result" || - message.say === "use_mcp_server" || - message.ask === "use_mcp_server") - - if (shouldShowCheckpoints && isLast) { - shouldShowCheckpoints = - lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task" - } - - const [chatrow, { height }] = useSize( - - - {shouldShowCheckpoints && } - , - ) - - useEffect(() => { - // used for partials, command output, etc. - // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete - const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that - // height starts off at Infinity - if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) { - if (!isInitialRender) { - onHeightChange(height > prevHeightRef.current) - } - prevHeightRef.current = height - } - }, [height, isLast, onHeightChange, message]) - - // we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered - return chatrow - }, - // memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change - deepEqual, +export const ProgressIndicator = () => ( +
+
+ +
+
) +const Markdown = memo(({ markdown }: { markdown?: string }) => { + return ( +
+ +
+ ) +}) + +const ChatRow = memo((props: ChatRowProps) => { + const { isLast, onHeightChange, message, lastModifiedMessage } = props + // Store the previous height to compare with the current height + // This allows us to detect changes without causing re-renders + const prevHeightRef = useRef(0) + + // NOTE: for tools that are interrupted and not responded to (approved or rejected) there won't be a checkpoint hash + let shouldShowCheckpoints = + message.lastCheckpointHash != null && + (message.say === "tool" || + message.ask === "tool" || + message.say === "command" || + message.ask === "command" || + // message.say === "completion_result" || + // message.ask === "completion_result" || + message.say === "use_mcp_server" || + message.ask === "use_mcp_server") + + if (shouldShowCheckpoints && isLast) { + shouldShowCheckpoints = lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task" + } + + const [chatrow, { height }] = useSize( + + + {shouldShowCheckpoints && } + , + ) + + useEffect(() => { + // used for partials command output etc. + // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete + const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that + // height starts off at Infinity + if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) { + if (!isInitialRender) { + onHeightChange(height > prevHeightRef.current) + } + prevHeightRef.current = height + } + }, [height, isLast, onHeightChange, message]) + + // we cannot return null as virtuoso does not support it so we use a separate visibleMessages array to filter out messages that should not be rendered + return chatrow +}, +// memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change +deepEqual) + export default ChatRow export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => { const { mcpServers, mcpMarketplaceCatalog } = useExtensionState() - const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false) const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { @@ -111,11 +137,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi } return [undefined, undefined, undefined] }, [message.text, message.say]) - // when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything + + // when resuming task last won't be api_req_failed but a resume_task message so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything const apiRequestFailedMessage = isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried ? lastModifiedMessage?.text : undefined + const isCommandExecuting = isLast && (lastModifiedMessage?.ask === "command" || lastModifiedMessage?.say === "command") && @@ -367,12 +395,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi Cline wants to read this file: - {/* */}
) - // case "inspectSite": - // const isInspecting = - // isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images - // return ( - // <> - //
- // {isInspecting ? : toolIcon("inspect")} - // - // {message.type === "ask" ? ( - // <>Cline wants to inspect this website: - // ) : ( - // <>Cline is inspecting this website: - // )} - // - //
- //
- // - //
- // - // ) default: return null } @@ -570,10 +566,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi {icon} {title}
- {/* 0} - /> */}
@@ -742,6 +732,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi color: "var(--vscode-errorForeground)", }}> {apiRequestFailedMessage || apiReqStreamingFailedMessage} + + {/* {apiProvider === "" && ( +
+ + + Uh-oh this could be a problem on end. We've been alerted and + will resolve this ASAP. You can also{" "} + + contact us + + . + +
+ )} */} {apiRequestFailedMessage?.toLowerCase().includes("powershell") && ( <>
@@ -759,39 +782,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi )}

- - {/* {apiProvider === "" && ( -
- - - Uh-oh, this could be a problem on end. We've been alerted and - will resolve this ASAP. You can also{" "} - - contact us - - . - -
- )} */} )} @@ -809,6 +799,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ) case "api_req_finished": return null // we should never see this message type + case "mcp_server_response": + return case "text": return (
@@ -825,7 +817,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi // marginBottom: 15, cursor: "pointer", color: "var(--vscode-descriptionForeground)", - + fontStyle: "italic", overflow: "hidden", }}> @@ -1101,28 +1093,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
) - case "mcp_server_response": - return ( - <> -
-
- Response -
- -
- - ) default: return ( <> @@ -1174,7 +1144,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ) case "completion_result": if (message.text) { - // FIXME: is this ever even used? const hasChanges = message.text.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false const text = hasChanges ? message.text.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text return ( @@ -1247,32 +1216,3 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi } } } - -export const ProgressIndicator = () => ( -
-
- -
-
-) - -const Markdown = memo(({ markdown }: { markdown?: string }) => { - return ( -
- -
- ) -}) diff --git a/webview-ui/src/components/mcp/LinkPreview.tsx b/webview-ui/src/components/mcp/LinkPreview.tsx new file mode 100644 index 0000000000..1f017df956 --- /dev/null +++ b/webview-ui/src/components/mcp/LinkPreview.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from "react" +import { vscode } from "../../utils/vscode" +import DOMPurify from 'dompurify'; + +interface OpenGraphData { + title?: string + description?: string + image?: string + url?: string + siteName?: string + type?: string +} + +interface LinkPreviewProps { + url: string +} + +const LinkPreview: React.FC = ({ url }) => { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [ogData, setOgData] = useState(null) + + useEffect(() => { + const fetchOpenGraphData = async () => { + try { + setLoading(true) + + // Send a message to the extension to fetch Open Graph data + vscode.postMessage({ + type: "fetchOpenGraphData", + text: url, + }) + + // Set up a listener for the response + const messageListener = (event: MessageEvent) => { + const message = event.data + if (message.type === "openGraphData" && message.url === url) { + setOgData(message.openGraphData) + setLoading(false) + window.removeEventListener("message", messageListener) + } + } + + window.addEventListener("message", messageListener) + + // Clean up the listener if the component unmounts + return () => { + window.removeEventListener("message", messageListener) + } + } catch (err) { + setError("Failed to fetch preview data") + setLoading(false) + } + } + + // Fetch Open Graph data immediately when component mounts + fetchOpenGraphData() + }, [url]) + + // Fallback display while loading + if (loading) { + return ( +
+
+ + Loading preview for {new URL(url).hostname}... +
+ ) + } + + // Create a fallback object if ogData is null + const data = ogData || { + title: new URL(url).hostname, + description: "No description available", + siteName: new URL(url).hostname, + url: url, + } + + // Render the Open Graph preview + return ( +
{ + vscode.postMessage({ + type: "openInBrowser", + url: DOMPurify.sanitize(url), + }) + }}> + {data.image && ( +
+ +
+ )} + +
+
+ {data.title || "No title"} +
+ +
+ {data.siteName || new URL(url).hostname} +
+ +
+ {data.description || "No description available"} +
+
+
+ ) +} + +export default LinkPreview diff --git a/webview-ui/src/components/mcp/McpResponseDisplay.tsx b/webview-ui/src/components/mcp/McpResponseDisplay.tsx new file mode 100644 index 0000000000..6bf0441685 --- /dev/null +++ b/webview-ui/src/components/mcp/McpResponseDisplay.tsx @@ -0,0 +1,460 @@ +import React, { useEffect, useState, useCallback } from "react" +import { vscode } from "../../utils/vscode" +import LinkPreview from "./LinkPreview" +import styled from "styled-components" +import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" +import DOMPurify from 'dompurify'; + +// We'll use the backend isImageUrl function for HEAD requests +// This is a client-side fallback for data URLs and obvious image extensions +const isImageUrlSync = (str: string): boolean => { + // Check for data URLs which are definitely images + if (str.startsWith("data:image/")) { + return true + } + + // Check for common image file extensions + return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null +} + +export const isUrl = (str: string): boolean => { + // Basic URL validation + const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/ + return urlPattern.test(str) +} + +// Function to check if a URL is an image using HEAD request +export const checkIfImageUrl = async (url: string): Promise => { + // For data URLs, we can check synchronously + if (url.startsWith("data:image/")) { + return true + } + + // For http/https URLs, we need to send a message to the extension + if (url.startsWith("http")) { + try { + // Create a promise that will resolve when we get a response + return new Promise((resolve) => { + // Set up a one-time listener for the response + const messageListener = (event: MessageEvent) => { + const message = event.data + if (message.type === "isImageUrlResult" && message.url === url) { + window.removeEventListener("message", messageListener) + resolve(message.isImage) + } + } + + window.addEventListener("message", messageListener) + + // Send the request to the extension + vscode.postMessage({ + type: "checkIsImageUrl", + text: url, + }) + + // Set a timeout to avoid hanging indefinitely + setTimeout(() => { + window.removeEventListener("message", messageListener) + // Fall back to extension check + resolve(isImageUrlSync(url)) + }, 3000) + }) + } catch (error) { + console.error("Error checking if URL is an image:", error) + return isImageUrlSync(url) + } + } + + // Fall back to extension check for other URLs + return isImageUrlSync(url) +} + +// No longer needed as our regex directly extracts the URL part + +// Helper to ensure URL is in a format that can be opened +export const formatUrlForOpening = (url: string): string => { + // If it's a data URI, return as is + if (url.startsWith("data:image/")) { + return url + } + + // If it's a regular URL but doesn't have a protocol, add https:// + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return `https://${url}` + } + + return url +} + +// Find all URLs (both image and regular) in an object +export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => { + const imageUrls: string[] = [] + const regularUrls: string[] = [] + const pendingChecks: Promise[] = [] + + if (typeof obj === "object" && obj !== null) { + for (const value of Object.values(obj)) { + if (typeof value === "string") { + // First check with synchronous method + if (isImageUrlSync(value)) { + imageUrls.push(value) + } else if (isUrl(value)) { + // For URLs that don't obviously look like images, we'll check asynchronously + const checkPromise = checkIfImageUrl(value).then((isImage) => { + if (isImage) { + imageUrls.push(value) + } else { + regularUrls.push(value) + } + }) + pendingChecks.push(checkPromise) + } + } else if (typeof value === "object") { + const nestedUrlsPromise = findUrls(value).then((nestedUrls) => { + imageUrls.push(...nestedUrls.imageUrls) + regularUrls.push(...nestedUrls.regularUrls) + }) + pendingChecks.push(nestedUrlsPromise) + } + } + } + + // Wait for all async checks to complete + await Promise.all(pendingChecks) + + return { imageUrls, regularUrls } +} + +// Extract URLs from text using regex +export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => { + const imageUrls: string[] = [] + const regularUrls: string[] = [] + const pendingChecks: Promise[] = [] + + // Match URLs with image: prefix and extract just the URL part + const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g) + if (imageMatches) { + // Extract just the URL part from matches with image: prefix + const extractedUrls = imageMatches + .map((match) => { + const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match) + return urlMatch ? urlMatch[1] : null + }) + .filter(Boolean) as string[] + + imageUrls.push(...extractedUrls) + } + + // Match all URLs (including those that might be in the middle of paragraphs) + const urlMatches = text.match(/https?:\/\/[^\s]+/g) + if (urlMatches) { + // Filter out URLs that are already in imageUrls + const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url)) + + // Check each URL to see if it's an image + for (const url of filteredUrls) { + // First check with synchronous method + if (isImageUrlSync(url)) { + imageUrls.push(url) + } else { + // For URLs that don't obviously look like images, we'll check asynchronously + const checkPromise = checkIfImageUrl(url).then((isImage) => { + if (isImage) { + imageUrls.push(url) + } else { + regularUrls.push(url) + } + }) + pendingChecks.push(checkPromise) + } + } + } + + // Wait for all async checks to complete + await Promise.all(pendingChecks) + + return { imageUrls, regularUrls } +} + +const ResponseHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 10px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; + border-bottom: 1px dashed var(--vscode-editorGroup-border); + margin-bottom: 8px; + + .header-title { + display: flex; + align-items: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 8px; + } +` + +const ToggleSwitch = styled.div` + display: flex; + align-items: center; + font-size: 12px; + color: var(--vscode-descriptionForeground); + + .toggle-label { + margin-right: 8px; + } + + .toggle-container { + position: relative; + width: 40px; + height: 20px; + background-color: var(--vscode-button-secondaryBackground); + border-radius: 10px; + cursor: pointer; + transition: background-color 0.3s; + } + + .toggle-container.active { + background-color: var(--vscode-button-background); + } + + .toggle-handle { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background-color: var(--vscode-button-foreground); + border-radius: 50%; + transition: transform 0.3s; + } + + .toggle-container.active .toggle-handle { + transform: translateX(20px); + } +` + +const ResponseContainer = styled.div` + position: relative; + font-family: var(--vscode-editor-font-family, monospace); + font-size: var(--vscode-editor-font-size, 12px); + background-color: ${CODE_BLOCK_BG_COLOR}; + color: var(--vscode-editor-foreground, #d4d4d4); + border-radius: 3px; + border: 1px solid var(--vscode-editorGroup-border); + overflow: hidden; + + .response-content { + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + padding: 10px; + } +` + +// Style for URL text to ensure proper wrapping +const UrlText = styled.div` + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: break-word; + font-family: var(--vscode-editor-font-family, monospace); + font-size: var(--vscode-editor-font-size, 12px); +` + +interface McpResponseDisplayProps { + responseText: string +} + +// Represents a URL found in the text with its position and metadata +interface UrlMatch { + url: string // The actual URL + fullMatch: string // The full matched text (including any prefix like "image:") + index: number // Position in the text + isImage: boolean // Whether this URL is an image + isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates) +} + +const McpResponseDisplay: React.FC = ({ responseText }) => { + const [isLoading, setIsLoading] = useState(true) + const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => { + // Get saved preference from localStorage, default to 'rich' + const savedMode = localStorage.getItem("mcpDisplayMode") + return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain" + }) + const [urlMatches, setUrlMatches] = useState([]) + + const toggleDisplayMode = useCallback(() => { + const newMode = displayMode === "rich" ? "plain" : "rich" + setDisplayMode(newMode) + localStorage.setItem("mcpDisplayMode", newMode) + }, [displayMode]) + + // Find all URLs in the text and determine if they're images + useEffect(() => { + const processResponse = async () => { + setIsLoading(true) + + try { + const text = responseText || "" + const matches: UrlMatch[] = [] + + const urlRegex = /https?:\/\/[^\s]+/g + let urlMatch: RegExpExecArray | null + + while ((urlMatch = urlRegex.exec(text)) !== null) { + const url = urlMatch[0] + const fullMatch = url + + matches.push({ + url, + fullMatch, + index: urlMatch.index, + isImage: false, // Will check later + isProcessed: false, + }) + } + + // Check if URLs are images + for (const match of matches) { + match.isImage = await checkIfImageUrl(match.url) + } + + // Sort by position in the text + matches.sort((a, b) => a.index - b.index) + + setUrlMatches(matches) + } catch (error) { + console.error("Error processing MCP response:", error) + } finally { + setIsLoading(false) + } + } + + processResponse() + }, [responseText]) + + // Function to render content based on display mode + const renderContent = () => { + // For plain text mode, just show the text + if (displayMode === "plain" || isLoading) { + return {responseText} + } + + // For rich display mode, show the text with embedded content + if (displayMode === "rich" && !isLoading) { + // Create an array of text segments and embedded content + const segments: JSX.Element[] = [] + let lastIndex = 0 + let segmentIndex = 0 + + // Reset the processed flag for all URLs + const processedUrls = new Set() + + // Add the text before the first URL + if (urlMatches.length === 0) { + segments.push({responseText}) + } else { + for (let i = 0; i < urlMatches.length; i++) { + const match = urlMatches[i] + const { url, fullMatch, index } = match + + // Add text segment before this URL + if (index > lastIndex) { + segments.push( + {responseText.substring(lastIndex, index)}, + ) + } + + // Add the URL text itself + segments.push({fullMatch}) + + // Calculate the end position of this URL in the text + const urlEndIndex = index + fullMatch.length + + // Add embedded content after the URL + if (match.isImage) { + segments.push( +
+ {`Image { + const formattedUrl = formatUrlForOpening(url) + vscode.postMessage({ + type: "openInBrowser", + url: DOMPurify.sanitize(formattedUrl), + }) + }} + /> +
, + ) + } else if (!processedUrls.has(url)) { + // For non-image URLs, only show the preview once + segments.push( +
+ +
, + ) + + // Mark this URL as processed + processedUrls.add(url) + } + + // Update lastIndex for next segment + lastIndex = urlEndIndex + } + + // Add any remaining text after the last URL + if (lastIndex < responseText.length) { + segments.push({responseText.substring(lastIndex)}) + } + } + + return <>{segments} + } + + return null + } + + try { + return ( + + + Response + + {displayMode === "rich" ? "Rich Display" : "Plain Text"} +
+
+
+
+
+ +
{renderContent()}
+
+ ) + } catch (error) { + console.error("Error parsing MCP response:", error) + return ( + + + Response + +
+
Error parsing response:
+ {responseText} +
+
+ ) + } +} + +export default McpResponseDisplay