feat(ui): add Windows clipboard image support and Alt+V paste workaround (#15218)

Co-authored-by: sgeraldes <sgeraldes@users.noreply.github.com>
This commit is contained in:
Jacob Richman
2025-12-17 14:05:25 -08:00
committed by GitHub
parent a6d1245a54
commit 3d486ec1e9
5 changed files with 136 additions and 21 deletions

View File

@@ -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`<br />`Cmd + V` |
#### App Controls

View File

@@ -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' }],

View File

@@ -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

View File

@@ -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<boolean> {
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<boolean> {
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<string | null> {
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 = [

View File

@@ -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<typeof import('@google/gemini-cli-core')>();
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/);
});
});