Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
.gemini/
!gemini/config.yaml

# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images

# Dependency directory
node_modules
bower_components
Expand Down
126 changes: 123 additions & 3 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import { vi } from 'vitest';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';

vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../utils/clipboardUtils.js');

type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
type MockedUseCompletion = ReturnType<typeof useCompletion>;
Expand Down Expand Up @@ -76,6 +78,7 @@ describe('InputPrompt', () => {
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
}),
replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
allVisualLines: [''],
visualCursor: [0, 0],
Expand All @@ -87,7 +90,6 @@ describe('InputPrompt', () => {
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
newline: vi.fn(),
replaceRangeByOffset: vi.fn(),
} as unknown as TextBuffer;

mockShellHistory = {
Expand Down Expand Up @@ -218,6 +220,126 @@ describe('InputPrompt', () => {
unmount();
});

describe('clipboard image paste', () => {
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
undefined,
);
});

it('should handle Ctrl+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-123.png',
);

const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();

// Send Ctrl+V
stdin.write('\x16'); // Ctrl+V
await wait();

expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
unmount();
});

it('should not insert anything when clipboard has no image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);

const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();

stdin.write('\x16'); // Ctrl+V
await wait();

expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
expect(mockBuffer.setText).not.toHaveBeenCalled();
unmount();
});

it('should handle image save failure gracefully', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);

const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();

stdin.write('\x16'); // Ctrl+V
await wait();

expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
expect(mockBuffer.setText).not.toHaveBeenCalled();
unmount();
});

it('should insert image path at cursor position with proper spacing', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-456.png',
);

// Set initial text and cursor position
mockBuffer.text = 'Hello world';
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
mockBuffer.lines = ['Hello world'];
mockBuffer.replaceRangeByOffset = vi.fn();

const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();

stdin.write('\x16'); // Ctrl+V
await wait();

// Should insert at cursor position with spaces
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();

// Get the actual call to see what path was used
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
.calls[0];
expect(actualCall[0]).toBe(5); // start offset
expect(actualCall[1]).toBe(5); // end offset
expect(actualCall[2]).toMatch(
/@.*\.gemini-clipboard\/clipboard-456\.png/,
); // flexible path match
unmount();
});

it('should handle errors during clipboard operations', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
new Error('Clipboard error'),
);

const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();

stdin.write('\x16'); // Ctrl+V
await wait();

expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error handling clipboard image:',
expect.any(Error),
);
expect(mockBuffer.setText).not.toHaveBeenCalled();

consoleErrorSpy.mockRestore();
unmount();
});
});

it('should complete a partial parent command and add a space', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
Expand Down Expand Up @@ -355,8 +477,6 @@ describe('InputPrompt', () => {
unmount();
});

// ADD this test for defensive coverage

it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace

Expand Down
63 changes: 62 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import * as path from 'path';

export interface InputPromptProps {
buffer: TextBuffer;
Expand Down Expand Up @@ -52,7 +58,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setShellModeActive,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);

const completion = useCompletion(
buffer.text,
config.getTargetDir(),
Expand Down Expand Up @@ -178,6 +183,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
[resetCompletionState, buffer, completionSuggestions, slashCommands],
);

// Handle clipboard image pasting with Ctrl+V
const handleClipboardImage = useCallback(async () => {
try {
if (await clipboardHasImage()) {
const imagePath = await saveClipboardImage(config.getTargetDir());
if (imagePath) {
// Clean up old images
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
// Ignore cleanup errors
});

// Get relative path from current directory
const relativePath = path.relative(config.getTargetDir(), imagePath);

// Insert @path reference at cursor position
const insertText = `@${relativePath}`;
const currentText = buffer.text;
const [row, col] = buffer.cursor;

// Calculate offset from row/col
let offset = 0;
for (let i = 0; i < row; i++) {
offset += buffer.lines[i].length + 1; // +1 for newline
}
offset += col;

// Add spaces around the path if needed
let textToInsert = insertText;
const charBefore = offset > 0 ? currentText[offset - 1] : '';
const charAfter =
offset < currentText.length ? currentText[offset] : '';

if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
textToInsert = ' ' + textToInsert;
}
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
textToInsert = textToInsert + ' ';
}

// Insert at cursor position
buffer.replaceRangeByOffset(offset, offset, textToInsert);
}
}
} catch (error) {
console.error('Error handling clipboard image:', error);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry to be clear, please add tests for this logic added to InputPrompt then LGTM.

}
}, [buffer, config]);

const handleInput = useCallback(
(key: Key) => {
if (!focus) {
Expand Down Expand Up @@ -315,6 +368,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}

// Ctrl+V for clipboard image paste
if (key.ctrl && key.name === 'v') {
handleClipboardImage();
return;
}

// Fallback to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
Expand All @@ -329,6 +388,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleAutocomplete,
handleSubmitAndClear,
shellHistory,
handleClipboardImage,
],
);

Expand Down Expand Up @@ -372,6 +432,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({

if (visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;

if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/ui/utils/clipboardUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from './clipboardUtils.js';

describe('clipboardUtils', () => {
describe('clipboardHasImage', () => {
it('should return false on non-macOS platforms', async () => {
if (process.platform !== 'darwin') {
const result = await clipboardHasImage();
expect(result).toBe(false);
} else {
// Skip on macOS as it would require actual clipboard state
expect(true).toBe(true);
}
});

it('should return boolean on macOS', async () => {
if (process.platform === 'darwin') {
const result = await clipboardHasImage();
expect(typeof result).toBe('boolean');
} else {
// Skip on non-macOS
expect(true).toBe(true);
}
});
});

describe('saveClipboardImage', () => {
it('should return null on non-macOS platforms', async () => {
if (process.platform !== 'darwin') {
const result = await saveClipboardImage();
expect(result).toBe(null);
} else {
// Skip on macOS
expect(true).toBe(true);
}
});

it('should handle errors gracefully', async () => {
// Test with invalid directory (should not throw)
const result = await saveClipboardImage(
'/invalid/path/that/does/not/exist',
);

if (process.platform === 'darwin') {
// On macOS, might return null due to various errors
expect(result === null || typeof result === 'string').toBe(true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does mac return a string?

} else {
// On other platforms, should always return null
expect(result).toBe(null);
}
});
});

describe('cleanupOldClipboardImages', () => {
it('should not throw errors', async () => {
// Should handle missing directories gracefully
await expect(
cleanupOldClipboardImages('/path/that/does/not/exist'),
).resolves.not.toThrow();
});

it('should complete without errors on valid directory', async () => {
await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow();
});
});
});
Loading