From 3d486ec1e9cf70cd1d8ce29bb3830dca3e7f3344 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Wed, 17 Dec 2025 14:05:25 -0800 Subject: [PATCH] feat(ui): add Windows clipboard image support and Alt+V paste workaround (#15218) Co-authored-by: sgeraldes --- docs/cli/keyboard-shortcuts.md | 8 +-- packages/cli/src/config/keyBindings.ts | 5 +- .../cli/src/ui/utils/clipboardUtils.test.ts | 24 +++---- packages/cli/src/ui/utils/clipboardUtils.ts | 57 +++++++++++++++-- .../ui/utils/clipboardUtils.windows.test.ts | 63 +++++++++++++++++++ 5 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/ui/utils/clipboardUtils.windows.test.ts 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/); + }); +});