diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md
index bd4b193f00..22ce5866c0 100644
--- a/docs/cli/keyboard-shortcuts.md
+++ b/docs/cli/keyboard-shortcuts.md
@@ -84,10 +84,10 @@ available combinations.
#### External Tools
-| Action | Keys |
-| ---------------------------------------------- | ---------- |
-| Open the current prompt in an external editor. | `Ctrl + X` |
-| Paste from the clipboard. | `Ctrl + V` |
+| Action | Keys |
+| ---------------------------------------------- | ------------------------- |
+| Open the current prompt in an external editor. | `Ctrl + X` |
+| Paste from the clipboard. | `Ctrl + V`
`Cmd + V` |
#### App Controls
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 497b359f2e..b5a20b90e3 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -192,7 +192,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'x', ctrl: true },
{ sequence: '\x18', ctrl: true },
],
- [Command.PASTE_CLIPBOARD]: [{ key: 'v', ctrl: true }],
+ [Command.PASTE_CLIPBOARD]: [
+ { key: 'v', ctrl: true },
+ { key: 'v', command: true },
+ ],
// App level bindings
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts
index bff3d2a6ec..101a5085f7 100644
--- a/packages/cli/src/ui/utils/clipboardUtils.test.ts
+++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts
@@ -15,34 +15,34 @@ import {
describe('clipboardUtils', () => {
describe('clipboardHasImage', () => {
- it('should return false on non-macOS platforms', async () => {
- if (process.platform !== 'darwin') {
+ it('should return false on unsupported platforms', async () => {
+ if (process.platform !== 'darwin' && process.platform !== 'win32') {
const result = await clipboardHasImage();
expect(result).toBe(false);
} else {
- // Skip on macOS as it would require actual clipboard state
+ // Skip on macOS/Windows as it would require actual clipboard state
expect(true).toBe(true);
}
});
- it('should return boolean on macOS', async () => {
- if (process.platform === 'darwin') {
+ it('should return boolean on macOS or Windows', async () => {
+ if (process.platform === 'darwin' || process.platform === 'win32') {
const result = await clipboardHasImage();
expect(typeof result).toBe('boolean');
} else {
- // Skip on non-macOS
+ // Skip on unsupported platforms
expect(true).toBe(true);
}
- });
+ }, 10000);
});
describe('saveClipboardImage', () => {
- it('should return null on non-macOS platforms', async () => {
- if (process.platform !== 'darwin') {
+ it('should return null on unsupported platforms', async () => {
+ if (process.platform !== 'darwin' && process.platform !== 'win32') {
const result = await saveClipboardImage();
expect(result).toBe(null);
} else {
- // Skip on macOS
+ // Skip on macOS/Windows
expect(true).toBe(true);
}
});
@@ -53,8 +53,8 @@ describe('clipboardUtils', () => {
'/invalid/path/that/does/not/exist',
);
- if (process.platform === 'darwin') {
- // On macOS, might return null due to various errors
+ if (process.platform === 'darwin' || process.platform === 'win32') {
+ // On macOS/Windows, might return null due to various errors
expect(result === null || typeof result === 'string').toBe(true);
} else {
// On other platforms, should always return null
diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts
index 91a657aca0..9296bfce99 100644
--- a/packages/cli/src/ui/utils/clipboardUtils.ts
+++ b/packages/cli/src/ui/utils/clipboardUtils.ts
@@ -30,10 +30,24 @@ export const IMAGE_EXTENSIONS = [
const PATH_PREFIX_PATTERN = /^([/~.]|[a-zA-Z]:|\\\\)/;
/**
- * Checks if the system clipboard contains an image (macOS only for now)
+ * Checks if the system clipboard contains an image (macOS and Windows)
* @returns true if clipboard contains an image
*/
export async function clipboardHasImage(): Promise {
+ if (process.platform === 'win32') {
+ try {
+ const { stdout } = await spawnAsync('powershell', [
+ '-NoProfile',
+ '-Command',
+ 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()',
+ ]);
+ return stdout.trim() === 'True';
+ } catch (error) {
+ debugLogger.warn('Error checking clipboard for image:', error);
+ return false;
+ }
+ }
+
if (process.platform !== 'darwin') {
return false;
}
@@ -44,20 +58,21 @@ export async function clipboardHasImage(): Promise {
const imageRegex =
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
return imageRegex.test(stdout);
- } catch {
+ } catch (error) {
+ debugLogger.warn('Error checking clipboard for image:', error);
return false;
}
}
/**
- * Saves the image from clipboard to a temporary file (macOS only for now)
+ * Saves the image from clipboard to a temporary file (macOS and Windows)
* @param targetDir The target directory to create temp files within
* @returns The path to the saved image file, or null if no image or error
*/
export async function saveClipboardImage(
targetDir?: string,
): Promise {
- if (process.platform !== 'darwin') {
+ if (process.platform !== 'darwin' && process.platform !== 'win32') {
return null;
}
@@ -71,6 +86,40 @@ export async function saveClipboardImage(
// Generate a unique filename with timestamp
const timestamp = new Date().getTime();
+ if (process.platform === 'win32') {
+ const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`);
+ // The path is used directly in the PowerShell script.
+ const psPath = tempFilePath.replace(/'/g, "''");
+
+ const script = `
+ Add-Type -AssemblyName System.Windows.Forms
+ Add-Type -AssemblyName System.Drawing
+ if ([System.Windows.Forms.Clipboard]::ContainsImage()) {
+ $image = [System.Windows.Forms.Clipboard]::GetImage()
+ $image.Save('${psPath}', [System.Drawing.Imaging.ImageFormat]::Png)
+ Write-Output "success"
+ }
+ `;
+
+ const { stdout } = await spawnAsync('powershell', [
+ '-NoProfile',
+ '-Command',
+ script,
+ ]);
+
+ if (stdout.trim() === 'success') {
+ try {
+ const stats = await fs.stat(tempFilePath);
+ if (stats.size > 0) {
+ return tempFilePath;
+ }
+ } catch {
+ // File doesn't exist
+ }
+ }
+ return null;
+ }
+
// AppleScript clipboard classes to try, in order of preference.
// macOS converts clipboard images to these formats (WEBP/HEIC/HEIF not supported by osascript).
const formats = [
diff --git a/packages/cli/src/ui/utils/clipboardUtils.windows.test.ts b/packages/cli/src/ui/utils/clipboardUtils.windows.test.ts
new file mode 100644
index 0000000000..714c631640
--- /dev/null
+++ b/packages/cli/src/ui/utils/clipboardUtils.windows.test.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs/promises';
+import { saveClipboardImage } from './clipboardUtils.js';
+
+// Mock dependencies
+vi.mock('node:fs/promises');
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ spawnAsync: vi.fn(),
+ };
+});
+
+describe('saveClipboardImage Windows Path Escaping', () => {
+ const originalPlatform = process.platform;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ Object.defineProperty(process, 'platform', {
+ value: 'win32',
+ });
+
+ // Mock fs calls to succeed
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any);
+ });
+
+ afterEach(() => {
+ Object.defineProperty(process, 'platform', {
+ value: originalPlatform,
+ });
+ });
+
+ it('should escape single quotes in path for PowerShell script', async () => {
+ const { spawnAsync } = await import('@google/gemini-cli-core');
+ vi.mocked(spawnAsync).mockResolvedValue({
+ stdout: 'success',
+ stderr: '',
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ const targetDir = "C:\\User's Files";
+ await saveClipboardImage(targetDir);
+
+ expect(spawnAsync).toHaveBeenCalled();
+ const args = vi.mocked(spawnAsync).mock.calls[0][1];
+ const script = args[2];
+
+ // The path C:\User's Files\.gemini-clipboard\clipboard-....png
+ // should be escaped in the script as 'C:\User''s Files\...'
+
+ // Check if the script contains the escaped path
+ expect(script).toMatch(/'C:\\User''s Files/);
+ });
+});