@@ -13,11 +13,13 @@ import { vi } from 'vitest';
1313import { useShellHistory } from '../hooks/useShellHistory.js' ;
1414import { useCompletion } from '../hooks/useCompletion.js' ;
1515import { useInputHistory } from '../hooks/useInputHistory.js' ;
16+ import * as clipboardUtils from '../utils/clipboardUtils.js' ;
1617import { createMockCommandContext } from '../../test-utils/mockCommandContext.js' ;
1718
1819vi . mock ( '../hooks/useShellHistory.js' ) ;
1920vi . mock ( '../hooks/useCompletion.js' ) ;
2021vi . mock ( '../hooks/useInputHistory.js' ) ;
22+ vi . mock ( '../utils/clipboardUtils.js' ) ;
2123
2224type MockedUseShellHistory = ReturnType < typeof useShellHistory > ;
2325type 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+ / @ .* \. g e m i n i - c l i p b o a r d \/ c l i p b o a r d - 4 5 6 \. p n g / ,
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
0 commit comments