diff --git a/package-lock.json b/package-lock.json index c98f13edf1a..14bf00197ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -500,6 +500,150 @@ "node": ">=0.1.90" } }, + "node_modules/@crosscopy/clipboard": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard/-/clipboard-0.2.8.tgz", + "integrity": "sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@crosscopy/clipboard-darwin-arm64": "0.2.8", + "@crosscopy/clipboard-darwin-universal": "0.2.8", + "@crosscopy/clipboard-darwin-x64": "0.2.8", + "@crosscopy/clipboard-linux-arm64-gnu": "0.2.8", + "@crosscopy/clipboard-linux-riscv64-gnu": "0.2.8", + "@crosscopy/clipboard-linux-x64-gnu": "0.2.8", + "@crosscopy/clipboard-win32-arm64-msvc": "0.2.8", + "@crosscopy/clipboard-win32-x64-msvc": "0.2.8" + } + }, + "node_modules/@crosscopy/clipboard-darwin-arm64": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.2.8.tgz", + "integrity": "sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-darwin-universal": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-darwin-universal/-/clipboard-darwin-universal-0.2.8.tgz", + "integrity": "sha512-btGV1tLpJWZ4iKa66niahvpZpVRJzgQnYUE+PUX3YYZzaWD0ESuHuVtKVC8sR+b4dsXIiWW5skXbcRmLsF4rtA==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-darwin-x64": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-darwin-x64/-/clipboard-darwin-x64-0.2.8.tgz", + "integrity": "sha512-0QMKf0XrLZrprYYXU4lgaTuzbnYPh9wH6PvsfDB1FZvWf6rOi0syTaBZYnoghbQe700qwLPEfBRjgljJ3Tn6oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-linux-arm64-gnu": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.2.8.tgz", + "integrity": "sha512-8YrU03MRsygymqEcHkNgqCqSCQbYRmJCnMXeS4i8FYeOkAxBEeRvPbHoNmI10uppXJZNZgfIKM7Qqk9tEHiwqQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-linux-riscv64-gnu": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.2.8.tgz", + "integrity": "sha512-/QWLhnb0QYVjEv5GOAC1q+1DaezYU8Th+IoDKUCsR5i43Cqm+g+N/I2K35yo3J+HHkK9XNbtIDZDXlFgK6tRUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-linux-x64-gnu": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.2.8.tgz", + "integrity": "sha512-j17eaF/onP/6VAGGKtxA1KmmkErmdjta9gMdMV/yUmgeBYzJ9fMpWUzbk2vmaOyXfhaSzR/sk1P6VLBmvCpqHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-win32-arm64-msvc": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.2.8.tgz", + "integrity": "sha512-MVkMyuYN3y5v0s4HrijM0iA8hZVmpUhHd8X4zKG30t4nE6MbOjOt/8EabMrVmGZlsLeOL2sa0o8Wo9bvhWU+vA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-win32-x64-msvc": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.2.8.tgz", + "integrity": "sha512-/GpiB4B3lSgg7eCLDQw9NfFjtQFjo0S88IL+EK54Hx7ZgAP4Ad/ezP/8dw0cA+N/M6iPYy0reCIjW9st82/uxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -12521,6 +12665,150 @@ "node": ">=18" } }, + "node_modules/node-screenshots": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots/-/node-screenshots-0.2.1.tgz", + "integrity": "sha512-1UY7VY/34uE6Giq/Winl0J7022KKwWt9T9Gu5ZBCxhXkWrv9q5pTVQRgZCcUIsIHq3zu8UFu5s8rqgauK2CnLA==", + "license": "Apache-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "node-screenshots-darwin-arm64": "0.2.1", + "node-screenshots-darwin-universal": "0.2.1", + "node-screenshots-darwin-x64": "0.2.1", + "node-screenshots-linux-x64-gnu": "0.2.1", + "node-screenshots-linux-x64-musl": "0.2.1", + "node-screenshots-win32-arm64-msvc": "0.2.1", + "node-screenshots-win32-ia32-msvc": "0.2.1", + "node-screenshots-win32-x64-msvc": "0.2.1" + } + }, + "node_modules/node-screenshots-darwin-arm64": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-darwin-arm64/-/node-screenshots-darwin-arm64-0.2.1.tgz", + "integrity": "sha512-mcNcdn5zABYNVXIb1vq58mItFlpr03T8VJetD892qy+hqNAQdZd/vvplw+ZIlb4tuH7sR1gia67WRsBRO5nrQA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-darwin-universal": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-darwin-universal/-/node-screenshots-darwin-universal-0.2.1.tgz", + "integrity": "sha512-cNqBasCyMU/P87Ej3hK/vedAk86DrVkpoxd2zz5qLA3h850Ew9qb/7g0MTYsRatbZFoLhw7MgFwAZkiNh4Mr9g==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-darwin-x64": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-darwin-x64/-/node-screenshots-darwin-x64-0.2.1.tgz", + "integrity": "sha512-8TOou5WwytgGV+IuV1vnnYaGzwfYgIw6XkOoZtt4qhSmTEW0K8EGa46Uq42/W5qQPxKt3GLqjlY3wL69eZWIyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-linux-x64-gnu": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-linux-x64-gnu/-/node-screenshots-linux-x64-gnu-0.2.1.tgz", + "integrity": "sha512-P2h511my2JytMSAW8+uvO+lGj1BwapERWbC6i56u5WbLIy/zgT1SQwNvcZNbPE8sxb1q8pqF3MerLcftnW0f+w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-linux-x64-musl": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-linux-x64-musl/-/node-screenshots-linux-x64-musl-0.2.1.tgz", + "integrity": "sha512-+B37/VYzH86ywWyF9XkYYicyf/BTan4TADsmxPlK+a/UHHZEnp1YjEM66sBIQTIVhVhv1DC6YUv9AppBpoV9AA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-win32-arm64-msvc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-win32-arm64-msvc/-/node-screenshots-win32-arm64-msvc-0.2.1.tgz", + "integrity": "sha512-+sj1FAF4qufcWO1KdmCOhPRMELfWu7hRv2qvr8e3jLPLM0/XGU8UTWWej9qAhf3UY83LaAsvxMhzai9JLIis1w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-win32-ia32-msvc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-win32-ia32-msvc/-/node-screenshots-win32-ia32-msvc-0.2.1.tgz", + "integrity": "sha512-8fzmFqbotHAzwIARG9fI8eD+Vw2g98Bl7aKlhu57nCjCXNrdl4Ck+b3uvMjdQD+v7rKC/sT+NZK973y4YgRoMA==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-win32-x64-msvc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-win32-x64-msvc/-/node-screenshots-win32-x64-msvc-0.2.1.tgz", + "integrity": "sha512-dfxAck3LR9eTYpc/hVtomQYxpP/80p4+vP9k1whDBCClTzRlHvgx1U9b6c5bYpe8JZFtCZsJL75S5p0kQJk7Dg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -17902,6 +18190,7 @@ "name": "@google/gemini-cli", "version": "0.19.0-nightly.20251123.dadd606c0", "dependencies": { + "@crosscopy/clipboard": "^0.2.8", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -17923,6 +18212,7 @@ "latest-version": "^9.0.0", "lowlight": "^3.3.0", "mnemonist": "^0.40.3", + "node-screenshots": "^0.2.1", "open": "^10.1.2", "prompts": "^2.4.2", "react": "^19.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index ed8361b0d87..d4fa6853e67 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,7 @@ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.0-nightly.20251123.dadd606c0" }, "dependencies": { + "@crosscopy/clipboard": "^0.2.8", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -35,6 +36,7 @@ "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "clipboardy": "^5.0.0", + "node-screenshots": "^0.2.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 497b359f2ef..8535593a316 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -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: 'insert', shift: true }, + ], // App level bindings [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 33e76b0f69d..e33afc340f1 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -555,6 +555,57 @@ describe('InputPrompt', () => { unmount(); }); + it('should render clipboard images with a friendly label', async () => { + const clipboardToken = '@.gemini-clipboard/image-5.png'; + props.buffer.text = clipboardToken; + props.buffer.lines = [clipboardToken]; + props.buffer.viewportVisualLines = [clipboardToken]; + props.buffer.allVisualLines = [clipboardToken]; + props.buffer.visualToLogicalMap = [[0, 0]]; + props.buffer.visualCursor = [0, clipboardToken.length]; + + const { stdout, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await waitFor(() => { + const frame = clean(stdout.lastFrame()); + expect(frame).toContain('[image #5]'); + expect(frame).not.toContain('.gemini-clipboard'); + }); + + unmount(); + }); + + it('should render clipboard images with backslash paths', async () => { + const clipboardToken = '@.gemini-clipboard\\image-7.png'; + props.buffer.text = clipboardToken; + props.buffer.lines = [clipboardToken]; + props.buffer.viewportVisualLines = [clipboardToken]; + props.buffer.allVisualLines = [clipboardToken]; + props.buffer.visualToLogicalMap = [[0, 0]]; + props.buffer.visualCursor = [0, clipboardToken.length]; + + const { stdout, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await waitFor(() => { + const frame = clean(stdout.lastFrame()); + expect(frame).toContain('[image #7]'); + expect(frame).not.toContain('.gemini-clipboard'); + expect(frame).not.toContain('\\image-7.png'); + }); + + unmount(); + }); + it('should handle errors during clipboard operations', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 20f454059bb..57f0879046d 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -101,6 +101,15 @@ export const calculatePromptWidths = (mainContentWidth: number) => { } as const; }; +const CLIPBOARD_IMAGE_TOKEN_REGEX = + /^@\.gemini-clipboard[\\/](?:clipboard|image)-(\d+)\.(?:png|jpe?g|gif|bmp|webp|tiff)$/i; + +const getClipboardImageLabel = (tokenText: string): string | null => { + const match = CLIPBOARD_IMAGE_TOKEN_REGEX.exec(tokenText); + if (!match) return null; + return `[image #${match[1]}]`; +}; + export const InputPrompt: React.FC = ({ buffer, onSubmit, @@ -225,10 +234,18 @@ export const InputPrompt: React.FC = ({ if (shellModeActive) { shellHistory.addCommandToHistory(submittedValue); } + + // Transform [image #N] to @.gemini-clipboard/image-N.png before submission + // Most clipboard images are PNG format + const transformedValue = submittedValue.replace( + /\[image #(\d+)\]/g, + '@.gemini-clipboard/image-$1.png', + ); + // Clear the buffer *before* calling onSubmit to prevent potential re-submission // if onSubmit triggers a re-render while the buffer still holds the old value. buffer.setText(''); - onSubmit(submittedValue); + onSubmit(transformedValue); resetCompletionState(); resetReverseSearchCompletionState(); }, @@ -331,15 +348,18 @@ export const InputPrompt: React.FC = ({ // Ignore cleanup errors }); - // Get relative path from current directory - const relativePath = path.relative(config.getTargetDir(), imagePath); + // Extract image number from filename (e.g., "image-1.png" -> 1) + const filename = path.basename(imagePath); + const imageNumberMatch = filename.match(/image-(\d+)\./); + const imageNumber = imageNumberMatch ? imageNumberMatch[1] : '?'; - // Insert @path reference at cursor position - const insertText = `@${relativePath}`; + // Insert friendly label only: [image #1] + // The actual path will be resolved at submit time + const insertText = `[image #${imageNumber}]`; const currentText = buffer.text; const offset = buffer.getOffset(); - // Add spaces around the path if needed + // Add spaces around the text if needed let textToInsert = insertText; const charBefore = offset > 0 ? currentText[offset - 1] : ''; const charAfter = @@ -362,7 +382,7 @@ export const InputPrompt: React.FC = ({ const offset = buffer.getOffset(); buffer.replaceRangeByOffset(offset, offset, textToInsert); } catch (error) { - console.error('Error handling clipboard image:', error); + console.error('Error handling clipboard paste:', error); } }, [buffer, config]); @@ -1074,17 +1094,25 @@ export const InputPrompt: React.FC = ({ let charCount = 0; segments.forEach((seg, segIdx) => { const segLen = cpLen(seg.text); - let display = seg.text; + const clipboardLabel = getClipboardImageLabel(seg.text); + let display = clipboardLabel ?? seg.text; if (isOnCursorLine) { const relativeVisualColForHighlight = cursorVisualColAbsolute; const segStart = charCount; const segEnd = segStart + segLen; - if ( + + const cursorInSegment = relativeVisualColForHighlight >= segStart && - relativeVisualColForHighlight < segEnd - ) { + relativeVisualColForHighlight < segEnd; + + if (clipboardLabel) { + display = + cursorInSegment && showCursor + ? chalk.inverse(clipboardLabel) + : clipboardLabel; + } else if (cursorInSegment) { const charToHighlight = cpSlice( seg.text, relativeVisualColForHighlight - segStart, diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889edb..64abeb3e4d8 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -4,73 +4,300 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, } from './clipboardUtils.js'; +// Mock the @crosscopy/clipboard module +vi.mock('@crosscopy/clipboard', () => ({ + default: { + hasImage: vi.fn(), + getImageBase64: vi.fn(), + }, + })); + +// Import the mocked module +import Clipboard from '@crosscopy/clipboard'; + describe('clipboardUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + 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 true when clipboard has an image', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + expect(Clipboard.hasImage).toHaveBeenCalledTimes(1); }); - 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); - } + it('should return false when clipboard has no image', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(false); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + expect(Clipboard.hasImage).toHaveBeenCalledTimes(1); + }); + + it('should return false on error', async () => { + vi.mocked(Clipboard.hasImage).mockRejectedValue( + new Error('Clipboard access error'), + ); + + 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 = path.join(process.cwd(), '.gemini-clipboard-test'); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors } }); + it('should return null when clipboard has no image', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(false); + + const result = await saveClipboardImage(testTempDir); + expect(result).toBe(null); + }); + + it('should save PNG image from clipboard', async () => { + // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A + const pngData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const base64PNG = pngData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64PNG); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toContain('.gemini-clipboard-test'); + expect(result).toMatch(/clipboard-\d+\.png$/); + + // Verify file exists and has correct content + if (result) { + const fileContent = await fs.readFile(result); + expect(fileContent).toEqual(pngData); + } + }); + + it('should save JPEG image from clipboard', async () => { + // JPEG magic bytes: FF D8 FF E0 + const jpegData = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + const base64JPEG = jpegData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64JPEG); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.jpg$/); + }); + + it('should save GIF image from clipboard', async () => { + // GIF magic bytes: 47 49 46 38 39 61 + const gifData = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + const base64GIF = gifData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64GIF); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.gif$/); + }); + + it('should save WebP image from clipboard', async () => { + // WebP magic bytes: RIFF....WEBP + const webpData = Buffer.from([ + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, + ]); + const base64WebP = webpData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64WebP); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.webp$/); + }); + + it('should default to PNG for unknown format', async () => { + // Random bytes that don't match any known format + const unknownData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]); + const base64Unknown = unknownData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64Unknown); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.png$/); + }); + + it('should return null when getImageBase64 returns empty string', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(''); + + const result = await saveClipboardImage(testTempDir); + expect(result).toBe(null); + }); + it('should handle errors gracefully', async () => { - // Test with invalid directory (should not throw) - const result = await saveClipboardImage( - '/invalid/path/that/does/not/exist', + vi.mocked(Clipboard.hasImage).mockRejectedValue( + new Error('Clipboard error'), ); - 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); - } + const result = await saveClipboardImage(testTempDir); + expect(result).toBe(null); + }); + + it('should create .gemini-clipboard directory if it does not exist', async () => { + const pngData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const base64PNG = pngData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64PNG); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + + // Verify directory was created + const dirExists = await fs + .access(path.join(testTempDir, '.gemini-clipboard')) + .then(() => true) + .catch(() => false); + expect(dirExists).toBe(true); }); }); describe('cleanupOldClipboardImages', () => { - it('should not throw errors', async () => { - // Should handle missing directories gracefully + const testTempDir = path.join(process.cwd(), '.gemini-clipboard-cleanup'); + const clipboardDir = path.join(testTempDir, '.gemini-clipboard'); + + beforeEach(async () => { + // Create test directory structure + await fs.mkdir(clipboardDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should not throw errors when directory does not exist', async () => { 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(); + await expect( + cleanupOldClipboardImages(testTempDir), + ).resolves.not.toThrow(); + }); + + it('should remove old clipboard images', async () => { + // Create an old file (2 hours ago) + const oldFile = path.join(clipboardDir, 'clipboard-1000000000.png'); + await fs.writeFile(oldFile, 'old image data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(oldFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldClipboardImages(testTempDir); + + // File should be deleted + const fileExists = await fs + .access(oldFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(false); + }); + + it('should keep recent clipboard images', async () => { + // Create a recent file + const recentFile = path.join(clipboardDir, 'clipboard-2000000000.png'); + await fs.writeFile(recentFile, 'recent image data'); + + await cleanupOldClipboardImages(testTempDir); + + // File should still exist + const fileExists = await fs + .access(recentFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + + it('should only clean up clipboard image files', async () => { + // Create a non-clipboard file + const otherFile = path.join(clipboardDir, 'other-file.txt'); + await fs.writeFile(otherFile, 'other file data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(otherFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldClipboardImages(testTempDir); + + // File should still exist (not a clipboard file) + const fileExists = await fs + .access(otherFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + + it('should handle multiple file formats', async () => { + const formats = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff']; + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + + // Create old files with different formats + for (const format of formats) { + const oldFile = path.join( + clipboardDir, + `clipboard-${Date.now()}.${format}`, + ); + await fs.writeFile(oldFile, `old ${format} data`); + await fs.utimes(oldFile, twoHoursAgo, twoHoursAgo); + } + + await cleanupOldClipboardImages(testTempDir); + + // All files should be deleted + const files = await fs.readdir(clipboardDir); + const clipboardFiles = files.filter((f) => f.startsWith('clipboard-')); + expect(clipboardFiles.length).toBe(0); }); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 86da766efaa..11dd7e6b751 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -5,104 +5,324 @@ */ import * as fs from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import * as path from 'node:path'; import { debugLogger, spawnAsync } from '@google/gemini-cli-core'; +import Clipboard from '@crosscopy/clipboard'; /** - * Checks if the system clipboard contains an image (macOS only for now) + * Detects if the system is running in WSL (Windows Subsystem for Linux) + * @returns true if running in WSL + */ +function isWSL(): boolean { + try { + if (process.platform !== 'linux') { + return false; + } + // Check for WSL-specific indicators + const releaseInfo = readFileSync('/proc/version', 'utf8'); + return releaseInfo.toLowerCase().includes('microsoft'); + } catch { + return false; + } +} + +/** + * Checks if PowerShell is available (for WSL) + * @returns path to powershell.exe or null if not available + */ +async function getPowerShellPath(): Promise { + try { + // Try pwsh.exe first (PowerShell 7+) + const { stdout: pwshPath } = await spawnAsync('which', ['pwsh.exe']); + if (pwshPath.trim()) { + return 'pwsh.exe'; + } + } catch { + // pwsh.exe not found + } + + try { + // Try powershell.exe (Windows PowerShell) + const { stdout: psPath } = await spawnAsync('which', ['powershell.exe']); + if (psPath.trim()) { + return 'powershell.exe'; + } + } catch { + // powershell.exe not found + } + + return null; +} + +/** + * Checks if clipboard has image using PowerShell (WSL) * @returns true if clipboard contains an image */ -export async function clipboardHasImage(): Promise { - if (process.platform !== 'darwin') { +async function wslClipboardHasImage(): Promise { + try { + const powershell = await getPowerShellPath(); + if (!powershell) { + debugLogger.warn('PowerShell not found in WSL'); + return false; + } + + const script = ` + Add-Type -AssemblyName System.Windows.Forms; + [System.Windows.Forms.Clipboard]::ContainsImage() + `; + + const { stdout } = await spawnAsync(powershell, [ + '-NoProfile', + '-NonInteractive', + '-Command', + script, + ]); + + return stdout.trim().toLowerCase() === 'true'; + } catch (error) { + debugLogger.warn('Error checking WSL clipboard for image:', error); return false; } +} +/** + * Gets the next available image number by checking existing files + * @param targetDir The target directory + * @returns The next image number + */ +async function getNextImageNumber(targetDir: string): Promise { try { - // Use osascript to check clipboard type - const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']); - const imageRegex = - /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; - return imageRegex.test(stdout); + const tempDir = path.join(targetDir, '.gemini-clipboard'); + await fs.mkdir(tempDir, { recursive: true }); + + const files = await fs.readdir(tempDir); + const imageFiles = files.filter((f) => f.match(/^image-(\d+)\.png$/)); + + if (imageFiles.length === 0) { + return 1; + } + + // Extract numbers and find the max + const numbers = imageFiles.map((f) => { + const match = f.match(/^image-(\d+)\.png$/); + return match ? parseInt(match[1], 10) : 0; + }); + + return Math.max(...numbers) + 1; } catch { + return 1; + } +} + +/** + * Saves clipboard image using PowerShell (WSL) + * @param targetDir The target directory to save the image + * @returns The path to the saved image file, or null on error + */ +async function wslSaveClipboardImage( + targetDir?: string, +): Promise { + try { + const powershell = await getPowerShellPath(); + if (!powershell) { + debugLogger.warn('PowerShell not found in WSL'); + return null; + } + + // Create temp directory in WSL + const baseDir = targetDir || process.cwd(); + const tempDir = path.join(baseDir, '.gemini-clipboard'); + await fs.mkdir(tempDir, { recursive: true }); + + // Get next image number + const imageNumber = await getNextImageNumber(baseDir); + const wslPath = path.join(tempDir, `image-${imageNumber}.png`); + + // Convert WSL path to Windows path for PowerShell + const { stdout: winPath } = await spawnAsync('wslpath', ['-w', wslPath]); + const windowsPath = winPath.trim(); + + // PowerShell script to save clipboard image + const script = ` + Add-Type -AssemblyName System.Windows.Forms; + Add-Type -AssemblyName System.Drawing; + + $image = [System.Windows.Forms.Clipboard]::GetImage(); + if ($image -ne $null) { + $image.Save('${windowsPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png); + Write-Output 'success'; + } else { + Write-Output 'no-image'; + } + `; + + const { stdout } = await spawnAsync(powershell, [ + '-NoProfile', + '-NonInteractive', + '-Command', + script, + ]); + + if (stdout.trim() !== 'success') { + return null; + } + + // Verify file exists and has content + const stats = await fs.stat(wslPath); + if (stats.size > 0) { + debugLogger.debug( + `Saved WSL clipboard image to ${wslPath} (${stats.size} bytes)`, + ); + return wslPath; + } + + // File is empty, clean up + await fs.unlink(wslPath); + return null; + } catch (error) { + debugLogger.warn('Error saving WSL clipboard image:', error); + return null; + } +} + +/** + * Checks if the system clipboard contains an image (cross-platform + WSL) + * @returns true if clipboard contains an image + */ +export async function clipboardHasImage(): Promise { + // Check if running in WSL + if (isWSL()) { + return await wslClipboardHasImage(); + } + + // Use cross-platform clipboard library + try { + return await Clipboard.hasImage(); + } 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) + * Detects image format from base64 data by checking magic bytes + * @param base64Data The base64 encoded image data + * @returns The detected file extension (png, jpg, gif, bmp, webp) or 'png' as default + */ +function detectImageFormat(base64Data: string): string { + try { + // Decode first few bytes to check magic numbers + const buffer = Buffer.from(base64Data.slice(0, 16), 'base64'); + + // PNG: 89 50 4E 47 + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e) { + return 'png'; + } + + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'jpg'; + } + + // GIF: 47 49 46 + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { + return 'gif'; + } + + // BMP: 42 4D + if (buffer[0] === 0x42 && buffer[1] === 0x4d) { + return 'bmp'; + } + + // WebP: 52 49 46 46 ... 57 45 42 50 + if ( + buffer[0] === 0x52 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x46 && + buffer[8] === 0x57 && + buffer[9] === 0x45 && + buffer[10] === 0x42 && + buffer[11] === 0x50 + ) { + return 'webp'; + } + + // TIFF (little-endian): 49 49 2A 00 + if (buffer[0] === 0x49 && buffer[1] === 0x49 && buffer[2] === 0x2a) { + return 'tiff'; + } + + // TIFF (big-endian): 4D 4D 00 2A + if (buffer[0] === 0x4d && buffer[1] === 0x4d && buffer[2] === 0x00) { + return 'tiff'; + } + + // Default to PNG if format is unknown + return 'png'; + } catch { + return 'png'; + } +} + +/** + * Saves the image from clipboard to a temporary file (cross-platform + WSL) * @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; + // Check if running in WSL + if (isWSL()) { + return await wslSaveClipboardImage(targetDir); } + // Use cross-platform clipboard library try { + // Check if clipboard has an image + if (!(await clipboardHasImage())) { + return null; + } + + // Get the image data as base64 + const base64Data = await Clipboard.getImageBase64(); + if (!base64Data) { + return null; + } + // 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}`, - ); + // Get next image number + const imageNumber = await getNextImageNumber(baseDir); - // 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 spawnAsync('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 - } - } + // Detect image format from magic bytes + const extension = detectImageFormat(base64Data); + const tempFilePath = path.join( + tempDir, + `image-${imageNumber}.${extension}`, + ); - // Clean up failed attempt - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } + // Convert base64 to buffer and save to file + const imageBuffer = Buffer.from(base64Data, 'base64'); + await fs.writeFile(tempFilePath, imageBuffer); + + // Verify the file was created and has content + const stats = await fs.stat(tempFilePath); + if (stats.size > 0) { + debugLogger.debug( + `Saved clipboard image to ${tempFilePath} (${stats.size} bytes, format: ${extension})`, + ); + return tempFilePath; } - // No format worked + // File is empty, clean up and return null + await fs.unlink(tempFilePath); return null; } catch (error) { debugLogger.warn('Error saving clipboard image:', error); @@ -126,20 +346,20 @@ export async function cleanupOldClipboardImages( for (const file of files) { if ( - file.startsWith('clipboard-') && - (file.endsWith('.png') || - file.endsWith('.jpg') || - file.endsWith('.tiff') || - file.endsWith('.gif')) + file.match( + /^(clipboard-\d+|image-\d+)\.(png|jpg|jpeg|tiff|gif|bmp|webp)$/, + ) ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); if (stats.mtimeMs < oneHourAgo) { await fs.unlink(filePath); + debugLogger.debug(`Cleaned up old clipboard image: ${filePath}`); } } } - } catch { - // Ignore errors in cleanup + } catch (error) { + // Ignore errors in cleanup - directory might not exist yet + debugLogger.debug('Clipboard cleanup skipped:', error); } } diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts index 1e48b36f13d..4126469b8de 100644 --- a/packages/cli/src/ui/utils/highlight.ts +++ b/packages/cli/src/ui/utils/highlight.ts @@ -11,7 +11,7 @@ export type HighlightToken = { type: 'default' | 'command' | 'file'; }; -const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_./-])+)/g; +const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_.\\\\/:-])+)/g; export function parseInputForHighlighting( text: string, diff --git a/packages/cli/src/ui/utils/screenshotUtils.test.ts b/packages/cli/src/ui/utils/screenshotUtils.test.ts new file mode 100644 index 00000000000..bd9940f85db --- /dev/null +++ b/packages/cli/src/ui/utils/screenshotUtils.test.ts @@ -0,0 +1,279 @@ +/** + * @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 * as path from 'node:path'; +import { + isScreenshotAvailable, + listDisplays, + captureScreenshot, + captureScreenshotFromDisplay, + cleanupOldScreenshots, +} from './screenshotUtils.js'; + +// Mock node-screenshots module +vi.mock('node-screenshots', () => { + const mockImage = { + width: 1920, + height: 1080, + toPngSync: vi.fn(), + }; + + const mockMonitor = { + id: 1, + name: 'Mock Display', + width: 1920, + height: 1080, + x: 0, + y: 0, + rotation: 0, + scaleFactor: 1, + frequency: 60, + isPrimary: true, + captureImageSync: vi.fn(() => mockImage), + }; + + return { + Monitor: { + all: vi.fn(() => [mockMonitor]), + }, + Image: vi.fn(), + Window: vi.fn(), + }; +}); + +describe('screenshotUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('isScreenshotAvailable', () => { + it('should return true when node-screenshots is available', async () => { + const result = await isScreenshotAvailable(); + expect(result).toBe(true); + }); + }); + + describe('listDisplays', () => { + it('should return list of available displays', async () => { + const displays = await listDisplays(); + expect(displays).toHaveLength(1); + expect(displays[0]).toEqual({ + id: 1, + name: 'Mock Display', + width: 1920, + height: 1080, + }); + }); + + it('should handle errors gracefully', async () => { + // Mock Monitor.all to throw error + const { Monitor } = await import('node-screenshots'); + vi.mocked(Monitor.all).mockImplementationOnce(() => { + throw new Error('Display error'); + }); + + const displays = await listDisplays(); + expect(displays).toEqual([]); + }); + }); + + describe('captureScreenshot', () => { + const testTempDir = path.join(process.cwd(), '.gemini-screenshots-test'); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should capture screenshot from primary display', async () => { + // Mock screenshot data + const mockImageData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + const mockImage = mockMonitor.captureImageSync(); + if (mockImage) { + vi.mocked(mockImage.toPngSync).mockReturnValue(mockImageData); + } + + const result = await captureScreenshot(testTempDir); + + expect(result).not.toBe(null); + expect(result).toContain('.gemini-screenshots-test'); + expect(result).toMatch(/screenshot-\d+\.png$/); + + // Verify file exists and has correct content + if (result) { + const fileContent = await fs.readFile(result); + expect(fileContent).toEqual(mockImageData); + } + }); + + it('should return null when capture fails', async () => { + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(mockMonitor.captureImageSync).mockReturnValue(null as any); + + const result = await captureScreenshot(testTempDir); + expect(result).toBe(null); + }); + + it('should create screenshot directory if it does not exist', async () => { + const mockImageData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + const mockImage = mockMonitor.captureImageSync(); + if (mockImage) { + vi.mocked(mockImage.toPngSync).mockReturnValue(mockImageData); + } + + const result = await captureScreenshot(testTempDir); + expect(result).not.toBe(null); + + // Verify directory was created + const dirExists = await fs + .access(path.join(testTempDir, '.gemini-screenshots')) + .then(() => true) + .catch(() => false); + expect(dirExists).toBe(true); + }); + }); + + describe('captureScreenshotFromDisplay', () => { + const testTempDir = path.join(process.cwd(), '.gemini-screenshots-test2'); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should capture screenshot from specified display', async () => { + const mockImageData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + const mockImage = mockMonitor.captureImageSync(); + if (mockImage) { + vi.mocked(mockImage.toPngSync).mockReturnValue(mockImageData); + } + + const result = await captureScreenshotFromDisplay(1, testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/screenshot-\d+\.png$/); + }); + + it('should return null for invalid display ID', async () => { + const result = await captureScreenshotFromDisplay(999, testTempDir); + expect(result).toBe(null); + }); + + it('should return null for negative display ID', async () => { + const result = await captureScreenshotFromDisplay(-1, testTempDir); + expect(result).toBe(null); + }); + }); + + describe('cleanupOldScreenshots', () => { + const testTempDir = path.join(process.cwd(), '.gemini-screenshots-cleanup'); + const screenshotDir = path.join(testTempDir, '.gemini-screenshots'); + + beforeEach(async () => { + // Create test directory structure + await fs.mkdir(screenshotDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should not throw errors when directory does not exist', async () => { + await expect( + cleanupOldScreenshots('/path/that/does/not/exist'), + ).resolves.not.toThrow(); + }); + + it('should complete without errors on valid directory', async () => { + await expect(cleanupOldScreenshots(testTempDir)).resolves.not.toThrow(); + }); + + it('should remove old screenshot files', async () => { + // Create an old file (2 hours ago) + const oldFile = path.join(screenshotDir, 'screenshot-1000000000.png'); + await fs.writeFile(oldFile, 'old screenshot data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(oldFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldScreenshots(testTempDir); + + // File should be deleted + const fileExists = await fs + .access(oldFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(false); + }); + + it('should keep recent screenshot files', async () => { + // Create a recent file + const recentFile = path.join(screenshotDir, 'screenshot-2000000000.png'); + await fs.writeFile(recentFile, 'recent screenshot data'); + + await cleanupOldScreenshots(testTempDir); + + // File should still exist + const fileExists = await fs + .access(recentFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + + it('should only clean up screenshot files', async () => { + // Create a non-screenshot file + const otherFile = path.join(screenshotDir, 'other-file.txt'); + await fs.writeFile(otherFile, 'other file data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(otherFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldScreenshots(testTempDir); + + // File should still exist (not a screenshot file) + const fileExists = await fs + .access(otherFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/screenshotUtils.ts b/packages/cli/src/ui/utils/screenshotUtils.ts new file mode 100644 index 00000000000..370394b398e --- /dev/null +++ b/packages/cli/src/ui/utils/screenshotUtils.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { debugLogger } from '@google/gemini-cli-core'; + +// Dynamically import node-screenshots to handle optional dependency +let screenshots: typeof import('node-screenshots') | null = null; + +/** + * Lazily loads the node-screenshots module + * @returns The screenshots module or null if not available + */ +async function getScreenshotsModule() { + if (screenshots) { + return screenshots; + } + + try { + screenshots = await import('node-screenshots'); + return screenshots; + } catch (error) { + debugLogger.warn('node-screenshots not available:', error); + return null; + } +} + +/** + * Checks if screenshot capture is available on this platform + * @returns true if screenshot capture is supported + */ +export async function isScreenshotAvailable(): Promise { + const module = await getScreenshotsModule(); + return module !== null; +} + +/** + * Lists all available displays/monitors + * @returns Array of display information or empty array if not available + */ +export async function listDisplays(): Promise< + Array<{ id: number; name: string; width: number; height: number }> +> { + try { + const module = await getScreenshotsModule(); + if (!module) { + return []; + } + + const monitors = module.Monitor.all(); + return monitors.map((monitor) => ({ + id: monitor.id, + name: monitor.name || `Display ${monitor.id}`, + width: monitor.width, + height: monitor.height, + })); + } catch (error) { + debugLogger.warn('Error listing displays:', error); + return []; + } +} + +/** + * Captures a screenshot from the primary display + * @param targetDir The target directory to save the screenshot + * @returns The path to the saved screenshot file, or null on error + */ +export async function captureScreenshot( + targetDir?: string, +): Promise { + try { + const module = await getScreenshotsModule(); + if (!module) { + debugLogger.warn('Screenshot capture not available'); + return null; + } + + // Get all monitors + const monitors = module.Monitor.all(); + if (monitors.length === 0) { + debugLogger.warn('No monitors available for capture'); + return null; + } + + // Find the primary monitor or use the first one + const primaryMonitor = monitors.find((m) => m.isPrimary) || monitors[0]; + + // Capture the screenshot + const image = primaryMonitor.captureImageSync(); + if (!image) { + debugLogger.warn('Failed to capture screenshot'); + return null; + } + + // Convert to PNG + const pngBuffer = image.toPngSync(); + + // Create screenshot directory + const baseDir = targetDir || process.cwd(); + const screenshotDir = path.join(baseDir, '.gemini-screenshots'); + await fs.mkdir(screenshotDir, { recursive: true }); + + // Generate unique filename with timestamp + const timestamp = new Date().getTime(); + const screenshotPath = path.join( + screenshotDir, + `screenshot-${timestamp}.png`, + ); + + // Save the screenshot + await fs.writeFile(screenshotPath, pngBuffer); + + // Verify the file was created + const stats = await fs.stat(screenshotPath); + if (stats.size > 0) { + debugLogger.debug( + `Captured screenshot to ${screenshotPath} (${stats.size} bytes)`, + ); + return screenshotPath; + } + + // File is empty, clean up + await fs.unlink(screenshotPath); + return null; + } catch (error) { + debugLogger.warn('Error capturing screenshot:', error); + return null; + } +} + +/** + * Captures a screenshot from a specific display + * @param displayId The ID of the display to capture from + * @param targetDir The target directory to save the screenshot + * @returns The path to the saved screenshot file, or null on error + */ +export async function captureScreenshotFromDisplay( + displayId: number, + targetDir?: string, +): Promise { + try { + const module = await getScreenshotsModule(); + if (!module) { + debugLogger.warn('Screenshot capture not available'); + return null; + } + + const monitors = module.Monitor.all(); + const monitor = monitors.find((m) => m.id === displayId); + + if (!monitor) { + debugLogger.warn(`Monitor with ID ${displayId} not found`); + return null; + } + + const image = monitor.captureImageSync(); + if (!image) { + debugLogger.warn('Failed to capture screenshot'); + return null; + } + + // Convert to PNG + const pngBuffer = image.toPngSync(); + + // Create screenshot directory + const baseDir = targetDir || process.cwd(); + const screenshotDir = path.join(baseDir, '.gemini-screenshots'); + await fs.mkdir(screenshotDir, { recursive: true }); + + // Generate unique filename with timestamp + const timestamp = new Date().getTime(); + const screenshotPath = path.join( + screenshotDir, + `screenshot-${timestamp}.png`, + ); + + // Save the screenshot + await fs.writeFile(screenshotPath, pngBuffer); + + // Verify the file was created + const stats = await fs.stat(screenshotPath); + if (stats.size > 0) { + debugLogger.debug( + `Captured screenshot to ${screenshotPath} (${stats.size} bytes)`, + ); + return screenshotPath; + } + + // File is empty, clean up + await fs.unlink(screenshotPath); + return null; + } catch (error) { + debugLogger.warn('Error capturing screenshot:', error); + return null; + } +} + +/** + * Cleans up old screenshot files + * Removes files older than 1 hour + * @param targetDir The target directory where screenshots are stored + */ +export async function cleanupOldScreenshots(targetDir?: string): Promise { + try { + const baseDir = targetDir || process.cwd(); + const screenshotDir = path.join(baseDir, '.gemini-screenshots'); + const files = await fs.readdir(screenshotDir); + const oneHourAgo = Date.now() - 60 * 60 * 1000; + + for (const file of files) { + if (file.startsWith('screenshot-') && file.endsWith('.png')) { + const filePath = path.join(screenshotDir, file); + const stats = await fs.stat(filePath); + if (stats.mtimeMs < oneHourAgo) { + await fs.unlink(filePath); + debugLogger.debug(`Cleaned up old screenshot: ${filePath}`); + } + } + } + } catch (error) { + // Ignore errors in cleanup - directory might not exist yet + debugLogger.debug('Screenshot cleanup skipped:', error); + } +}