diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3224eb7..9d911ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,10 @@ version with its date and start a fresh empty `[Unreleased]` above it.
`Model is queued (3 ahead · ~12s wait)...`, and returns to the usual
wording as soon as the request leaves the queue.
+- Images an agent embeds in a reply now offer **Copy image** in their
+ right-click menu, which puts the bitmap on the system clipboard ready to
+ paste into any application or another note.
+
## [1.0.14] - 2026-09-20
### Changed
diff --git a/src/features/chat/rendering/message-renderer.ts b/src/features/chat/rendering/message-renderer.ts
index 1eb8076..ce8c41b 100644
--- a/src/features/chat/rendering/message-renderer.ts
+++ b/src/features/chat/rendering/message-renderer.ts
@@ -24,7 +24,8 @@ import {
} from '../../../shared/markdown/markdown-math';
import { replaceMentionTokensWithHtml } from '../../../shared/markdown/mention-chip';
import type { ReferenceChipKind } from '../../../shared/mention/types';
-import { openReferenceChip } from '../../../shared/obsidian/compat';
+import { getVaultFileByPath, openReferenceChip } from '../../../shared/obsidian/compat';
+import { copyVaultImageToClipboard } from '../../../shared/obsidian/image-clipboard';
import { TurnChangesModal } from '../changes/turn-changes-modal';
import { collectTurnChanges } from '../changes/turn-file-changes';
import { turnFileDisplayPath } from '../changes/turn-file-path';
@@ -844,6 +845,10 @@ export class MessageRenderer {
this.enhanceMentionChips(el);
}
+ if (processedMarkdown.includes('qoderian-embedded-image')) {
+ this.enhanceEmbeddedImages(el);
+ }
+
// Wrap pre elements and move buttons outside scroll area
el.querySelectorAll('pre').forEach((pre) => {
// Skip if already wrapped
@@ -921,6 +926,39 @@ export class MessageRenderer {
});
}
+ /**
+ * Binds a copy action onto rendered vault images. Embeds render as plain
+ *
elements, so Obsidian's own image context menu never applies here.
+ */
+ private enhanceEmbeddedImages(el: HTMLElement): void {
+ el.querySelectorAll('.qoderian-embedded-image').forEach((imageEl) => {
+ const path = imageEl.dataset.qoderianImagePath;
+ if (!path) return;
+
+ imageEl.addEventListener('contextmenu', (event) => {
+ event.preventDefault();
+ this.showImageCopyMenu(event, path);
+ });
+ });
+ }
+
+ private showImageCopyMenu(event: MouseEvent, path: string): void {
+ const menu = new Menu();
+ menu.addItem((item) => {
+ item
+ .setTitle(t('chat.imageEmbed.copyImage'))
+ .setIcon('copy')
+ .onClick(() => {
+ runRendererAction(async () => {
+ const file = getVaultFileByPath(this.app, path);
+ const copied = file ? await copyVaultImageToClipboard(this.app, file) : false;
+ new Notice(copied ? t('chat.imageEmbed.copied') : t('chat.imageEmbed.copyFailed'));
+ });
+ });
+ });
+ menu.showAtMouseEvent(event);
+ }
+
// ============================================
// Copy Button
// ============================================
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 3e102b8..aa8a22c 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -68,6 +68,11 @@
"closeSession": "Sitzung schließen"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "Bild kopieren",
+ "copied": "Bild in die Zwischenablage kopiert",
+ "copyFailed": "Bild konnte nicht kopiert werden"
+ },
"rewind": {
"confirmMessage": "Zu diesem Punkt zurückspulen? Dateiänderungen nach dieser Nachricht werden rückgängig gemacht. Das Zurückspulen betrifft keine manuell oder über Bash bearbeiteten Dateien.",
"confirmMessageConversationOnly": "Konversation zu diesem Punkt zurückspulen? Dateiänderungen bleiben erhalten.",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index b3f1141..2a18578 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -68,6 +68,11 @@
"closeSession": "Close session"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "Copy image",
+ "copied": "Image copied to clipboard",
+ "copyFailed": "Failed to copy image"
+ },
"rewind": {
"confirmMessage": "Rewind to this point? File changes after this message will be reverted. Rewinding does not affect files edited manually or via bash.",
"confirmMessageConversationOnly": "Rewind conversation to this point? File changes will be kept.",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index bc284ca..972738c 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -68,6 +68,11 @@
"closeSession": "Cerrar sesión"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "Copiar imagen",
+ "copied": "Imagen copiada al portapapeles",
+ "copyFailed": "No se pudo copiar la imagen"
+ },
"rewind": {
"confirmMessage": "¿Rebobinar a este punto? Los cambios de archivos después de este mensaje serán revertidos. El rebobinado no afecta archivos editados manualmente o mediante bash.",
"confirmMessageConversationOnly": "¿Rebobinar la conversación hasta este punto? Se conservarán los cambios de archivos.",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index af734a9..68b996d 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -68,6 +68,11 @@
"closeSession": "Fermer la session"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "Copier l'image",
+ "copied": "Image copiée dans le presse-papiers",
+ "copyFailed": "Échec de la copie de l'image"
+ },
"rewind": {
"confirmMessage": "Rembobiner jusqu'à ce point ? Les modifications de fichiers après ce message seront annulées. Le rembobinage n'affecte pas les fichiers modifiés manuellement ou via bash.",
"confirmMessageConversationOnly": "Rembobiner la conversation jusqu'à ce point ? Les modifications de fichiers seront conservées.",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index d5320e0..f68151f 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -68,6 +68,11 @@
"closeSession": "セッションを閉じる"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "画像をコピー",
+ "copied": "画像をクリップボードにコピーしました",
+ "copyFailed": "画像のコピーに失敗しました"
+ },
"rewind": {
"confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。",
"confirmMessageConversationOnly": "会話をこの時点に巻き戻しますか?ファイル変更は保持されます。",
diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json
index da5b4ad..b8e1ff3 100644
--- a/src/i18n/locales/ko.json
+++ b/src/i18n/locales/ko.json
@@ -68,6 +68,11 @@
"closeSession": "세션 닫기"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "이미지 복사",
+ "copied": "이미지를 클립보드에 복사했습니다",
+ "copyFailed": "이미지 복사 실패"
+ },
"rewind": {
"confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.",
"confirmMessageConversationOnly": "대화를 이 시점으로 되감으시겠습니까? 파일 변경 사항은 유지됩니다.",
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 63a2273..82f075f 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -68,6 +68,11 @@
"closeSession": "Fechar sessão"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "Copiar imagem",
+ "copied": "Imagem copiada para a área de transferência",
+ "copyFailed": "Falha ao copiar a imagem"
+ },
"rewind": {
"confirmMessage": "Retroceder até este ponto? As alterações de arquivos após esta mensagem serão revertidas. O retrocesso não afeta arquivos editados manualmente ou via bash.",
"confirmMessageConversationOnly": "Retroceder a conversa até este ponto? As alterações de arquivos serão mantidas.",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 0d3ede7..d088e40 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -68,6 +68,11 @@
"closeSession": "Закрыть сессию"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "Копировать изображение",
+ "copied": "Изображение скопировано в буфер обмена",
+ "copyFailed": "Не удалось скопировать изображение"
+ },
"rewind": {
"confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.",
"confirmMessageConversationOnly": "Откатить разговор до этой точки? Изменения файлов будут сохранены.",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 3099e94..ff84d1f 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -68,6 +68,11 @@
"closeSession": "关闭会话"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "复制图片",
+ "copied": "图片已复制到剪贴板",
+ "copyFailed": "复制图片失败"
+ },
"rewind": {
"confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。",
"confirmMessageConversationOnly": "将对话回退到此处?文件更改将保留。",
diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json
index 31574ab..ca42762 100644
--- a/src/i18n/locales/zh-TW.json
+++ b/src/i18n/locales/zh-TW.json
@@ -68,6 +68,11 @@
"closeSession": "關閉對話"
},
"chat": {
+ "imageEmbed": {
+ "copyImage": "複製圖片",
+ "copied": "圖片已複製到剪貼簿",
+ "copyFailed": "複製圖片失敗"
+ },
"rewind": {
"confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。",
"confirmMessageConversationOnly": "將對話回退到此處?檔案變更將保留。",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index 0c89ffb..33b8ad4 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -84,6 +84,10 @@ export type TranslationKey =
| 'chat.rewind.cannot'
| 'chat.rewind.unavailableStreaming'
| 'chat.rewind.unavailableNoUuid'
+ // Chat - Embedded images
+ | 'chat.imageEmbed.copyImage'
+ | 'chat.imageEmbed.copied'
+ | 'chat.imageEmbed.copyFailed'
| 'chat.bangBash.placeholder'
| 'chat.bangBash.commandPanel'
| 'chat.bangBash.copyAriaLabel'
diff --git a/src/shared/markdown/image-embed.ts b/src/shared/markdown/image-embed.ts
index d1a05ef..9dc2f52 100644
--- a/src/shared/markdown/image-embed.ts
+++ b/src/shared/markdown/image-embed.ts
@@ -81,7 +81,7 @@ function createImageHtml(
const alt = escapeHtml(altText || file.basename);
const style = buildStyleAttribute(altText);
- return `
`;
+ return `
`;
}
function createFallbackHtml(wikilink: string): string {
diff --git a/src/shared/obsidian/image-clipboard.ts b/src/shared/obsidian/image-clipboard.ts
new file mode 100644
index 0000000..0975e81
--- /dev/null
+++ b/src/shared/obsidian/image-clipboard.ts
@@ -0,0 +1,69 @@
+/**
+ * Qoderian - Vault Image Clipboard
+ *
+ * Copies a vault image into the OS clipboard as a bitmap so it can be pasted
+ * into other applications. Mirrors Obsidian's own "Copy image" in Live Preview:
+ * desktop PNG/JPEG go through Electron's nativeImage, everything else falls
+ * back to the async Clipboard API.
+ */
+
+import type { App, TFile } from 'obsidian';
+
+interface ElectronClipboardApi {
+ clipboard?: {
+ writeImage?: (image: unknown) => void;
+ };
+ nativeImage?: {
+ createFromBuffer?: (buffer: Buffer) => { isEmpty?: () => boolean };
+ };
+}
+
+const NATIVE_CLIPBOARD_EXTENSIONS = new Set(['png', 'jpg', 'jpeg']);
+
+function getElectronClipboard(): ElectronClipboardApi | null {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- Electron is exposed only at runtime in Obsidian's renderer.
+ return require('electron') as ElectronClipboardApi;
+ } catch {
+ return null;
+ }
+}
+
+function writeImageWithNativeImage(data: ArrayBuffer): boolean {
+ const electron = getElectronClipboard();
+ const clipboard = electron?.clipboard;
+ const createFromBuffer = electron?.nativeImage?.createFromBuffer;
+ const writeImage = clipboard?.writeImage;
+ if (!clipboard || typeof createFromBuffer !== 'function' || typeof writeImage !== 'function') {
+ return false;
+ }
+
+ const image = createFromBuffer(Buffer.from(data));
+ if (!image || image.isEmpty?.()) {
+ return false;
+ }
+
+ writeImage.call(clipboard, image);
+ return true;
+}
+
+async function writeImageWithClipboardItem(data: ArrayBuffer): Promise {
+ try {
+ const blob = new Blob([data], { type: 'image/png' });
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Copies a vault image to the OS clipboard. Resolves false when the write failed. */
+export async function copyVaultImageToClipboard(app: App, file: TFile): Promise {
+ const data = await app.vault.readBinary(file);
+
+ if (NATIVE_CLIPBOARD_EXTENSIONS.has(file.extension.toLowerCase()) && writeImageWithNativeImage(data)) {
+ return true;
+ }
+
+ return writeImageWithClipboardItem(data);
+}
diff --git a/tests/unit/features/chat/rendering/message-renderer.image-copy.test.ts b/tests/unit/features/chat/rendering/message-renderer.image-copy.test.ts
new file mode 100644
index 0000000..7f6e257
--- /dev/null
+++ b/tests/unit/features/chat/rendering/message-renderer.image-copy.test.ts
@@ -0,0 +1,125 @@
+import { createMockEl } from '@test/helpers/mock-element';
+import { MarkdownRenderer, Menu, Notice, TFile } from 'obsidian';
+
+import { MessageRenderer } from '@/features/chat/rendering/message-renderer';
+
+jest.mock('electron', () => ({
+ clipboard: { writeImage: jest.fn() },
+ nativeImage: { createFromBuffer: jest.fn(() => ({ isEmpty: () => false })) },
+}), { virtual: true });
+
+const electronMock = jest.requireMock('electron') as {
+ clipboard: { writeImage: jest.Mock };
+};
+
+interface MockMenuItem {
+ title: string;
+ clickHandler: (() => void) | null;
+}
+
+// The obsidian mock tracks every Menu instance; the published types don't.
+const menuInstances = (Menu as unknown as {
+ instances: Array<{ items: MockMenuItem[] }>;
+}).instances;
+
+const IMAGE_PATH = 'vibe_images/chart.png';
+const EMBED_MARKDOWN = `Here is the render:\n\n![[${IMAGE_PATH}]]`;
+
+const renderMock = MarkdownRenderer.render as unknown as jest.Mock;
+
+function createVaultFile(): TFile {
+ return Object.assign(new TFile(), {
+ path: IMAGE_PATH,
+ name: 'chart.png',
+ basename: 'chart',
+ extension: 'png',
+ });
+}
+
+function renderEmbeddedImageSpan(el: any): void {
+ const imageEl = el.createSpan({ cls: 'qoderian-embedded-image' });
+ imageEl.setAttribute('data-qoderian-image-path', IMAGE_PATH);
+}
+
+function createRenderer(file: TFile | null) {
+ const messagesEl = createMockEl();
+ const container = createMockEl();
+ const readBinary = jest.fn().mockResolvedValue(new ArrayBuffer(8));
+ const app = {
+ vault: {
+ getAbstractFileByPath: jest.fn().mockReturnValue(file),
+ getResourcePath: jest.fn().mockReturnValue(`app://local/${IMAGE_PATH}`),
+ readBinary,
+ },
+ metadataCache: { getFirstLinkpathDest: jest.fn().mockReturnValue(null) },
+ };
+ const plugin = { app, settings: { expandFileEditsByDefault: false, mediaFolder: '' } };
+ const component = { registerDomEvent: jest.fn() };
+
+ return {
+ app,
+ container,
+ readBinary,
+ renderer: new MessageRenderer(plugin as any, component as any, messagesEl),
+ };
+}
+
+async function flushAsyncAction(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+async function clickCopyMenuItem(container: ReturnType): Promise {
+ const imageEl = container.querySelector('.qoderian-embedded-image');
+ expect(imageEl).not.toBeNull();
+ imageEl.dispatchEvent({ type: 'contextmenu', preventDefault: jest.fn() });
+
+ const menu = menuInstances[menuInstances.length - 1];
+ await menu.items[0]?.clickHandler?.();
+ await flushAsyncAction();
+}
+
+describe('MessageRenderer embedded image copy', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ menuInstances.length = 0;
+ renderMock.mockReset();
+ renderMock.mockImplementation(async (_app: unknown, _markdown: string, el: any) => {
+ renderEmbeddedImageSpan(el);
+ });
+ });
+
+ it('opens a copy menu on right-click and writes the image to the clipboard', async () => {
+ const file = createVaultFile();
+ const { container, readBinary, renderer } = createRenderer(file);
+
+ await renderer.renderContent(container, EMBED_MARKDOWN);
+ expect(container.querySelector('.qoderian-embedded-image')).not.toBeNull();
+
+ const preventDefault = jest.fn();
+ container.querySelector('.qoderian-embedded-image').dispatchEvent({ type: 'contextmenu', preventDefault });
+ expect(preventDefault).toHaveBeenCalled();
+
+ const menu = menuInstances[menuInstances.length - 1];
+ expect(menu.items[0]?.title).toBe('Copy image');
+
+ await menu.items[0]?.clickHandler?.();
+ await flushAsyncAction();
+
+ expect(readBinary).toHaveBeenCalledWith(file);
+ expect(electronMock.clipboard.writeImage).toHaveBeenCalledTimes(1);
+ expect(Notice).toHaveBeenCalledWith('Image copied to clipboard');
+ });
+
+ it('reports a failure when the image file disappeared before the copy', async () => {
+ const file = createVaultFile();
+ const { app, container, renderer } = createRenderer(file);
+
+ await renderer.renderContent(container, EMBED_MARKDOWN);
+ app.vault.getAbstractFileByPath.mockReturnValue(null);
+
+ await clickCopyMenuItem(container);
+
+ expect(electronMock.clipboard.writeImage).not.toHaveBeenCalled();
+ expect(Notice).toHaveBeenCalledWith('Failed to copy image');
+ });
+});
diff --git a/tests/unit/i18n/locales.test.ts b/tests/unit/i18n/locales.test.ts
index 7eb64c4..a432964 100644
--- a/tests/unit/i18n/locales.test.ts
+++ b/tests/unit/i18n/locales.test.ts
@@ -31,6 +31,9 @@ const localizedKeys = [
'chat.rewind.menuCodeAndConversation',
'chat.rewind.noticeConversationOnly',
'chat.rewind.noticeConversationOnlySaveFailed',
+ 'chat.imageEmbed.copyImage',
+ 'chat.imageEmbed.copied',
+ 'chat.imageEmbed.copyFailed',
'chat.fork.errorMessageNotFound',
'chat.fork.errorNoSession',
'chat.fork.errorNoActiveTab',
diff --git a/tests/unit/shared/markdown/image-embed.test.ts b/tests/unit/shared/markdown/image-embed.test.ts
index fcf07bf..d564e64 100644
--- a/tests/unit/shared/markdown/image-embed.test.ts
+++ b/tests/unit/shared/markdown/image-embed.test.ts
@@ -68,6 +68,13 @@ describe('replaceImageEmbedsWithHtml', () => {
expect(result).toContain('image');
expect(result).toContain('
{
+ const app = createMockApp(new Map([['attachments/diagram.png', 'app://local/attachments/diagram.png']]));
+ const result = replaceImageEmbedsWithHtml('![[diagram.png]]', app);
+
+ expect(result).toContain('data-qoderian-image-path="attachments/diagram.png"');
+ });
});
describe('alt text and dimensions', () => {
diff --git a/tests/unit/shared/obsidian/image-clipboard.test.ts b/tests/unit/shared/obsidian/image-clipboard.test.ts
new file mode 100644
index 0000000..607d239
--- /dev/null
+++ b/tests/unit/shared/obsidian/image-clipboard.test.ts
@@ -0,0 +1,82 @@
+import type { App, TFile } from 'obsidian';
+
+import { copyVaultImageToClipboard } from '@/shared/obsidian/image-clipboard';
+
+jest.mock('electron', () => ({
+ clipboard: { writeImage: jest.fn() },
+ nativeImage: { createFromBuffer: jest.fn() },
+}), { virtual: true });
+
+const electronMock = jest.requireMock('electron') as {
+ clipboard: { writeImage: jest.Mock };
+ nativeImage: { createFromBuffer: jest.Mock };
+};
+
+class ClipboardItemStub {
+ constructor(readonly items: Record) {}
+}
+
+const writeMock = jest.fn, [unknown[]]>();
+
+function createApp(data: ArrayBuffer): App {
+ return {
+ vault: { readBinary: jest.fn().mockResolvedValue(data) },
+ } as unknown as App;
+}
+
+function createFile(path: string): TFile {
+ return {
+ path,
+ extension: path.split('.').pop() ?? '',
+ } as unknown as TFile;
+}
+
+describe('copyVaultImageToClipboard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ electronMock.nativeImage.createFromBuffer.mockReturnValue({ isEmpty: () => false });
+ writeMock.mockResolvedValue(undefined);
+ Object.defineProperty(globalThis, 'navigator', {
+ value: { clipboard: { write: writeMock } },
+ configurable: true,
+ });
+ (globalThis as { ClipboardItem?: unknown }).ClipboardItem = ClipboardItemStub;
+ });
+
+ it('writes PNG images through Electron nativeImage', async () => {
+ const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('vibe_images/chart.png'));
+
+ expect(result).toBe(true);
+ expect(electronMock.nativeImage.createFromBuffer).toHaveBeenCalledTimes(1);
+ expect(electronMock.clipboard.writeImage).toHaveBeenCalledTimes(1);
+ expect(writeMock).not.toHaveBeenCalled();
+ });
+
+ it('routes non-PNG formats to the async clipboard API', async () => {
+ const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('diagram.webp'));
+
+ expect(result).toBe(true);
+ expect(electronMock.nativeImage.createFromBuffer).not.toHaveBeenCalled();
+ expect(writeMock).toHaveBeenCalledTimes(1);
+ expect(writeMock.mock.calls[0][0][0]).toBeInstanceOf(ClipboardItemStub);
+ });
+
+ it('falls back to the async clipboard API when nativeImage yields an empty image', async () => {
+ electronMock.nativeImage.createFromBuffer.mockReturnValue({ isEmpty: () => true });
+
+ const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('chart.png'));
+
+ expect(result).toBe(true);
+ expect(electronMock.clipboard.writeImage).not.toHaveBeenCalled();
+ expect(writeMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('resolves false when every clipboard path is unavailable', async () => {
+ electronMock.nativeImage.createFromBuffer.mockReturnValue(null);
+ writeMock.mockRejectedValue(new Error('clipboard denied'));
+
+ const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('chart.png'));
+
+ expect(result).toBe(false);
+ });
+});