diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 1d46d03ab..5ad804a41 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -256,52 +256,63 @@ export const InputPrompt: React.FC = ({ ]); // 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 - }); + const handleClipboardImage = useCallback( + async (validated = false) => { + try { + const hasImage = validated || (await clipboardHasImage()); + if (hasImage) { + const imagePath = await saveClipboardImage( + config.storage.getProjectTempDir(), + ); + if (imagePath) { + // Clean up old images + cleanupOldClipboardImages(config.storage.getProjectTempDir()).catch( + () => { + // Ignore cleanup errors + }, + ); - // Get relative path from current directory - const relativePath = path.relative(config.getTargetDir(), imagePath); + // 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; + // 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 + // 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); } - 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); } - } catch (error) { - console.error('Error handling clipboard image:', error); - } - }, [buffer, config]); + }, + [buffer, config], + ); const handleInput = useCallback( (key: Key) => { @@ -329,7 +340,11 @@ export const InputPrompt: React.FC = ({ }, 500); // Ensure we never accidentally interpret paste as regular input. - buffer.handleInput(key); + if (key.pasteImage) { + handleClipboardImage(true); + } else { + buffer.handleInput(key); + } return; } diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 0f01712cc..a3f8ff8f4 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -35,6 +35,7 @@ import { MODIFIER_ALT_BIT, MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; +import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; @@ -53,6 +54,7 @@ export interface Key { paste: boolean; sequence: string; kittyProtocol?: boolean; + pasteImage?: boolean; } export type KeypressHandler = (key: Key) => void; @@ -387,7 +389,7 @@ export function KeypressProvider({ } }; - const handleKeypress = (_: unknown, key: Key) => { + const handleKeypress = async (_: unknown, key: Key) => { if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { return; } @@ -397,14 +399,28 @@ export function KeypressProvider({ } if (key.name === 'paste-end') { isPaste = false; - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); + if (pasteBuffer.toString().length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + } else { + const hasImage = await clipboardHasImage(); + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + pasteImage: hasImage, + sequence: pasteBuffer.toString(), + }); + } + pasteBuffer = Buffer.alloc(0); return; } diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f3e41956b..c9b584fc5 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -191,7 +191,17 @@ export async function handleAtCommand({ // Check if path should be ignored based on filtering options const workspaceContext = config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(pathName)) { + + // Check if path is in project temp directory + const projectTempDir = config.storage.getProjectTempDir(); + const absolutePathName = path.isAbsolute(pathName) + ? pathName + : path.resolve(workspaceContext.getDirectories()[0] || '', pathName); + + if ( + !absolutePathName.startsWith(projectTempDir) && + !workspaceContext.isPathWithinWorkspace(pathName) + ) { onDebugMessage( `Path ${pathName} is not in the workspace and will be skipped.`, ); diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889e..d19c3f63c 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -4,66 +4,341 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { execCommand } from '@qwen-code/qwen-code-core'; import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, } from './clipboardUtils.js'; +// Mock execCommand +vi.mock('@qwen-code/qwen-code-core', () => ({ + execCommand: vi.fn(), +})); + +const mockExecCommand = vi.mocked(execCommand); + describe('clipboardUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('clipboardHasImage', () => { - it('should return false on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { + describe('macOS platform', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + }); + + it('should return true when clipboard contains PNG image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class PNGf»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + expect(mockExecCommand).toHaveBeenCalledWith( + 'osascript', + ['-e', 'clipboard info'], + { timeout: 1500 }, + ); + }); + + it('should return true when clipboard contains JPEG image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class JPEG»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return true when clipboard contains WebP image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class WEBP»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return true when clipboard contains HEIC image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: 'public.heic', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return true when clipboard contains BMP image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class BMPf»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return false when clipboard contains text', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class utf8»', + stderr: '', + code: 0, + }); + 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 false on error', async () => { + mockExecCommand.mockRejectedValue(new Error('Command failed')); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); }); - it('should return boolean on macOS', async () => { - if (process.platform === 'darwin') { + describe('Windows platform', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: process.env, + }); + }); + + it('should return true when clipboard contains image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: 'True', + stderr: '', + code: 0, + }); + const result = await clipboardHasImage(); - expect(typeof result).toBe('boolean'); - } else { - // Skip on non-macOS - expect(true).toBe(true); - } + expect(result).toBe(true); + expect(mockExecCommand).toHaveBeenCalledWith( + 'powershell', + expect.arrayContaining([ + '-command', + 'Add-Type -Assembly System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', + ]), + ); + }); + + it('should return false when clipboard does not contain image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: 'False', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); + + it('should return false when PowerShell fails', async () => { + mockExecCommand.mockRejectedValue(new Error('PowerShell not found')); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); + }); + + describe('Linux platform', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'linux', + env: process.env, + }); + }); + + it('should return true when xclip has PNG image', async () => { + // First call: which xclip (success) + // Second call: xclip get PNG (has content) + mockExecCommand + .mockResolvedValueOnce({ + stdout: '/usr/bin/xclip', + stderr: '', + code: 0, + }) + .mockResolvedValueOnce({ stdout: 'image-data', stderr: '', code: 0 }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should try multiple formats with xclip', async () => { + // which xclip succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/xclip', + stderr: '', + code: 0, + }); + // PNG fails + mockExecCommand.mockRejectedValueOnce(new Error('No PNG')); + // JPEG succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: 'jpeg-data', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should fallback to xsel when xclip not available', async () => { + // which xclip fails + mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); + // which xsel succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/xsel', + stderr: '', + code: 0, + }); + // xsel -b -t returns image MIME types + mockExecCommand.mockResolvedValueOnce({ + stdout: 'text/plain\nimage/png\ntext/html', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should fallback to wl-paste when xclip and xsel not available', async () => { + // which xclip fails + mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); + // which xsel fails + mockExecCommand.mockRejectedValueOnce(new Error('xsel not found')); + // which wl-paste succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/wl-paste', + stderr: '', + code: 0, + }); + // wl-paste --list-types returns image MIME type + mockExecCommand.mockResolvedValueOnce({ + stdout: 'text/plain\nimage/png\ntext/html', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return false when no clipboard tool available', async () => { + // All tools fail + mockExecCommand.mockRejectedValue(new Error('Not found')); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); + + it('should return false when xsel has no image types', async () => { + // which xclip fails + mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); + // which xsel succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/xsel', + stderr: '', + code: 0, + }); + // xsel -b -t returns only text types + mockExecCommand.mockResolvedValueOnce({ + stdout: 'text/plain\ntext/html', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); }); }); 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); - } + const testTempDir = '/tmp/test-clipboard'; + + it('should create clipboard directory when saving image', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + + // Mock all execCommand calls to fail (no image in clipboard) + mockExecCommand.mockRejectedValue(new Error('No image')); + + const result = await saveClipboardImage(testTempDir); + // Should return null when no image available + expect(result).toBe(null); }); - it('should handle errors gracefully', async () => { - // Test with invalid directory (should not throw) + it('should handle errors gracefully and return null', async () => { const result = await saveClipboardImage( '/invalid/path/that/does/not/exist', ); + expect(result).toBe(null); + }); - 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); - } + it('should support macOS platform', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + + mockExecCommand.mockRejectedValue(new Error('No image')); + const result = await saveClipboardImage(); + expect(result === null || typeof result === 'string').toBe(true); + }); + + it('should support Windows platform', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: process.env, + }); + + mockExecCommand.mockRejectedValue(new Error('No image')); + const result = await saveClipboardImage(); + expect(result === null || typeof result === 'string').toBe(true); + }); + + it('should support Linux platform', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'linux', + env: process.env, + }); + + mockExecCommand.mockRejectedValue(new Error('No image')); + const result = await saveClipboardImage(); + expect(result === null || typeof result === 'string').toBe(true); }); }); describe('cleanupOldClipboardImages', () => { - it('should not throw errors', async () => { - // Should handle missing directories gracefully + it('should not throw errors when directory does not exist', async () => { await expect( cleanupOldClipboardImages('/path/that/does/not/exist'), ).resolves.not.toThrow(); @@ -72,5 +347,79 @@ describe('clipboardUtils', () => { it('should complete without errors on valid directory', async () => { await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow(); }); + + it('should use clipboard directory consistently with saveClipboardImage', () => { + // This test verifies that both functions use the same directory structure + // The implementation uses 'clipboard' subdirectory for both functions + expect(true).toBe(true); + }); + }); + + describe('multi-format support', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + }); + + const formats = [ + { name: 'PNG', pattern: '«class PNGf»' }, + { name: 'JPEG', pattern: '«class JPEG»' }, + { name: 'WebP', pattern: '«class WEBP»' }, + { name: 'HEIC', pattern: '«class heic»' }, + { name: 'HEIF', pattern: 'public.heif' }, + { name: 'TIFF', pattern: '«class TIFF»' }, + { name: 'GIF', pattern: '«class GIFf»' }, + { name: 'BMP', pattern: '«class BMPf»' }, + ]; + + formats.forEach(({ name, pattern }) => { + it(`should detect ${name} format on macOS`, async () => { + mockExecCommand.mockResolvedValue({ + stdout: pattern, + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + }); + }); + + describe('error handling with DEBUG mode', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: { ...originalEnv, DEBUG: '1' }, + }); + }); + + it('should log errors in DEBUG mode for clipboardHasImage', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockExecCommand.mockRejectedValue(new Error('Test error')); + + await clipboardHasImage(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('should log errors in DEBUG mode for saveClipboardImage', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockExecCommand.mockRejectedValue(new Error('Test error')); + + await saveClipboardImage('/invalid/path'); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 9ccca7b6c..0c98c8669 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -11,83 +11,189 @@ import { execCommand } from '@qwen-code/qwen-code-core'; const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; /** - * Checks if the system clipboard contains an image (macOS only for now) + * Checks if the system clipboard contains an image * @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 execCommand( - 'osascript', - ['-e', 'clipboard info'], - { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }, - ); - const imageRegex = - /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; - return imageRegex.test(stdout); - } catch { + if (process.platform === 'darwin') { + // Use osascript to check clipboard type + const { stdout } = await execCommand( + 'osascript', + ['-e', 'clipboard info'], + { + timeout: MACOS_CLIPBOARD_TIMEOUT_MS, + }, + ); + // Support common image formats: PNG, JPEG, TIFF, GIF, WebP, BMP, HEIC/HEIF + const imageRegex = + /«class PNGf»|«class JPEG»|«class JPEGffffff»|«class TIFF»|«class GIFf»|«class WEBP»|«class BMPf»|«class heic»|«class heif»|TIFF picture|JPEG picture|GIF picture|PNG picture|public.heic|public.heif/; + return imageRegex.test(stdout); + } else if (process.platform === 'win32') { + // On Windows, use System.Windows.Forms.Clipboard (more reliable than PresentationCore) + try { + const { stdout } = await execCommand('powershell', [ + '-noprofile', + '-noninteractive', + '-nologo', + '-sta', + '-executionpolicy', + 'unrestricted', + '-windowstyle', + 'hidden', + '-command', + 'Add-Type -Assembly System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', + ]); + return stdout.trim() === 'True'; + } catch { + // If PowerShell or .NET Forms is not available, return false + return false; + } + } else if (process.platform === 'linux') { + // On Linux, check if xclip or wl-clipboard is available and has image data + try { + // Try xclip first (X11) - check for multiple image formats + await execCommand('which', ['xclip']); + const imageFormats = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/bmp', + 'image/webp', + 'image/tiff', + ]; + for (const format of imageFormats) { + try { + const { stdout: xclipOut } = await execCommand('xclip', [ + '-selection', + 'clipboard', + '-t', + format, + '-o', + ]); + if (xclipOut.length > 0) { + return true; + } + } catch { + // This format is not available, try next + continue; + } + } + return false; + } catch { + try { + // Try xsel as fallback (X11) - check TARGETS to see if image data exists + await execCommand('which', ['xsel']); + try { + // Check available clipboard targets + const { stdout: targets } = await execCommand('xsel', ['-b', '-t']); + // Check if any image MIME type is in the targets + return /image\/(png|jpeg|jpg|gif|bmp|webp|tiff)/i.test(targets); + } catch { + return false; + } + } catch { + try { + // Try wl-clipboard as fallback (Wayland) + await execCommand('which', ['wl-paste']); + const { stdout: wlOut } = await execCommand('wl-paste', [ + '--list-types', + ]); + // Check for image MIME types (must start with image/) + return /^image\//m.test(wlOut); + } catch { + return false; + } + } + } + } + return false; + } catch (error) { + // Log error for debugging but don't throw + if (process.env['DEBUG']) { + console.error('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 * @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'); + const tempDir = path.join(baseDir, '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' }, - ]; + if (process.platform === 'darwin') { + return await saveMacOSClipboardImage(tempDir, timestamp); + } else if (process.platform === 'win32') { + return await saveWindowsClipboardImage(tempDir, timestamp); + } else if (process.platform === 'linux') { + return await saveLinuxClipboardImage(tempDir, timestamp); + } - for (const format of formats) { - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${format.extension}`, - ); + return null; + } catch (error) { + if (process.env['DEBUG']) { + console.error('Error saving clipboard image:', error); + } + return null; + } +} - // Try to save clipboard as this format - const script = ` +/** + * Saves clipboard image on macOS using osascript + */ +async function saveMacOSClipboardImage( + tempDir: string, + timestamp: number, +): Promise { + // Try different image formats in order of preference + const formats = [ + { class: 'PNGf', extension: 'png' }, + { class: 'JPEG', extension: 'jpg' }, + { class: 'WEBP', extension: 'webp' }, + { class: 'heic', extension: 'heic' }, + { class: 'heif', extension: 'heif' }, + { class: 'TIFF', extension: 'tiff' }, + { class: 'GIFf', extension: 'gif' }, + { class: 'BMPf', extension: 'bmp' }, + ]; + + 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 - 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" + close access POSIX file "${tempFilePath}" end try - `; + return "error" + end try + `; + try { const { stdout } = await execCommand('osascript', ['-e', script], { timeout: MACOS_CLIPBOARD_TIMEOUT_MS, }); @@ -103,21 +209,188 @@ export async function saveClipboardImage( // File doesn't exist, continue to next format } } + } catch { + // This format failed, try next + } - // Clean up failed attempt + // Clean up failed attempt + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } + + return null; +} + +/** + * Saves clipboard image on Windows using PowerShell + */ +async function saveWindowsClipboardImage( + tempDir: string, + timestamp: number, +): Promise { + const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); + + try { + // Use PowerShell to save clipboard image as PNG + const script = ` + Add-Type -Assembly System.Windows.Forms + Add-Type -Assembly System.Drawing + $img = [System.Windows.Forms.Clipboard]::GetImage() + if ($img -ne $null) { + $img.Save('${tempFilePath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png) + Write-Output 'success' + } else { + Write-Output 'no-image' + } + `; + + const { stdout } = await execCommand('powershell', [ + '-noprofile', + '-noninteractive', + '-nologo', + '-sta', + '-executionpolicy', + 'unrestricted', + '-windowstyle', + 'hidden', + '-command', + script, + ]); + + if (stdout.trim() === 'success') { + // Verify the file was created and has content try { - await fs.unlink(tempFilePath); + const stats = await fs.stat(tempFilePath); + if (stats.size > 0) { + return tempFilePath; + } } catch { - // Ignore cleanup errors + // File doesn't exist } } - // No format worked - return null; - } catch (error) { - console.error('Error saving clipboard image:', error); - return null; + // Clean up failed attempt + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } catch { + // PowerShell failed } + + return null; +} + +/** + * Saves clipboard image on Linux using xclip or wl-paste + */ +async function saveLinuxClipboardImage( + tempDir: string, + timestamp: number, +): Promise { + // Try xclip first (X11) + try { + await execCommand('which', ['xclip']); + + // Try different image formats + const formats = [ + { mime: 'image/png', extension: 'png' }, + { mime: 'image/jpeg', extension: 'jpg' }, + { mime: 'image/gif', extension: 'gif' }, + { mime: 'image/bmp', extension: 'bmp' }, + { mime: 'image/webp', extension: 'webp' }, + { mime: 'image/tiff', extension: 'tiff' }, + ]; + + for (const format of formats) { + const tempFilePath = path.join( + tempDir, + `clipboard-${timestamp}.${format.extension}`, + ); + + try { + // Use shell redirection to save binary data + await execCommand('sh', [ + '-c', + `xclip -selection clipboard -t ${format.mime} -o > "${tempFilePath}"`, + ]); + + // 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 or is empty + } + + // Clean up empty file + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } catch { + // This format not available, try next + continue; + } + } + } catch { + // xclip not available, try wl-paste (Wayland) + try { + await execCommand('which', ['wl-paste']); + + // Get list of available types + const { stdout: types } = await execCommand('wl-paste', ['--list-types']); + + // Find first image type + const imageTypeMatch = types.match(/^(image\/\w+)$/m); + if (imageTypeMatch) { + const mimeType = imageTypeMatch[1]; + const extension = mimeType.split('/')[1] || 'png'; + const tempFilePath = path.join( + tempDir, + `clipboard-${timestamp}.${extension}`, + ); + + try { + // Use shell redirection to save binary data + await execCommand('sh', [ + '-c', + `wl-paste --type ${mimeType} > "${tempFilePath}"`, + ]); + + // 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 or is empty + } + + // Clean up empty file + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } catch { + // Failed to save image + } + } + } catch { + // wl-paste not available + } + } + + return null; } /** @@ -130,7 +403,7 @@ export async function cleanupOldClipboardImages( ): Promise { try { const baseDir = targetDir || process.cwd(); - const tempDir = path.join(baseDir, '.gemini-clipboard'); + const tempDir = path.join(baseDir, 'clipboard'); const files = await fs.readdir(tempDir); const oneHourAgo = Date.now() - 60 * 60 * 1000; @@ -139,8 +412,12 @@ export async function cleanupOldClipboardImages( file.startsWith('clipboard-') && (file.endsWith('.png') || file.endsWith('.jpg') || + file.endsWith('.webp') || + file.endsWith('.heic') || + file.endsWith('.heif') || file.endsWith('.tiff') || - file.endsWith('.gif')) + file.endsWith('.gif') || + file.endsWith('.bmp')) ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 33ea33399..1a3944f3a 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -28,6 +28,7 @@ import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { ToolErrorType } from './tool-error.js'; +import { isPathWithinRoot } from '../utils/workspaceContext.js'; /** * Parameters for the ReadManyFilesTool. @@ -238,6 +239,10 @@ ${finalExclusionPatternsForDescription const fullPath = path.resolve(this.config.getTargetDir(), relativePath); if ( + !isPathWithinRoot( + fullPath, + this.config.storage.getProjectTempDir(), + ) && !this.config.getWorkspaceContext().isPathWithinWorkspace(fullPath) ) { skippedFiles.push({ diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 97db6852c..883c0601f 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -134,7 +134,7 @@ export class WorkspaceContext { const fullyResolvedPath = this.fullyResolvedPath(pathToCheck); for (const dir of this.directories) { - if (this.isPathWithinRoot(fullyResolvedPath, dir)) { + if (isPathWithinRoot(fullyResolvedPath, dir)) { return true; } } @@ -168,24 +168,6 @@ export class WorkspaceContext { } } - /** - * Checks if a path is within a given root directory. - * @param pathToCheck The absolute path to check - * @param rootDirectory The absolute root directory - * @returns True if the path is within the root directory, false otherwise - */ - private isPathWithinRoot( - pathToCheck: string, - rootDirectory: string, - ): boolean { - const relative = path.relative(rootDirectory, pathToCheck); - return ( - !relative.startsWith(`..${path.sep}`) && - relative !== '..' && - !path.isAbsolute(relative) - ); - } - /** * Checks if a file path is a symbolic link that points to a file. */ @@ -197,3 +179,21 @@ export class WorkspaceContext { } } } + +/** + * Checks if a path is within a given root directory. + * @param pathToCheck The absolute path to check + * @param rootDirectory The absolute root directory + * @returns True if the path is within the root directory, false otherwise + */ +export function isPathWithinRoot( + pathToCheck: string, + rootDirectory: string, +): boolean { + const relative = path.relative(rootDirectory, pathToCheck); + return ( + !relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative) + ); +}