mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
63
packages/cli/src/ui/utils/clipboardUtils.windows.test.ts
Normal file
63
packages/cli/src/ui/utils/clipboardUtils.windows.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user