summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJayson Dasher <[email protected]>2025-07-12 00:06:49 -0400
committerGitHub <[email protected]>2025-07-12 04:06:49 +0000
commitc9e194ec6ae514cd35f1bdd8cebd6cc556a74208 (patch)
treecb2414f7050ef972ba0549a888376d099e3d6bc8
parentc4ea17692f4fac8df304993d2271704e79344968 (diff)
feat: Add clipboard image paste support for macOS (#1580)
Co-authored-by: Jacob Richman <[email protected]> Co-authored-by: Scott Densmore <[email protected]>
-rw-r--r--.gitignore2
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx126
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx63
-rw-r--r--packages/cli/src/ui/utils/clipboardUtils.test.ts76
-rw-r--r--packages/cli/src/ui/utils/clipboardUtils.ts149
5 files changed, 412 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore
index 8afd3293..d347dbfb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,8 @@
.gemini/
!gemini/config.yaml
+# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
+
# Dependency directory
node_modules
bower_components
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 6f3f996d..ad7a3985 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -13,11 +13,13 @@ import { vi } from 'vitest';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
+import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
vi.mock('../hooks/useInputHistory.js');
+vi.mock('../utils/clipboardUtils.js');
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
type MockedUseCompletion = ReturnType<typeof useCompletion>;
@@ -76,6 +78,7 @@ describe('InputPrompt', () => {
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
}),
+ replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
allVisualLines: [''],
visualCursor: [0, 0],
@@ -87,7 +90,6 @@ describe('InputPrompt', () => {
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
newline: vi.fn(),
- replaceRangeByOffset: vi.fn(),
} as unknown as TextBuffer;
mockShellHistory = {
@@ -218,6 +220,126 @@ describe('InputPrompt', () => {
unmount();
});
+ describe('clipboard image paste', () => {
+ beforeEach(() => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
+ vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
+ undefined,
+ );
+ });
+
+ it('should handle Ctrl+V when clipboard has an image', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
+ '/test/.gemini-clipboard/clipboard-123.png',
+ );
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ // Send Ctrl+V
+ stdin.write('\x16'); // Ctrl+V
+ await wait();
+
+ expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
+ expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
+ props.config.getTargetDir(),
+ );
+ expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
+ props.config.getTargetDir(),
+ );
+ expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should not insert anything when clipboard has no image', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\x16'); // Ctrl+V
+ await wait();
+
+ expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
+ expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
+ expect(mockBuffer.setText).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should handle image save failure gracefully', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\x16'); // Ctrl+V
+ await wait();
+
+ expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
+ expect(mockBuffer.setText).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should insert image path at cursor position with proper spacing', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
+ '/test/.gemini-clipboard/clipboard-456.png',
+ );
+
+ // Set initial text and cursor position
+ mockBuffer.text = 'Hello world';
+ mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
+ mockBuffer.lines = ['Hello world'];
+ mockBuffer.replaceRangeByOffset = vi.fn();
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\x16'); // Ctrl+V
+ await wait();
+
+ // Should insert at cursor position with spaces
+ expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
+
+ // Get the actual call to see what path was used
+ const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
+ .calls[0];
+ expect(actualCall[0]).toBe(5); // start offset
+ expect(actualCall[1]).toBe(5); // end offset
+ expect(actualCall[2]).toMatch(
+ /@.*\.gemini-clipboard\/clipboard-456\.png/,
+ ); // flexible path match
+ unmount();
+ });
+
+ it('should handle errors during clipboard operations', async () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
+ new Error('Clipboard error'),
+ );
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\x16'); // Ctrl+V
+ await wait();
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error handling clipboard image:',
+ expect.any(Error),
+ );
+ expect(mockBuffer.setText).not.toHaveBeenCalled();
+
+ consoleErrorSpy.mockRestore();
+ unmount();
+ });
+ });
+
it('should complete a partial parent command and add a space', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
@@ -355,8 +477,6 @@ describe('InputPrompt', () => {
unmount();
});
- // ADD this test for defensive coverage
-
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 3771f5b9..371fb48d 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -19,6 +19,12 @@ import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core';
+import {
+ clipboardHasImage,
+ saveClipboardImage,
+ cleanupOldClipboardImages,
+} from '../utils/clipboardUtils.js';
+import * as path from 'path';
export interface InputPromptProps {
buffer: TextBuffer;
@@ -52,7 +58,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setShellModeActive,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
-
const completion = useCompletion(
buffer.text,
config.getTargetDir(),
@@ -178,6 +183,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
[resetCompletionState, buffer, completionSuggestions, slashCommands],
);
+ // Handle clipboard image pasting with Ctrl+V
+ const handleClipboardImage = useCallback(async () => {
+ try {
+ if (await clipboardHasImage()) {
+ const imagePath = await saveClipboardImage(config.getTargetDir());
+ if (imagePath) {
+ // Clean up old images
+ cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
+ // Ignore cleanup errors
+ });
+
+ // Get relative path from current directory
+ const relativePath = path.relative(config.getTargetDir(), imagePath);
+
+ // Insert @path reference at cursor position
+ const insertText = `@${relativePath}`;
+ const currentText = buffer.text;
+ const [row, col] = buffer.cursor;
+
+ // Calculate offset from row/col
+ let offset = 0;
+ for (let i = 0; i < row; i++) {
+ offset += buffer.lines[i].length + 1; // +1 for newline
+ }
+ offset += col;
+
+ // Add spaces around the path if needed
+ let textToInsert = insertText;
+ const charBefore = offset > 0 ? currentText[offset - 1] : '';
+ const charAfter =
+ offset < currentText.length ? currentText[offset] : '';
+
+ if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
+ textToInsert = ' ' + textToInsert;
+ }
+ if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
+ textToInsert = textToInsert + ' ';
+ }
+
+ // Insert at cursor position
+ buffer.replaceRangeByOffset(offset, offset, textToInsert);
+ }
+ }
+ } catch (error) {
+ console.error('Error handling clipboard image:', error);
+ }
+ }, [buffer, config]);
+
const handleInput = useCallback(
(key: Key) => {
if (!focus) {
@@ -315,6 +368,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
+ // Ctrl+V for clipboard image paste
+ if (key.ctrl && key.name === 'v') {
+ handleClipboardImage();
+ return;
+ }
+
// Fallback to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
@@ -329,6 +388,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleAutocomplete,
handleSubmitAndClear,
shellHistory,
+ handleClipboardImage,
],
);
@@ -372,6 +432,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
+
if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts
new file mode 100644
index 00000000..30258889
--- /dev/null
+++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ clipboardHasImage,
+ saveClipboardImage,
+ cleanupOldClipboardImages,
+} from './clipboardUtils.js';
+
+describe('clipboardUtils', () => {
+ describe('clipboardHasImage', () => {
+ it('should return false on non-macOS platforms', async () => {
+ if (process.platform !== 'darwin') {
+ const result = await clipboardHasImage();
+ expect(result).toBe(false);
+ } else {
+ // Skip on macOS as it would require actual clipboard state
+ expect(true).toBe(true);
+ }
+ });
+
+ it('should return boolean on macOS', async () => {
+ if (process.platform === 'darwin') {
+ const result = await clipboardHasImage();
+ expect(typeof result).toBe('boolean');
+ } else {
+ // Skip on non-macOS
+ expect(true).toBe(true);
+ }
+ });
+ });
+
+ describe('saveClipboardImage', () => {
+ it('should return null on non-macOS platforms', async () => {
+ if (process.platform !== 'darwin') {
+ const result = await saveClipboardImage();
+ expect(result).toBe(null);
+ } else {
+ // Skip on macOS
+ expect(true).toBe(true);
+ }
+ });
+
+ it('should handle errors gracefully', async () => {
+ // Test with invalid directory (should not throw)
+ const result = await saveClipboardImage(
+ '/invalid/path/that/does/not/exist',
+ );
+
+ if (process.platform === 'darwin') {
+ // On macOS, might return null due to various errors
+ expect(result === null || typeof result === 'string').toBe(true);
+ } else {
+ // On other platforms, should always return null
+ expect(result).toBe(null);
+ }
+ });
+ });
+
+ describe('cleanupOldClipboardImages', () => {
+ it('should not throw errors', async () => {
+ // Should handle missing directories gracefully
+ await expect(
+ cleanupOldClipboardImages('/path/that/does/not/exist'),
+ ).resolves.not.toThrow();
+ });
+
+ it('should complete without errors on valid directory', async () => {
+ await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts
new file mode 100644
index 00000000..74554495
--- /dev/null
+++ b/packages/cli/src/ui/utils/clipboardUtils.ts
@@ -0,0 +1,149 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import * as fs from 'fs/promises';
+import * as path from 'path';
+
+const execAsync = promisify(exec);
+
+/**
+ * Checks if the system clipboard contains an image (macOS only for now)
+ * @returns true if clipboard contains an image
+ */
+export async function clipboardHasImage(): Promise<boolean> {
+ if (process.platform !== 'darwin') {
+ return false;
+ }
+
+ try {
+ // Use osascript to check clipboard type
+ const { stdout } = await execAsync(
+ `osascript -e 'clipboard info' 2>/dev/null | grep -qE "«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»" && echo "true" || echo "false"`,
+ { shell: '/bin/bash' },
+ );
+ return stdout.trim() === 'true';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Saves the image from clipboard to a temporary file (macOS only for now)
+ * @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') {
+ return null;
+ }
+
+ try {
+ // Create a temporary directory for clipboard images within the target directory
+ // This avoids security restrictions on paths outside the target directory
+ const baseDir = targetDir || process.cwd();
+ const tempDir = path.join(baseDir, '.gemini-clipboard');
+ await fs.mkdir(tempDir, { recursive: true });
+
+ // Generate a unique filename with timestamp
+ const timestamp = new Date().getTime();
+
+ // Try different image formats in order of preference
+ const formats = [
+ { class: 'PNGf', extension: 'png' },
+ { class: 'JPEG', extension: 'jpg' },
+ { class: 'TIFF', extension: 'tiff' },
+ { class: 'GIFf', extension: 'gif' },
+ ];
+
+ for (const format of formats) {
+ const tempFilePath = path.join(
+ tempDir,
+ `clipboard-${timestamp}.${format.extension}`,
+ );
+
+ // Try to save clipboard as this format
+ const script = `
+ try
+ set imageData to the clipboard as «class ${format.class}»
+ set fileRef to open for access POSIX file "${tempFilePath}" with write permission
+ write imageData to fileRef
+ close access fileRef
+ return "success"
+ on error errMsg
+ try
+ close access POSIX file "${tempFilePath}"
+ end try
+ return "error"
+ end try
+ `;
+
+ const { stdout } = await execAsync(`osascript -e '${script}'`);
+
+ if (stdout.trim() === 'success') {
+ // Verify the file was created and has content
+ try {
+ const stats = await fs.stat(tempFilePath);
+ if (stats.size > 0) {
+ return tempFilePath;
+ }
+ } catch {
+ // File doesn't exist, continue to next format
+ }
+ }
+
+ // Clean up failed attempt
+ try {
+ await fs.unlink(tempFilePath);
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+
+ // No format worked
+ return null;
+ } catch (error) {
+ console.error('Error saving clipboard image:', error);
+ return null;
+ }
+}
+
+/**
+ * Cleans up old temporary clipboard image files
+ * Removes files older than 1 hour
+ * @param targetDir The target directory where temp files are stored
+ */
+export async function cleanupOldClipboardImages(
+ targetDir?: string,
+): Promise<void> {
+ try {
+ const baseDir = targetDir || process.cwd();
+ const tempDir = path.join(baseDir, '.gemini-clipboard');
+ const files = await fs.readdir(tempDir);
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
+
+ for (const file of files) {
+ if (
+ file.startsWith('clipboard-') &&
+ (file.endsWith('.png') ||
+ file.endsWith('.jpg') ||
+ file.endsWith('.tiff') ||
+ file.endsWith('.gif'))
+ ) {
+ const filePath = path.join(tempDir, file);
+ const stats = await fs.stat(filePath);
+ if (stats.mtimeMs < oneHourAgo) {
+ await fs.unlink(filePath);
+ }
+ }
+ }
+ } catch {
+ // Ignore errors in cleanup
+ }
+}