Skip to content

Commit 4e430ff

Browse files
authored
Merge branch 'google-gemini:main' into main
2 parents 11a6d6f + b3cbde5 commit 4e430ff

File tree

10 files changed

+674
-317
lines changed

10 files changed

+674
-317
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
.gemini/
77
!gemini/config.yaml
88

9+
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
10+
911
# Dependency directory
1012
node_modules
1113
bower_components

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ The Gemini API provides a free tier with [100 requests per day](https://ai.googl
5353

5454
### Use a Vertex AI API key:
5555

56-
The Vertex AI provides [free tier](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) using express mode for Gemini 2.5 Pro, control over which model you use, and access to higher rate limits with a billing account:
56+
The Vertex AI API provides a [free tier](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) using express mode for Gemini 2.5 Pro, control over which model you use, and access to higher rate limits with a billing account:
5757

5858
1. Generate a key from [Google Cloud](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys).
5959
2. Set it as an environment variable in your terminal. Replace `YOUR_API_KEY` with your generated key and set GOOGLE_GENAI_USE_VERTEXAI to true

packages/cli/src/ui/components/InputPrompt.test.tsx

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import { vi } from 'vitest';
1313
import { useShellHistory } from '../hooks/useShellHistory.js';
1414
import { useCompletion } from '../hooks/useCompletion.js';
1515
import { useInputHistory } from '../hooks/useInputHistory.js';
16+
import * as clipboardUtils from '../utils/clipboardUtils.js';
1617
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
1718

1819
vi.mock('../hooks/useShellHistory.js');
1920
vi.mock('../hooks/useCompletion.js');
2021
vi.mock('../hooks/useInputHistory.js');
22+
vi.mock('../utils/clipboardUtils.js');
2123

2224
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
2325
type MockedUseCompletion = ReturnType<typeof useCompletion>;
@@ -76,6 +78,7 @@ describe('InputPrompt', () => {
7678
mockBuffer.viewportVisualLines = [newText];
7779
mockBuffer.allVisualLines = [newText];
7880
}),
81+
replaceRangeByOffset: vi.fn(),
7982
viewportVisualLines: [''],
8083
allVisualLines: [''],
8184
visualCursor: [0, 0],
@@ -87,7 +90,6 @@ describe('InputPrompt', () => {
8790
killLineLeft: vi.fn(),
8891
openInExternalEditor: vi.fn(),
8992
newline: vi.fn(),
90-
replaceRangeByOffset: vi.fn(),
9193
} as unknown as TextBuffer;
9294

9395
mockShellHistory = {
@@ -218,6 +220,126 @@ describe('InputPrompt', () => {
218220
unmount();
219221
});
220222

223+
describe('clipboard image paste', () => {
224+
beforeEach(() => {
225+
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
226+
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
227+
vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
228+
undefined,
229+
);
230+
});
231+
232+
it('should handle Ctrl+V when clipboard has an image', async () => {
233+
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
234+
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
235+
'/test/.gemini-clipboard/clipboard-123.png',
236+
);
237+
238+
const { stdin, unmount } = render(<InputPrompt {...props} />);
239+
await wait();
240+
241+
// Send Ctrl+V
242+
stdin.write('\x16'); // Ctrl+V
243+
await wait();
244+
245+
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
246+
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
247+
props.config.getTargetDir(),
248+
);
249+
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
250+
props.config.getTargetDir(),
251+
);
252+
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
253+
unmount();
254+
});
255+
256+
it('should not insert anything when clipboard has no image', async () => {
257+
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
258+
259+
const { stdin, unmount } = render(<InputPrompt {...props} />);
260+
await wait();
261+
262+
stdin.write('\x16'); // Ctrl+V
263+
await wait();
264+
265+
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
266+
expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
267+
expect(mockBuffer.setText).not.toHaveBeenCalled();
268+
unmount();
269+
});
270+
271+
it('should handle image save failure gracefully', async () => {
272+
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
273+
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
274+
275+
const { stdin, unmount } = render(<InputPrompt {...props} />);
276+
await wait();
277+
278+
stdin.write('\x16'); // Ctrl+V
279+
await wait();
280+
281+
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
282+
expect(mockBuffer.setText).not.toHaveBeenCalled();
283+
unmount();
284+
});
285+
286+
it('should insert image path at cursor position with proper spacing', async () => {
287+
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
288+
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
289+
'/test/.gemini-clipboard/clipboard-456.png',
290+
);
291+
292+
// Set initial text and cursor position
293+
mockBuffer.text = 'Hello world';
294+
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
295+
mockBuffer.lines = ['Hello world'];
296+
mockBuffer.replaceRangeByOffset = vi.fn();
297+
298+
const { stdin, unmount } = render(<InputPrompt {...props} />);
299+
await wait();
300+
301+
stdin.write('\x16'); // Ctrl+V
302+
await wait();
303+
304+
// Should insert at cursor position with spaces
305+
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
306+
307+
// Get the actual call to see what path was used
308+
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
309+
.calls[0];
310+
expect(actualCall[0]).toBe(5); // start offset
311+
expect(actualCall[1]).toBe(5); // end offset
312+
expect(actualCall[2]).toMatch(
313+
/@.*\.gemini-clipboard\/clipboard-456\.png/,
314+
); // flexible path match
315+
unmount();
316+
});
317+
318+
it('should handle errors during clipboard operations', async () => {
319+
const consoleErrorSpy = vi
320+
.spyOn(console, 'error')
321+
.mockImplementation(() => {});
322+
vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
323+
new Error('Clipboard error'),
324+
);
325+
326+
const { stdin, unmount } = render(<InputPrompt {...props} />);
327+
await wait();
328+
329+
stdin.write('\x16'); // Ctrl+V
330+
await wait();
331+
332+
expect(consoleErrorSpy).toHaveBeenCalledWith(
333+
'Error handling clipboard image:',
334+
expect.any(Error),
335+
);
336+
expect(mockBuffer.setText).not.toHaveBeenCalled();
337+
338+
consoleErrorSpy.mockRestore();
339+
unmount();
340+
});
341+
});
342+
221343
it('should complete a partial parent command and add a space', async () => {
222344
// SCENARIO: /mem -> Tab
223345
mockedUseCompletion.mockReturnValue({
@@ -355,8 +477,6 @@ describe('InputPrompt', () => {
355477
unmount();
356478
});
357479

358-
// ADD this test for defensive coverage
359-
360480
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
361481
props.buffer.setText(' '); // Set buffer to whitespace
362482

packages/cli/src/ui/components/InputPrompt.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import { useKeypress, Key } from '../hooks/useKeypress.js';
1919
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
2020
import { CommandContext, SlashCommand } from '../commands/types.js';
2121
import { Config } from '@google/gemini-cli-core';
22+
import {
23+
clipboardHasImage,
24+
saveClipboardImage,
25+
cleanupOldClipboardImages,
26+
} from '../utils/clipboardUtils.js';
27+
import * as path from 'path';
2228

2329
export interface InputPromptProps {
2430
buffer: TextBuffer;
@@ -52,7 +58,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
5258
setShellModeActive,
5359
}) => {
5460
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
55-
5661
const completion = useCompletion(
5762
buffer.text,
5863
config.getTargetDir(),
@@ -178,6 +183,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
178183
[resetCompletionState, buffer, completionSuggestions, slashCommands],
179184
);
180185

186+
// Handle clipboard image pasting with Ctrl+V
187+
const handleClipboardImage = useCallback(async () => {
188+
try {
189+
if (await clipboardHasImage()) {
190+
const imagePath = await saveClipboardImage(config.getTargetDir());
191+
if (imagePath) {
192+
// Clean up old images
193+
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
194+
// Ignore cleanup errors
195+
});
196+
197+
// Get relative path from current directory
198+
const relativePath = path.relative(config.getTargetDir(), imagePath);
199+
200+
// Insert @path reference at cursor position
201+
const insertText = `@${relativePath}`;
202+
const currentText = buffer.text;
203+
const [row, col] = buffer.cursor;
204+
205+
// Calculate offset from row/col
206+
let offset = 0;
207+
for (let i = 0; i < row; i++) {
208+
offset += buffer.lines[i].length + 1; // +1 for newline
209+
}
210+
offset += col;
211+
212+
// Add spaces around the path if needed
213+
let textToInsert = insertText;
214+
const charBefore = offset > 0 ? currentText[offset - 1] : '';
215+
const charAfter =
216+
offset < currentText.length ? currentText[offset] : '';
217+
218+
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
219+
textToInsert = ' ' + textToInsert;
220+
}
221+
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
222+
textToInsert = textToInsert + ' ';
223+
}
224+
225+
// Insert at cursor position
226+
buffer.replaceRangeByOffset(offset, offset, textToInsert);
227+
}
228+
}
229+
} catch (error) {
230+
console.error('Error handling clipboard image:', error);
231+
}
232+
}, [buffer, config]);
233+
181234
const handleInput = useCallback(
182235
(key: Key) => {
183236
if (!focus) {
@@ -315,6 +368,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
315368
return;
316369
}
317370

371+
// Ctrl+V for clipboard image paste
372+
if (key.ctrl && key.name === 'v') {
373+
handleClipboardImage();
374+
return;
375+
}
376+
318377
// Fallback to the text buffer's default input handling for all other keys
319378
buffer.handleInput(key);
320379
},
@@ -329,6 +388,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
329388
handleAutocomplete,
330389
handleSubmitAndClear,
331390
shellHistory,
391+
handleClipboardImage,
332392
],
333393
);
334394

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

373433
if (visualIdxInRenderedSet === cursorVisualRow) {
374434
const relativeVisualColForHighlight = cursorVisualColAbsolute;
435+
375436
if (relativeVisualColForHighlight >= 0) {
376437
if (relativeVisualColForHighlight < cpLen(display)) {
377438
const charToHighlight =

0 commit comments

Comments
 (0)