diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 5037370f24f5..378506973c29 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -146,6 +146,7 @@ export const clineSays = [ "api_req_retry_delayed", "api_req_deleted", "text", + "image", "reasoning", "completion_result", "user_feedback", diff --git a/src/core/tools/generateImageTool.ts b/src/core/tools/generateImageTool.ts index 775637f34b18..749e7cff9aa6 100644 --- a/src/core/tools/generateImageTool.ts +++ b/src/core/tools/generateImageTool.ts @@ -8,7 +8,6 @@ import { fileExistsAtPath } from "../../utils/fs" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { safeWriteJson } from "../../utils/safeWriteJson" import { OpenRouterHandler } from "../../api/providers/openrouter" // Hardcoded list of image generation models for now @@ -237,12 +236,18 @@ export async function generateImageTool( cline.didEditFile = true - // Display the generated image in the chat using a text message with the image - await cline.say("text", getReadablePath(cline.cwd, finalPath), [result.imageData]) - // Record successful tool usage cline.recordToolUsage("generate_image") + // Get the webview URI for the image + const provider = cline.providerRef.deref() + const fullImagePath = path.join(cline.cwd, finalPath) + + // Convert to webview URI if provider is available + const imageUri = provider?.convertToWebviewUri?.(fullImagePath) ?? vscode.Uri.file(fullImagePath).toString() + + // Send the image with the webview URI + await cline.say("image", JSON.stringify({ imageUri, imagePath: fullImagePath })) pushToolResult(formatResponse.toolResult(getReadablePath(cline.cwd, finalPath))) return diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bef8d766ac29..bec060ba982f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -600,9 +600,17 @@ export class ClineProvider setTtsSpeed(ttsSpeed ?? 1) }) + // Set up webview options with proper resource roots + const resourceRoots = [this.contextProxy.extensionUri] + + // Add workspace folders to allow access to workspace files + if (vscode.workspace.workspaceFolders) { + resourceRoots.push(...vscode.workspace.workspaceFolders.map((folder) => folder.uri)) + } + webviewView.webview.options = { enableScripts: true, - localResourceRoots: [this.contextProxy.extensionUri], + localResourceRoots: resourceRoots, } webviewView.webview.html = @@ -2466,4 +2474,40 @@ export class ClineProvider public get cwd() { return getWorkspacePath() } + + /** + * Convert a file path to a webview-accessible URI + * This method safely converts file paths to URIs that can be loaded in the webview + * + * @param filePath - The absolute file path to convert + * @returns The webview URI string, or the original file URI if conversion fails + * @throws {Error} When webview is not available + * @throws {TypeError} When file path is invalid + */ + public convertToWebviewUri(filePath: string): string { + try { + const fileUri = vscode.Uri.file(filePath) + + // Check if we have a webview available + if (this.view?.webview) { + const webviewUri = this.view.webview.asWebviewUri(fileUri) + return webviewUri.toString() + } + + // Specific error for no webview available + const error = new Error("No webview available for URI conversion") + console.error(error.message) + // Fallback to file URI if no webview available + return fileUri.toString() + } catch (error) { + // More specific error handling + if (error instanceof TypeError) { + console.error("Invalid file path provided for URI conversion:", error) + } else { + console.error("Failed to convert to webview URI:", error) + } + // Return file URI as fallback + return vscode.Uri.file(filePath).toString() + } + } } diff --git a/src/integrations/misc/image-handler.ts b/src/integrations/misc/image-handler.ts index f35f700749c7..7a2e7da24c72 100644 --- a/src/integrations/misc/image-handler.ts +++ b/src/integrations/misc/image-handler.ts @@ -4,8 +4,46 @@ import * as vscode from "vscode" import { getWorkspacePath } from "../../utils/path" import { t } from "../../i18n" -export async function openImage(dataUri: string, options?: { values?: { action?: string } }) { - const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/) +export async function openImage(dataUriOrPath: string, options?: { values?: { action?: string } }) { + // Check if it's a file path (absolute or relative) + const isFilePath = + !dataUriOrPath.startsWith("data:") && + !dataUriOrPath.startsWith("http:") && + !dataUriOrPath.startsWith("https:") && + !dataUriOrPath.startsWith("vscode-resource:") && + !dataUriOrPath.startsWith("file+.vscode-resource") + + if (isFilePath) { + // Handle file path - open directly in VSCode + try { + // Resolve the path relative to workspace if needed + let filePath = dataUriOrPath + if (!path.isAbsolute(filePath)) { + const workspacePath = getWorkspacePath() + if (workspacePath) { + filePath = path.join(workspacePath, filePath) + } + } + + const fileUri = vscode.Uri.file(filePath) + + // Check if this is a copy action + if (options?.values?.action === "copy") { + await vscode.env.clipboard.writeText(filePath) + vscode.window.showInformationMessage(t("common:info.path_copied_to_clipboard")) + return + } + + // Open the image file directly + await vscode.commands.executeCommand("vscode.open", fileUri) + } catch (error) { + vscode.window.showErrorMessage(t("common:errors.error_opening_image", { error })) + } + return + } + + // Handle data URI (existing logic) + const matches = dataUriOrPath.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/) if (!matches) { vscode.window.showErrorMessage(t("common:errors.invalid_data_uri")) return diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ad33ae9187f4..749c19fe59c7 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1159,6 +1159,17 @@ export const ChatRowContent = ({ return case "user_edit_todos": return {}} /> + case "image": + // Parse the JSON to get imageUri and imagePath + const imageInfo = safeJsonParse<{ imageUri: string; imagePath: string }>(message.text || "{}") + if (!imageInfo) { + return null + } + return ( +
+ +
+ ) default: return ( <> diff --git a/webview-ui/src/components/common/ImageBlock.tsx b/webview-ui/src/components/common/ImageBlock.tsx index b8ed69eeb6d0..c2c8231b9af5 100644 --- a/webview-ui/src/components/common/ImageBlock.tsx +++ b/webview-ui/src/components/common/ImageBlock.tsx @@ -1,15 +1,66 @@ import React from "react" import { ImageViewer } from "./ImageViewer" +/** + * Props for the ImageBlock component + */ interface ImageBlockProps { - imageData: string + /** + * The webview-accessible URI for rendering the image. + * This is the preferred format for new image generation tools. + * Should be a URI that can be directly loaded in the webview context. + */ + imageUri?: string + + /** + * The actual file path for display purposes and file operations. + * Used to show the path to the user and for opening the file in the editor. + * This is typically an absolute or relative path to the image file. + */ + imagePath?: string + + /** + * Base64 data or regular URL for backward compatibility. + * @deprecated Use imageUri instead for new implementations. + * This is maintained for compatibility with Mermaid diagrams and legacy code. + */ + imageData?: string + + /** + * Optional path for Mermaid diagrams. + * @deprecated Use imagePath instead for new implementations. + * This is maintained for backward compatibility with existing Mermaid diagram rendering. + */ path?: string } -export default function ImageBlock({ imageData, path }: ImageBlockProps) { +export default function ImageBlock({ imageUri, imagePath, imageData, path }: ImageBlockProps) { + // Determine which props to use based on what's provided + let finalImageUri: string + let finalImagePath: string | undefined + + if (imageUri) { + // New format: explicit imageUri and imagePath + finalImageUri = imageUri + finalImagePath = imagePath + } else if (imageData) { + // Legacy format: use imageData as direct URI (for Mermaid diagrams) + finalImageUri = imageData + finalImagePath = path + } else { + // No valid image data provided + console.error("ImageBlock: No valid image data provided") + return null + } + return (
- +
) } diff --git a/webview-ui/src/components/common/ImageViewer.tsx b/webview-ui/src/components/common/ImageViewer.tsx index bb2f6791a4ad..6c2832d05012 100644 --- a/webview-ui/src/components/common/ImageViewer.tsx +++ b/webview-ui/src/components/common/ImageViewer.tsx @@ -13,17 +13,17 @@ const MIN_ZOOM = 0.5 const MAX_ZOOM = 20 export interface ImageViewerProps { - imageData: string // base64 data URL or regular URL + imageUri: string // The URI to use for rendering (webview URI, base64, or regular URL) + imagePath?: string // The actual file path for display and opening alt?: string - path?: string showControls?: boolean className?: string } export function ImageViewer({ - imageData, + imageUri, + imagePath, alt = "Generated image", - path, showControls = true, className = "", }: ImageViewerProps) { @@ -33,6 +33,7 @@ export function ImageViewer({ const [isHovering, setIsHovering] = useState(false) const [isDragging, setIsDragging] = useState(false) const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }) + const [imageError, setImageError] = useState(null) const { copyWithFeedback } = useCopyToClipboard() const { t } = useAppTranslation() @@ -53,12 +54,13 @@ export function ImageViewer({ e.stopPropagation() try { - const textToCopy = path || imageData - await copyWithFeedback(textToCopy, e) - - // Show feedback - setCopyFeedback(true) - setTimeout(() => setCopyFeedback(false), 2000) + // Copy the file path if available + if (imagePath) { + await copyWithFeedback(imagePath, e) + // Show feedback + setCopyFeedback(true) + setTimeout(() => setCopyFeedback(false), 2000) + } } catch (err) { console.error("Error copying:", err instanceof Error ? err.message : String(err)) } @@ -71,10 +73,10 @@ export function ImageViewer({ e.stopPropagation() try { - // Send message to VSCode to save the image + // Request VSCode to save the image vscode.postMessage({ type: "saveImage", - dataUri: imageData, + dataUri: imageUri, }) } catch (error) { console.error("Error saving image:", error) @@ -86,10 +88,21 @@ export function ImageViewer({ */ const handleOpenInEditor = (e: React.MouseEvent) => { e.stopPropagation() - vscode.postMessage({ - type: "openImage", - text: imageData, - }) + // Use openImage for both file paths and data URIs + // The backend will handle both cases appropriately + if (imagePath) { + // Use the actual file path for opening + vscode.postMessage({ + type: "openImage", + text: imagePath, + }) + } else if (imageUri) { + // Fallback to opening image URI if no path is available (for Mermaid diagrams) + vscode.postMessage({ + type: "openImage", + text: imageUri, + }) + } } /** @@ -129,24 +142,86 @@ export function ImageViewer({ setIsHovering(false) } + const handleImageError = useCallback(() => { + setImageError("Failed to load image") + }, []) + + const handleImageLoad = useCallback(() => { + setImageError(null) + }, []) + + /** + * Format the display path for the image + */ + const formatDisplayPath = (path: string): string => { + // If it's already a relative path starting with ./, keep it + if (path.startsWith("./")) return path + // If it's an absolute path, extract the relative portion + // Look for workspace patterns - match the last segment after any directory separator + const workspaceMatch = path.match(/\/([^/]+)\/(.+)$/) + if (workspaceMatch && workspaceMatch[2]) { + // Return relative path from what appears to be the workspace root + return `./${workspaceMatch[2]}` + } + // Otherwise, just get the filename + const filename = path.split("/").pop() + return filename || path + } + + // Handle missing image URI + if (!imageUri) { + return ( +
+ {t("common:image.noData")} +
+ ) + } + return ( <>
- {alt} - {path &&
{path}
} + {imageError ? ( +
+ ⚠️ {imageError} +
+ ) : ( + {alt} + )} + {imagePath && ( +
{formatDisplayPath(imagePath)}
+ )} {showControls && isHovering && (
setIsDragging(false)} onMouseLeave={() => setIsDragging(false)}> {alt} - {path && ( + {imagePath && ( diff --git a/webview-ui/src/components/common/__tests__/ImageViewer.spec.tsx b/webview-ui/src/components/common/__tests__/ImageViewer.spec.tsx new file mode 100644 index 000000000000..5f85978b741f --- /dev/null +++ b/webview-ui/src/components/common/__tests__/ImageViewer.spec.tsx @@ -0,0 +1,116 @@ +// npx vitest run src/components/common/__tests__/ImageViewer.spec.tsx + +import { render, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi } from "vitest" +import { ImageViewer } from "../ImageViewer" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Import the mocked vscode after the mock is set up +import { vscode } from "@src/utils/vscode" + +describe("ImageViewer", () => { + it("should render image with webview URI", () => { + const webviewUri = "https://file+.vscode-resource.vscode-cdn.net/path/to/image.png" + const { container } = render() + + const img = container.querySelector("img") + expect(img).toBeTruthy() + expect(img?.src).toBe(webviewUri) + expect(img?.alt).toBe("Test image") + }) + + it("should render image with vscode-resource URI", () => { + const vscodeResourceUri = "vscode-resource://file///path/to/image.png" + const { container } = render() + + const img = container.querySelector("img") + expect(img).toBeTruthy() + expect(img?.src).toBe(vscodeResourceUri) + }) + + it("should handle base64 images", () => { + const base64Image = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + const { container } = render() + + const img = container.querySelector("img") + expect(img).toBeTruthy() + expect(img?.src).toBe(base64Image) + }) + + it("should use imageUri for rendering and imagePath for display", () => { + const webviewUri = "https://file+.vscode-resource.vscode-cdn.net/path/to/image.png" + const filePath = "/Users/test/project/image.png" + const { container } = render() + + const img = container.querySelector("img") + expect(img).toBeTruthy() + // Should use imageUri for src + expect(img?.src).toBe(webviewUri) + + // Should display imagePath below image + const pathElement = container.querySelector(".text-xs.text-vscode-descriptionForeground") + expect(pathElement).toBeTruthy() + expect(pathElement?.textContent).toContain("image.png") + }) + + it("should handle click to open in editor", () => { + const webviewUri = "https://file+.vscode-resource.vscode-cdn.net/path/to/image.png" + const filePath = "/Users/test/project/image.png" + const { container } = render() + + const img = container.querySelector("img") + expect(img).toBeTruthy() + + // Clear previous calls + vi.clearAllMocks() + + // Click the image + fireEvent.click(img!) + + // Check if vscode.postMessage was called to open the image with the actual path + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "openImage", + text: filePath, + }) + }) + + it("should handle error state gracefully", () => { + const invalidUri = "invalid://uri" + const { container } = render() + + const img = container.querySelector("img") + expect(img).toBeTruthy() + + // Trigger error event + fireEvent.error(img!) + + // Image should still be rendered but might have error styling + expect(img).toBeTruthy() + }) + + it("should show no image message when imageUri is empty", () => { + const { container } = render() + + // Should show no image message + expect(container.textContent).toContain("common:image.noData") + }) + + it("should display path below image when provided", () => { + const filePath = "/Users/test/rc1/path/to/image.png" + const webviewUri = "https://file+.vscode-resource.vscode-cdn.net/path/to/image.png" + const { container } = render() + + // Check if path is displayed as relative path + const pathElement = container.querySelector(".text-xs.text-vscode-descriptionForeground") + expect(pathElement).toBeTruthy() + // Accept filename or relative path depending on environment + expect(pathElement?.textContent).toContain("image.png") + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index c056a4432876..edf95df4a03c 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Imatge" - } + }, + "noData": "Sense dades d'imatge" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index 85137922ffd0..b332d71413b7 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Bild" - } + }, + "noData": "Keine Bilddaten" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 973cb48297bf..a1830f1f912b 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Image" - } + }, + "noData": "No image data" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index a293008d8a88..9beee73891f0 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Imagen" - } + }, + "noData": "Sin datos de imagen" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index fd7f53dd976b..8bd04a2aeff1 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Image" - } + }, + "noData": "Aucune donnée d'image" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 15039dc9001a..55f1b5a71703 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "चित्र" - } + }, + "noData": "कोई छवि डेटा नहीं" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index 0dac9b2987c9..80104a87e021 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Gambar" - } + }, + "noData": "Tidak ada data gambar" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 9ac9cbadad1e..a913113708e2 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Immagine" - } + }, + "noData": "Nessun dato immagine" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index a92a3cd79a58..210c828a21d6 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "画像" - } + }, + "noData": "画像データなし" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index e8a9b7c64b97..8f613c71398a 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "이미지" - } + }, + "noData": "이미지 데이터 없음" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index 12a6c74365f1..3c4bc490176d 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Afbeelding" - } + }, + "noData": "Geen afbeeldingsgegevens" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 410c8dbb9c05..8ada6155bfdb 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Obraz" - } + }, + "noData": "Brak danych obrazu" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 30d9b6dc6c1c..b4cfdbb1126c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Imagem" - } + }, + "noData": "Nenhum dado de imagem" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index 8cdb1431eb19..9a29b596c599 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Изображение" - } + }, + "noData": "Нет данных изображения" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index 15f13fcdd36c..d268bf223f5e 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Resim" - } + }, + "noData": "Resim verisi yok" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index a75e1e1f4acd..9815c23b6cb5 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "Hình ảnh" - } + }, + "noData": "Không có dữ liệu hình ảnh" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 902bd7f7e068..afdb34794d7d 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "图像" - } + }, + "noData": "无图片数据" }, "file": { "errors": { diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 9497d369a5bf..b9c9070c8e4c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -51,7 +51,8 @@ "image": { "tabs": { "view": "圖像" - } + }, + "noData": "無圖片資料" }, "file": { "errors": {