Compare commits

...

16 Commits

Author SHA1 Message Date
Andrei Edell fb6ae14f3c undo some comment removals and cleanups to make PR easier to read 2025-02-25 10:36:50 -10:00
Andrei Edell fc422f253a delete old version of open image implementation 2025-02-25 10:36:50 -10:00
Andrei Edell 68de0e732c remove incorrect vendor prefix css 2025-02-25 10:36:50 -10:00
Andrei Edell ba28e09fa0 avoid XSS attacks by sanitizing the preview image urls and embeds 2025-02-25 10:36:50 -10:00
Andrei Edell b527031cab add the dashed border back 2025-02-25 10:36:50 -10:00
Andrei Edell 63fe6f21fa remove some old code 2025-02-25 10:36:50 -10:00
Andrei Edell d890895adc added changeset output 2025-02-25 10:36:50 -10:00
Andrei Edell 810d325f83 formatting fix 2025-02-25 10:36:50 -10:00
Andrei Edell 38e33348b1 default to plain text if rich response is loading 2025-02-25 10:36:49 -10:00
Andrei Edell 4ffef314a6 updated styling of mcp responses 2025-02-25 10:36:49 -10:00
Andrei Edell 94a2122653 header for response display 2025-02-25 10:36:49 -10:00
Andrei Edell 343b12a562 closer 2025-02-25 10:36:49 -10:00
Andrei Edell 2aadea75c3 almost totally working rich mcp response display with images and embeds 2025-02-25 10:36:49 -10:00
Andrei Edell b039ee7601 Open Graph link metadata display for MCP responses 2025-02-25 10:36:47 -10:00
Andrei Edell 1b42c584ee images now open in a webview tab 2025-02-25 10:36:07 -10:00
Andrei Edell 631a009384 showing images after mcp responses 2025-02-25 10:36:07 -10:00
12 changed files with 990 additions and 175 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add rich MCP responses with images and link previews
+22
View File
@@ -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",
@@ -11047,6 +11048,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",
+1
View File
@@ -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",
+59
View File
@@ -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"
@@ -635,6 +636,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
@@ -1906,6 +1918,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() {
+107
View File
@@ -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<OpenGraphData> {
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<boolean> {
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)
}
}
+12
View File
@@ -30,6 +30,8 @@ export interface ExtensionMessage {
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
| "commitSearchResults"
| "openGraphData"
| "isImageUrlResult"
text?: string
action?:
| "chatButtonClicked"
@@ -54,6 +56,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"
+7
View File
@@ -22,6 +22,7 @@ export interface WebviewMessage {
| "requestOllamaModels"
| "requestLmStudioModels"
| "openImage"
| "openInBrowser"
| "openFile"
| "openMention"
| "cancelTask"
@@ -50,6 +51,9 @@ export interface WebviewMessage {
| "searchCommits"
| "showMcpView"
| "fetchLatestMcpServersFromHub"
| "updateMcpTimeout"
| "fetchOpenGraphData"
| "checkIsImageUrl"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
@@ -68,6 +72,9 @@ export interface WebviewMessage {
serverName?: string
toolName?: string
autoApprove?: boolean
// For openInBrowser
url?: string
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+12
View File
@@ -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"
}
+2
View File
@@ -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",
+115 -175
View File
@@ -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<ChatRowProps, "onHeightChange"> {}
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(
<ChatRowContainer>
<ChatRowContent {...props} />
{shouldShowCheckpoints && <CheckpointOverlay messageTs={message.ts} />}
</ChatRowContainer>,
)
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 = () => (
<div
style={{
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
<VSCodeProgressRing />
</div>
</div>
)
const Markdown = memo(({ markdown }: { markdown?: string }) => {
return (
<div
style={{
wordBreak: "break-word",
overflowWrap: "anywhere",
marginBottom: -15,
marginTop: -15,
}}>
<MarkdownBlock markdown={markdown} />
</div>
)
})
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(
<ChatRowContainer>
<ChatRowContent {...props} />
{shouldShowCheckpoints && <CheckpointOverlay messageTs={message.ts} />}
</ChatRowContainer>,
)
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:
</span>
</div>
{/* <CodeAccordian
code={tool.content!}
path={tool.path!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> */}
<div
style={{
borderRadius: 3,
@@ -498,32 +520,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
/>
</>
)
// case "inspectSite":
// const isInspecting =
// isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
// return (
// <>
// <div style={headerStyle}>
// {isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
// <span style={{ fontWeight: "bold" }}>
// {message.type === "ask" ? (
// <>Cline wants to inspect this website:</>
// ) : (
// <>Cline is inspecting this website:</>
// )}
// </span>
// </div>
// <div
// style={{
// borderRadius: 3,
// border: "1px solid var(--vscode-editorGroup-border)",
// overflow: "hidden",
// backgroundColor: CODE_BLOCK_BG_COLOR,
// }}>
// <CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
// </div>
// </>
// )
default:
return null
}
@@ -570,10 +566,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{icon}
{title}
</div>
{/* <Terminal
rawOutput={command + (output ? "\n" + output : "")}
shouldAllowInput={!!isCommandExecuting && output.length > 0}
/> */}
<div
style={{
borderRadius: 3,
@@ -640,7 +632,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{useMcpServer.type === "access_mcp_resource" && (
<McpResourceRow
item={{
// Use the matched resource/template details, with fallbacks
...(findMatchingResourceOrTemplate(
useMcpServer.uri || "",
server?.resources,
@@ -650,7 +641,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
mimeType: "",
description: "",
}),
// Always use the actual URI from the request
uri: useMcpServer.uri || "",
}}
/>
@@ -742,6 +732,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
{/* {apiProvider === "" && (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor:
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
color: "var(--vscode-editor-foreground)",
padding: "6px 8px",
borderRadius: "3px",
margin: "10px 0 0 0",
fontSize: "12px",
}}>
<i
className="codicon codicon-warning"
style={{
marginRight: 6,
fontSize: 16,
color: "var(--vscode-errorForeground)",
}}></i>
<span>
Uh-oh this could be a problem on end. We've been alerted and
will resolve this ASAP. You can also{" "}
<a
href=""
style={{ color: "inherit", textDecoration: "underline" }}>
contact us
</a>
.
</span>
</div>
)} */}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
<br />
@@ -759,39 +782,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</>
)}
</p>
{/* {apiProvider === "" && (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor:
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
color: "var(--vscode-editor-foreground)",
padding: "6px 8px",
borderRadius: "3px",
margin: "10px 0 0 0",
fontSize: "12px",
}}>
<i
className="codicon codicon-warning"
style={{
marginRight: 6,
fontSize: 16,
color: "var(--vscode-errorForeground)",
}}></i>
<span>
Uh-oh, this could be a problem on end. We've been alerted and
will resolve this ASAP. You can also{" "}
<a
href=""
style={{ color: "inherit", textDecoration: "underline" }}>
contact us
</a>
.
</span>
</div>
)} */}
</>
)}
@@ -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 <McpResponseDisplay responseText={message.text || ""} />
case "text":
return (
<div>
@@ -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
</div>
</>
)
case "mcp_server_response":
return (
<>
<div style={{ paddingTop: 0 }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "12px",
textTransform: "uppercase",
}}>
Response
</div>
<CodeAccordian
code={message.text}
language="json"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
</div>
</>
)
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 = () => (
<div
style={{
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
<VSCodeProgressRing />
</div>
</div>
)
const Markdown = memo(({ markdown }: { markdown?: string }) => {
return (
<div
style={{
wordBreak: "break-word",
overflowWrap: "anywhere",
marginBottom: -15,
marginTop: -15,
}}>
<MarkdownBlock markdown={markdown} />
</div>
)
})
@@ -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<LinkPreviewProps> = ({ url }) => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [ogData, setOgData] = useState<OpenGraphData | null>(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 (
<div
className="link-preview-loading"
style={{
padding: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
}}>
<div
className="loading-spinner"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading preview for {new URL(url).hostname}...
</div>
)
}
// 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 (
<div
className="link-preview"
style={{
display: "flex",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
overflow: "hidden",
cursor: "pointer",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
{data.image && (
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
<img
src={DOMPurify.sanitize(data.image)}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
)}
<div
className="link-preview-content"
style={{
flex: 1,
padding: "12px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div
className="link-preview-title"
style={{
fontWeight: "bold",
marginBottom: "4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.title || "No title"}
</div>
<div
className="link-preview-url"
style={{
fontSize: "12px",
color: "var(--vscode-textLink-foreground, #3794ff)",
marginBottom: "8px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.siteName || new URL(url).hostname}
</div>
<div
className="link-preview-description"
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
textOverflow: "ellipsis",
}}>
{data.description || "No description available"}
</div>
</div>
</div>
)
}
export default LinkPreview
@@ -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<boolean> => {
// 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<void>[] = []
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<void>[] = []
// 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<McpResponseDisplayProps> = ({ 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<UrlMatch[]>([])
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 <UrlText>{responseText}</UrlText>
}
// 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<string>()
// Add the text before the first URL
if (urlMatches.length === 0) {
segments.push(<UrlText key={`segment-${segmentIndex}`}>{responseText}</UrlText>)
} 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(
<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex, index)}</UrlText>,
)
}
// Add the URL text itself
segments.push(<UrlText key={`url-${segmentIndex++}`}>{fullMatch}</UrlText>)
// 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(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<img
src={DOMPurify.sanitize(url)}
alt={`Image for ${url}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
cursor: "pointer",
}}
onClick={() => {
const formattedUrl = formatUrlForOpening(url)
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(formattedUrl),
})
}}
/>
</div>,
)
} else if (!processedUrls.has(url)) {
// For non-image URLs, only show the preview once
segments.push(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<LinkPreview url={formatUrlForOpening(url)} />
</div>,
)
// 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(<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex)}</UrlText>)
}
}
return <>{segments}</>
}
return null
}
try {
return (
<ResponseContainer>
<ResponseHeader>
<span className="header-title">Response</span>
<ToggleSwitch>
<span className="toggle-label">{displayMode === "rich" ? "Rich Display" : "Plain Text"}</span>
<div className={`toggle-container ${displayMode === "rich" ? "active" : ""}`} onClick={toggleDisplayMode}>
<div className="toggle-handle"></div>
</div>
</ToggleSwitch>
</ResponseHeader>
<div className="response-content">{renderContent()}</div>
</ResponseContainer>
)
} catch (error) {
console.error("Error parsing MCP response:", error)
return (
<ResponseContainer>
<ResponseHeader>
<span className="header-title">Response</span>
</ResponseHeader>
<div className="response-content">
<div>Error parsing response:</div>
<UrlText>{responseText}</UrlText>
</div>
</ResponseContainer>
)
}
}
export default McpResponseDisplay