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);
+ }
+}