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; type MockedUseCompletion = ReturnType; @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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 = ({ setShellModeActive, }) => { const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); - const completion = useCompletion( buffer.text, config.getTargetDir(), @@ -178,6 +183,54 @@ export const InputPrompt: React.FC = ({ [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 = ({ 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 = ({ handleAutocomplete, handleSubmitAndClear, shellHistory, + handleClipboardImage, ], ); @@ -372,6 +432,7 @@ export const InputPrompt: React.FC = ({ 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 { + 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 { + 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 { + 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 + } +}