This repository was archived by the owner on May 15, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat: add support for image file @mentions #10189
Merged
Merged
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
44f774f
feat: add support for image file @mentions
hannesrudolph 826275d
test: normalize edit-dialog images to empty array
hannesrudolph bc49d92
test: fix edit dialog image expectation
hannesrudolph 24b1f6e
fix: align image mentions with read_file behavior
hannesrudolph f2d1554
refactor: reuse existing imageHelpers functions per review feedback
hannesrudolph 6311205
refactor: remove unused SUPPORTED_IMAGE_FORMATS constant from test file
roomote File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import * as path from "path" | ||
|
|
||
| import { resolveImageMentions } from "../resolveImageMentions" | ||
|
|
||
| vi.mock("fs/promises", () => { | ||
| return { | ||
| default: { | ||
| readFile: vi.fn(), | ||
| }, | ||
| readFile: vi.fn(), | ||
| } | ||
| }) | ||
|
|
||
| import * as fs from "fs/promises" | ||
|
|
||
| const mockReadFile = vi.mocked(fs.readFile) | ||
|
|
||
| describe("resolveImageMentions", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| }) | ||
|
|
||
| it("should append a data URL when a local png mention is present", async () => { | ||
| mockReadFile.mockResolvedValue(Buffer.from("png-bytes")) | ||
|
|
||
| const result = await resolveImageMentions({ | ||
| text: "Please look at @/assets/cat.png", | ||
| images: [], | ||
| cwd: "/workspace", | ||
| }) | ||
|
|
||
| expect(mockReadFile).toHaveBeenCalledWith(path.resolve("/workspace", "assets/cat.png")) | ||
| expect(result.text).toBe("Please look at @/assets/cat.png") | ||
| expect(result.images).toEqual([`data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`]) | ||
| }) | ||
|
|
||
| it("should ignore non-image mentions", async () => { | ||
| const result = await resolveImageMentions({ | ||
| text: "See @/src/index.ts", | ||
| images: [], | ||
| cwd: "/workspace", | ||
| }) | ||
|
|
||
| expect(mockReadFile).not.toHaveBeenCalled() | ||
| expect(result.images).toEqual([]) | ||
| }) | ||
|
|
||
| it("should skip unreadable files (fail-soft)", async () => { | ||
| mockReadFile.mockRejectedValue(new Error("ENOENT")) | ||
|
|
||
| const result = await resolveImageMentions({ | ||
| text: "See @/missing.webp", | ||
| images: [], | ||
| cwd: "/workspace", | ||
| }) | ||
|
|
||
| expect(result.images).toEqual([]) | ||
| }) | ||
|
|
||
| it("should respect rooIgnoreController", async () => { | ||
| mockReadFile.mockResolvedValue(Buffer.from("jpg-bytes")) | ||
| const rooIgnoreController = { | ||
| validateAccess: vi.fn().mockReturnValue(false), | ||
| } | ||
|
|
||
| const result = await resolveImageMentions({ | ||
| text: "See @/secret.jpg", | ||
| images: [], | ||
| cwd: "/workspace", | ||
| rooIgnoreController, | ||
| }) | ||
|
|
||
| expect(rooIgnoreController.validateAccess).toHaveBeenCalledWith("secret.jpg") | ||
| expect(mockReadFile).not.toHaveBeenCalled() | ||
| expect(result.images).toEqual([]) | ||
| }) | ||
|
|
||
| it("should dedupe when mention repeats", async () => { | ||
| mockReadFile.mockResolvedValue(Buffer.from("png-bytes")) | ||
|
|
||
| const result = await resolveImageMentions({ | ||
| text: "@/a.png and again @/a.png", | ||
| images: [], | ||
| cwd: "/workspace", | ||
| }) | ||
|
|
||
| expect(result.images).toHaveLength(1) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import * as path from "path" | ||
| import * as fs from "fs/promises" | ||
|
|
||
| import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions" | ||
|
|
||
| const MAX_IMAGES_PER_MESSAGE = 20 | ||
|
|
||
| const SUPPORTED_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]) | ||
|
|
||
| function getMimeTypeFromExtension(extLower: string): string | undefined { | ||
| if (extLower === ".png") return "image/png" | ||
| if (extLower === ".jpg" || extLower === ".jpeg") return "image/jpeg" | ||
| if (extLower === ".webp") return "image/webp" | ||
| return undefined | ||
| } | ||
|
|
||
| export interface ResolveImageMentionsOptions { | ||
| text: string | ||
| images?: string[] | ||
| cwd: string | ||
| rooIgnoreController?: { validateAccess: (filePath: string) => boolean } | ||
| } | ||
|
|
||
| export interface ResolveImageMentionsResult { | ||
| text: string | ||
| images: string[] | ||
| } | ||
|
|
||
| function isPathWithinCwd(absPath: string, cwd: string): boolean { | ||
| const rel = path.relative(cwd, absPath) | ||
| return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) | ||
| } | ||
|
|
||
| function dedupePreserveOrder(values: string[]): string[] { | ||
| const seen = new Set<string>() | ||
| const result: string[] = [] | ||
| for (const v of values) { | ||
| if (seen.has(v)) continue | ||
| seen.add(v) | ||
| result.push(v) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| /** | ||
| * Resolves local image file mentions like `@/path/to/image.png` found in `text` into `data:image/...;base64,...` | ||
| * and appends them to the outgoing `images` array. | ||
| * | ||
| * - Only supports local workspace-relative mentions (must start with `/`). | ||
| * - Only supports: png, jpg, jpeg, webp. | ||
| * - Leaves `text` unchanged. | ||
| * - Respects `.rooignore` via `rooIgnoreController.validateAccess` when provided. | ||
| */ | ||
| export async function resolveImageMentions({ | ||
| text, | ||
| images, | ||
| cwd, | ||
| rooIgnoreController, | ||
| }: ResolveImageMentionsOptions): Promise<ResolveImageMentionsResult> { | ||
| const existingImages = Array.isArray(images) ? images : [] | ||
| if (existingImages.length >= MAX_IMAGES_PER_MESSAGE) { | ||
| return { text, images: existingImages.slice(0, MAX_IMAGES_PER_MESSAGE) } | ||
| } | ||
|
|
||
| const mentions = Array.from(text.matchAll(mentionRegexGlobal)) | ||
| .map((m) => m[1]) | ||
| .filter(Boolean) | ||
| if (mentions.length === 0) { | ||
| return { text, images: existingImages } | ||
| } | ||
|
|
||
| const imageMentions = mentions.filter((mention) => { | ||
| if (!mention.startsWith("/")) return false | ||
| const relPath = unescapeSpaces(mention.slice(1)) | ||
| const ext = path.extname(relPath).toLowerCase() | ||
| return SUPPORTED_IMAGE_EXTENSIONS.has(ext) | ||
| }) | ||
|
|
||
| if (imageMentions.length === 0) { | ||
| return { text, images: existingImages } | ||
| } | ||
|
|
||
| const newImages: string[] = [] | ||
| for (const mention of imageMentions) { | ||
| if (existingImages.length + newImages.length >= MAX_IMAGES_PER_MESSAGE) { | ||
| break | ||
| } | ||
|
|
||
| const relPath = unescapeSpaces(mention.slice(1)) | ||
| const absPath = path.resolve(cwd, relPath) | ||
| if (!isPathWithinCwd(absPath, cwd)) { | ||
| continue | ||
| } | ||
|
|
||
| if (rooIgnoreController && !rooIgnoreController.validateAccess(relPath)) { | ||
| continue | ||
| } | ||
|
|
||
| const ext = path.extname(relPath).toLowerCase() | ||
| const mimeType = getMimeTypeFromExtension(ext) | ||
| if (!mimeType) { | ||
| continue | ||
| } | ||
|
|
||
| try { | ||
| const buffer = await fs.readFile(absPath) | ||
| const base64 = buffer.toString("base64") | ||
| newImages.push(`data:${mimeType};base64,${base64}`) | ||
| } catch { | ||
| // Fail-soft: skip unreadable/missing files. | ||
| continue | ||
| } | ||
| } | ||
|
|
||
| const merged = dedupePreserveOrder([...existingImages, ...newImages]).slice(0, MAX_IMAGES_PER_MESSAGE) | ||
| return { text, images: merged } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
77 changes: 77 additions & 0 deletions
77
src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import * as fs from "fs/promises" | ||
| import * as path from "path" | ||
|
|
||
| // Must mock dependencies before importing the handler module. | ||
| vi.mock("../../../api/providers/fetchers/modelCache") | ||
|
|
||
| import { webviewMessageHandler } from "../webviewMessageHandler" | ||
| import type { ClineProvider } from "../ClineProvider" | ||
|
|
||
| vi.mock("vscode", () => ({ | ||
| window: { | ||
| showInformationMessage: vi.fn(), | ||
| showErrorMessage: vi.fn(), | ||
| }, | ||
| workspace: { | ||
| workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], | ||
| }, | ||
| })) | ||
|
|
||
| describe("webviewMessageHandler - image mentions (integration)", () => { | ||
| it("resolves image mentions for newTask and passes images to createTask", async () => { | ||
| const tmpRoot = await fs.mkdtemp(path.join(process.cwd(), "tmp-image-mentions-")) | ||
| try { | ||
| const imgBytes = Buffer.from("png-bytes") | ||
| await fs.writeFile(path.join(tmpRoot, "cat.png"), imgBytes) | ||
|
|
||
| const mockProvider = { | ||
| cwd: tmpRoot, | ||
| getCurrentTask: vi.fn().mockReturnValue(undefined), | ||
| createTask: vi.fn().mockResolvedValue(undefined), | ||
| postMessageToWebview: vi.fn().mockResolvedValue(undefined), | ||
| } as unknown as ClineProvider | ||
|
|
||
| await webviewMessageHandler(mockProvider, { | ||
| type: "newTask", | ||
| text: "Please look at @/cat.png", | ||
| images: [], | ||
| } as any) | ||
|
|
||
| expect(mockProvider.createTask).toHaveBeenCalledWith("Please look at @/cat.png", [ | ||
| `data:image/png;base64,${imgBytes.toString("base64")}`, | ||
| ]) | ||
| } finally { | ||
| await fs.rm(tmpRoot, { recursive: true, force: true }) | ||
| } | ||
| }) | ||
|
|
||
| it("resolves image mentions for askResponse and passes images to handleWebviewAskResponse", async () => { | ||
| const tmpRoot = await fs.mkdtemp(path.join(process.cwd(), "tmp-image-mentions-")) | ||
| try { | ||
| const imgBytes = Buffer.from("jpg-bytes") | ||
| await fs.writeFile(path.join(tmpRoot, "cat.jpg"), imgBytes) | ||
|
|
||
| const handleWebviewAskResponse = vi.fn() | ||
| const mockProvider = { | ||
| cwd: tmpRoot, | ||
| getCurrentTask: vi.fn().mockReturnValue({ | ||
| cwd: tmpRoot, | ||
| handleWebviewAskResponse, | ||
| }), | ||
| } as unknown as ClineProvider | ||
|
|
||
| await webviewMessageHandler(mockProvider, { | ||
| type: "askResponse", | ||
| askResponse: "messageResponse", | ||
| text: "Please look at @/cat.jpg", | ||
| images: [], | ||
| } as any) | ||
|
|
||
| expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Please look at @/cat.jpg", [ | ||
| `data:image/jpeg;base64,${imgBytes.toString("base64")}`, | ||
| ]) | ||
| } finally { | ||
| await fs.rm(tmpRoot, { recursive: true, force: true }) | ||
| } | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.