From 7d153e0238497f3f4e6f965a7530a72ee18fdfca Mon Sep 17 00:00:00 2001 From: Jayson Dasher <58889274+jaysondasher@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:31:56 -0400 Subject: [PATCH 1/7] feat: Add clipboard image paste support for macOS Implements clipboard image pasting functionality using Ctrl+V to paste images from clipboard. Images are automatically saved to a temporary directory and inserted as @ commands for Gemini to process. Features: - Ctrl+V keyboard shortcut to paste images from clipboard (macOS only) - Automatic detection of clipboard image content using AppleScript - Clean [Image #N] display format instead of showing file paths - Per-message sequential image numbering starting from 1 - Proper cursor positioning with formatted text - Automatic cleanup of old clipboard images (1 hour retention) - Integration with existing @ command system Technical implementation: - New clipboardUtils.ts module for macOS clipboard operations - New messageFormatting.ts module for clean image display - Enhanced InputPrompt component with Ctrl+V handler - Updated UserMessage component to format image references - Images saved to .gemini-clipboard/ directory within project Addresses issue #1452 for clipboard image paste functionality. Platform support: macOS only (uses AppleScript for clipboard access) Future work: Add Linux/Windows clipboard support --- .gitignore | 2 + .../cli/src/ui/components/InputPrompt.tsx | 151 +++++++++++++---- .../ui/components/messages/UserMessage.tsx | 4 +- packages/cli/src/ui/utils/clipboardUtils.ts | 125 ++++++++++++++ .../cli/src/ui/utils/messageFormatting.ts | 156 ++++++++++++++++++ 5 files changed, 402 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/ui/utils/clipboardUtils.ts create mode 100644 packages/cli/src/ui/utils/messageFormatting.ts diff --git a/.gitignore b/.gitignore index 8afd32935e8..d347dbfb25a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 6721132ddd1..e064d1159ef 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -19,6 +19,16 @@ import { useCompletion } from '../hooks/useCompletion.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { SlashCommand } from '../hooks/slashCommandProcessor.js'; import { Config } from '@google/gemini-cli-core'; +import { + clipboardHasImage, + saveClipboardImage, + cleanupOldClipboardImages, +} from '../utils/clipboardUtils.js'; +import { + formatUserMessageForDisplay, + mapCursorPosition, +} from '../utils/messageFormatting.js'; +import * as path from 'path'; export interface InputPromptProps { buffer: TextBuffer; @@ -42,7 +52,7 @@ export const InputPrompt: React.FC = ({ onClearScreen, config, slashCommands, - placeholder = ' Type your message or @path/to/file', + placeholder = ' Type your message or @path/to/file (Ctrl+V for images)', focus = true, inputWidth, suggestionsWidth, @@ -50,7 +60,6 @@ export const InputPrompt: React.FC = ({ setShellModeActive, }) => { const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); - const completion = useCompletion( buffer.text, config.getTargetDir(), @@ -155,6 +164,40 @@ export const InputPrompt: React.FC = ({ ], ); + // 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 clean @path reference + const currentText = buffer.text; + const insertText = `@${relativePath}`; + + let newText: string; + if (!currentText || currentText.endsWith(' ')) { + newText = currentText + insertText + ' '; + } else { + newText = currentText + ' ' + insertText + ' '; + } + + // setText automatically moves cursor to end, no need for moveToOffset + buffer.setText(newText); + } + } + } catch (error) { + console.error('Error handling clipboard image:', error); + } + }, [buffer, config]); + useInput( (input, key) => { if (!focus) { @@ -348,6 +391,12 @@ export const InputPrompt: React.FC = ({ return; } + // Ctrl+V for image paste (like Claude Code) + if (key.ctrl && input === 'v') { + handleClipboardImage(); + return; + } + // Fallback to buffer's default input handling buffer.handleInput(input, key as Record); }, @@ -384,41 +433,73 @@ export const InputPrompt: React.FC = ({ {placeholder} ) ) : ( - linesToRender.map((lineText, visualIdxInRenderedSet) => { - const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; - let display = cpSlice(lineText, 0, inputWidth); - const currentVisualWidth = stringWidth(display); - if (currentVisualWidth < inputWidth) { - display = display + ' '.repeat(inputWidth - currentVisualWidth); - } - - if (visualIdxInRenderedSet === cursorVisualRow) { - const relativeVisualColForHighlight = cursorVisualColAbsolute; - if (relativeVisualColForHighlight >= 0) { - if (relativeVisualColForHighlight < cpLen(display)) { - const charToHighlight = - cpSlice( - display, - relativeVisualColForHighlight, - relativeVisualColForHighlight + 1, - ) || ' '; - const highlighted = chalk.inverse(charToHighlight); - display = - cpSlice(display, 0, relativeVisualColForHighlight) + - highlighted + - cpSlice(display, relativeVisualColForHighlight + 1); - } else if ( - relativeVisualColForHighlight === cpLen(display) && - cpLen(display) === inputWidth - ) { - display = display + chalk.inverse(' '); + (() => { + // Count images across all lines to maintain sequential numbering + const fullText = linesToRender.join('\n'); + const formattedFullText = formatUserMessageForDisplay( + fullText, + 1, + ); + const formattedLines = formattedFullText.split('\n'); + + return linesToRender.map((lineText, visualIdxInRenderedSet) => { + const cursorVisualRow = + cursorVisualRowAbsolute - scrollVisualRow; + const formattedLineText = + formattedLines[visualIdxInRenderedSet] || lineText; + let display = cpSlice(formattedLineText, 0, inputWidth); + const currentVisualWidth = stringWidth(display); + if (currentVisualWidth < inputWidth) { + display = + display + ' '.repeat(inputWidth - currentVisualWidth); + } + + if (visualIdxInRenderedSet === cursorVisualRow) { + // Map the cursor position from original text to formatted text + // Calculate position in full text for proper image numbering + const lineStartInFullText = + linesToRender.slice(0, visualIdxInRenderedSet).join('\\n') + .length + (visualIdxInRenderedSet > 0 ? 1 : 0); + const cursorInFullText = + lineStartInFullText + cursorVisualColAbsolute; + const mappedCursorInFullText = mapCursorPosition( + fullText, + cursorInFullText, + 1, + ); + const mappedCursorCol = + mappedCursorInFullText - + (formattedLines.slice(0, visualIdxInRenderedSet).join('\\n') + .length + + (visualIdxInRenderedSet > 0 ? 1 : 0)); + const relativeVisualColForHighlight = mappedCursorCol; + + if (relativeVisualColForHighlight >= 0) { + if (relativeVisualColForHighlight < cpLen(display)) { + const charToHighlight = + cpSlice( + display, + relativeVisualColForHighlight, + relativeVisualColForHighlight + 1, + ) || ' '; + const highlighted = chalk.inverse(charToHighlight); + display = + cpSlice(display, 0, relativeVisualColForHighlight) + + highlighted + + cpSlice(display, relativeVisualColForHighlight + 1); + } else if ( + relativeVisualColForHighlight === cpLen(display) && + cpLen(display) === inputWidth + ) { + display = display + chalk.inverse(' '); + } } } - } - return ( - {display} - ); - }) + return ( + {display} + ); + }); + })() )} diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx index b73fb200a6c..1b4ff257cbd 100644 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ b/packages/cli/src/ui/components/messages/UserMessage.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { Text, Box } from 'ink'; import { Colors } from '../../colors.js'; +import { formatUserMessageForDisplay } from '../../utils/messageFormatting.js'; interface UserMessageProps { text: string; @@ -15,6 +16,7 @@ interface UserMessageProps { export const UserMessage: React.FC = ({ text }) => { const prefix = '> '; const prefixWidth = prefix.length; + const displayText = formatUserMessageForDisplay(text); return ( @@ -23,7 +25,7 @@ export const UserMessage: React.FC = ({ text }) => { - {text} + {displayText} diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts new file mode 100644 index 00000000000..455f995cba9 --- /dev/null +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const execAsync = promisify(exec); + +/** + * Checks if the system clipboard contains an image (macOS only for now) + * @returns true if clipboard contains an image + */ +export async function clipboardHasImage(): Promise { + if (process.platform !== 'darwin') { + return false; + } + + try { + // Use osascript to check clipboard type + const { stdout } = await execAsync( + `osascript -e 'clipboard info' 2>/dev/null | grep -q "«class PNGf»\\|TIFF\\|JPEG" && echo "true" || echo "false"`, + { shell: '/bin/bash' }, + ); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +/** + * Saves the image from clipboard to a temporary file (macOS only for now) + * @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; + } + + try { + // 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(); + const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); + + // Use a simpler, more reliable AppleScript approach + const script = ` + try + set imageData to the clipboard as «class PNGf» + 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 execAsync(`osascript -e '${script}'`); + + if (stdout.trim() === 'success') { + // Verify the file was created and has content + const stats = await fs.stat(tempFilePath); + if (stats.size > 0) { + return tempFilePath; + } + } + + // Clean up on failure + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + + return null; + } catch (error) { + console.error('Error saving clipboard image:', error); + return null; + } +} + +/** + * Cleans up old temporary clipboard image files + * Removes files older than 1 hour + * @param targetDir The target directory where temp files are stored + */ +export async function cleanupOldClipboardImages( + targetDir?: string, +): Promise { + try { + const baseDir = targetDir || process.cwd(); + const tempDir = path.join(baseDir, '.gemini-clipboard'); + const files = await fs.readdir(tempDir); + const oneHourAgo = Date.now() - 60 * 60 * 1000; + + for (const file of files) { + if (file.startsWith('clipboard-') && file.endsWith('.png')) { + const filePath = path.join(tempDir, file); + const stats = await fs.stat(filePath); + if (stats.mtimeMs < oneHourAgo) { + await fs.unlink(filePath); + } + } + } + } catch { + // Ignore errors in cleanup + } +} + diff --git a/packages/cli/src/ui/utils/messageFormatting.ts b/packages/cli/src/ui/utils/messageFormatting.ts new file mode 100644 index 00000000000..18c4937df5c --- /dev/null +++ b/packages/cli/src/ui/utils/messageFormatting.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'path'; + +/** + * Checks if a file path refers to an image based on extension + */ +function isImagePath(filePath: string): boolean { + const imageExtensions = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.bmp', + '.svg', + ]; + const ext = path.extname(filePath).toLowerCase(); + return imageExtensions.includes(ext); +} + +/** + * Formats a user message by replacing image @ commands with clean [Image #N] placeholders + * while keeping the original @ commands for processing. + * Images are numbered sequentially starting from 1. + * @param text The text to format + * @param startingImageNumber The number to start counting from (for continuing sequences) + */ +export function formatUserMessageForDisplay( + text: string, + startingImageNumber: number = 1, +): string { + let imageCounter = startingImageNumber; + + // First pass: find all image @ commands and create a mapping + const imageReplacements = new Map(); + const regex = /@([^\s]+(?:\\ [^\s]*)*)/g; + let match; + + while ((match = regex.exec(text)) !== null) { + const fullMatch = match[0]; + const filePath = match[1]; + const cleanPath = filePath.replace(/\\ /g, ' '); + + if (isImagePath(cleanPath) && !imageReplacements.has(fullMatch)) { + imageReplacements.set(fullMatch, `[Image #${imageCounter++}]`); + } + } + + // Second pass: replace all @ commands + return text.replace(/@([^\s]+(?:\\ [^\s]*)*)/g, (match, filePath) => { + const cleanPath = filePath.replace(/\\ /g, ' '); + + if (isImagePath(cleanPath)) { + return imageReplacements.get(match) || match; + } + + // Keep non-image @ commands as-is + return match; + }); +} + +/** + * Maps a cursor position in the original text to the corresponding position in the formatted text + * @param originalText The original text + * @param cursorPos The cursor position in the original text + * @param startingImageNumber The number to start counting from (for continuing sequences) + */ +export function mapCursorPosition( + originalText: string, + cursorPos: number, + startingImageNumber: number = 1, +): number { + // If cursor is before any @ commands, no mapping needed + if (cursorPos === 0) return 0; + + // First pass: create the same image replacement mapping as formatUserMessageForDisplay + let imageCounter = startingImageNumber; + const imageReplacements = new Map(); + const regex = /@([^\s]+(?:\\ [^\s]*)*)/g; + let match; + + // Reset regex + regex.lastIndex = 0; + while ((match = regex.exec(originalText)) !== null) { + const fullMatch = match[0]; + const filePath = match[1]; + const cleanPath = filePath.replace(/\\ /g, ' '); + + if (isImagePath(cleanPath) && !imageReplacements.has(fullMatch)) { + imageReplacements.set(fullMatch, `[Image #${imageCounter++}]`); + } + } + + // Second pass: calculate cursor position with replacements + let offset = 0; + regex.lastIndex = 0; // Reset regex + + while ((match = regex.exec(originalText)) !== null) { + const matchStart = match.index; + const matchEnd = matchStart + match[0].length; + + // If cursor is before this match, we're done + if (cursorPos <= matchStart) { + break; + } + + // Check if this is an image path + const cleanPath = match[1].replace(/\\ /g, ' '); + if (isImagePath(cleanPath)) { + const replacement = imageReplacements.get(match[0]) || match[0]; + const lengthDifference = match[0].length - replacement.length; + + // If cursor is within this match, place it at the end of the replacement + if (cursorPos >= matchStart && cursorPos <= matchEnd) { + return matchStart - offset + replacement.length; + } + + // If cursor is after this match, accumulate the offset + if (cursorPos > matchEnd) { + offset += lengthDifference; + } + } + } + + // Return cursor position adjusted by total offset + return Math.max(0, cursorPos - offset); +} + +/** + * Counts the number of images in previous user messages to determine the starting number for new images + */ +export function countImagesInHistory(userMessages: readonly string[]): number { + let totalImages = 0; + + for (const message of userMessages) { + const regex = /@([^\s]+(?:\\ [^\s]*)*)/g; + let match; + + while ((match = regex.exec(message)) !== null) { + const filePath = match[1]; + const cleanPath = filePath.replace(/\\ /g, ' '); + + if (isImagePath(cleanPath)) { + totalImages++; + } + } + } + + return totalImages + 1; // Return the next number to use +} + From f29ac16a3b2ad610465a96acd78d0a6e9ed6928d Mon Sep 17 00:00:00 2001 From: Jayson Dasher <58889274+jaysondasher@users.noreply.github.com> Date: Wed, 25 Jun 2025 19:11:06 -0400 Subject: [PATCH 2/7] refactor: simplify clipboard image paste implementation Based on PR feedback, simplifying the implementation by: - Removing [Image #N] display formatting - now shows raw file paths - Removing complex cursor mapping logic - Using TextBuffer's replaceRangeByOffset for proper cursor position insertion - Removing placeholder text modification - Removing messageFormatting.ts as it's no longer needed This addresses reviewer concerns about complex display logic causing bugs with arrow key navigation and autocomplete. The simpler approach shows raw @ command paths which works well with existing functionality. --- .../cli/src/ui/components/InputPrompt.tsx | 94 ++++------- .../ui/components/messages/UserMessage.tsx | 4 +- .../cli/src/ui/utils/messageFormatting.ts | 156 ------------------ 3 files changed, 37 insertions(+), 217 deletions(-) delete mode 100644 packages/cli/src/ui/utils/messageFormatting.ts diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e064d1159ef..e21fedf7b9c 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -24,10 +24,6 @@ import { saveClipboardImage, cleanupOldClipboardImages, } from '../utils/clipboardUtils.js'; -import { - formatUserMessageForDisplay, - mapCursorPosition, -} from '../utils/messageFormatting.js'; import * as path from 'path'; export interface InputPromptProps { @@ -52,7 +48,7 @@ export const InputPrompt: React.FC = ({ onClearScreen, config, slashCommands, - placeholder = ' Type your message or @path/to/file (Ctrl+V for images)', + placeholder = ' Type your message or @path/to/file', focus = true, inputWidth, suggestionsWidth, @@ -178,19 +174,32 @@ export const InputPrompt: React.FC = ({ // Get relative path from current directory const relativePath = path.relative(config.getTargetDir(), imagePath); - // Insert clean @path reference - const currentText = buffer.text; + // Insert @path reference at cursor position const insertText = `@${relativePath}`; - - let newText: string; - if (!currentText || currentText.endsWith(' ')) { - newText = currentText + insertText + ' '; - } else { - newText = currentText + ' ' + insertText + ' '; + 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 } - - // setText automatically moves cursor to end, no need for moveToOffset - buffer.setText(newText); + 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) { @@ -391,7 +400,7 @@ export const InputPrompt: React.FC = ({ return; } - // Ctrl+V for image paste (like Claude Code) + // Ctrl+V for image paste if (key.ctrl && input === 'v') { handleClipboardImage(); return; @@ -433,46 +442,16 @@ export const InputPrompt: React.FC = ({ {placeholder} ) ) : ( - (() => { - // Count images across all lines to maintain sequential numbering - const fullText = linesToRender.join('\n'); - const formattedFullText = formatUserMessageForDisplay( - fullText, - 1, - ); - const formattedLines = formattedFullText.split('\n'); - - return linesToRender.map((lineText, visualIdxInRenderedSet) => { - const cursorVisualRow = - cursorVisualRowAbsolute - scrollVisualRow; - const formattedLineText = - formattedLines[visualIdxInRenderedSet] || lineText; - let display = cpSlice(formattedLineText, 0, inputWidth); - const currentVisualWidth = stringWidth(display); - if (currentVisualWidth < inputWidth) { - display = - display + ' '.repeat(inputWidth - currentVisualWidth); - } + linesToRender.map((lineText, visualIdxInRenderedSet) => { + const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; + let display = cpSlice(lineText, 0, inputWidth); + const currentVisualWidth = stringWidth(display); + if (currentVisualWidth < inputWidth) { + display = display + ' '.repeat(inputWidth - currentVisualWidth); + } - if (visualIdxInRenderedSet === cursorVisualRow) { - // Map the cursor position from original text to formatted text - // Calculate position in full text for proper image numbering - const lineStartInFullText = - linesToRender.slice(0, visualIdxInRenderedSet).join('\\n') - .length + (visualIdxInRenderedSet > 0 ? 1 : 0); - const cursorInFullText = - lineStartInFullText + cursorVisualColAbsolute; - const mappedCursorInFullText = mapCursorPosition( - fullText, - cursorInFullText, - 1, - ); - const mappedCursorCol = - mappedCursorInFullText - - (formattedLines.slice(0, visualIdxInRenderedSet).join('\\n') - .length + - (visualIdxInRenderedSet > 0 ? 1 : 0)); - const relativeVisualColForHighlight = mappedCursorCol; + if (visualIdxInRenderedSet === cursorVisualRow) { + const relativeVisualColForHighlight = cursorVisualColAbsolute; if (relativeVisualColForHighlight >= 0) { if (relativeVisualColForHighlight < cpLen(display)) { @@ -498,8 +477,7 @@ export const InputPrompt: React.FC = ({ return ( {display} ); - }); - })() + }) )} diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx index 1b4ff257cbd..b73fb200a6c 100644 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ b/packages/cli/src/ui/components/messages/UserMessage.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { Text, Box } from 'ink'; import { Colors } from '../../colors.js'; -import { formatUserMessageForDisplay } from '../../utils/messageFormatting.js'; interface UserMessageProps { text: string; @@ -16,7 +15,6 @@ interface UserMessageProps { export const UserMessage: React.FC = ({ text }) => { const prefix = '> '; const prefixWidth = prefix.length; - const displayText = formatUserMessageForDisplay(text); return ( @@ -25,7 +23,7 @@ export const UserMessage: React.FC = ({ text }) => { - {displayText} + {text} diff --git a/packages/cli/src/ui/utils/messageFormatting.ts b/packages/cli/src/ui/utils/messageFormatting.ts deleted file mode 100644 index 18c4937df5c..00000000000 --- a/packages/cli/src/ui/utils/messageFormatting.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as path from 'path'; - -/** - * Checks if a file path refers to an image based on extension - */ -function isImagePath(filePath: string): boolean { - const imageExtensions = [ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.svg', - ]; - const ext = path.extname(filePath).toLowerCase(); - return imageExtensions.includes(ext); -} - -/** - * Formats a user message by replacing image @ commands with clean [Image #N] placeholders - * while keeping the original @ commands for processing. - * Images are numbered sequentially starting from 1. - * @param text The text to format - * @param startingImageNumber The number to start counting from (for continuing sequences) - */ -export function formatUserMessageForDisplay( - text: string, - startingImageNumber: number = 1, -): string { - let imageCounter = startingImageNumber; - - // First pass: find all image @ commands and create a mapping - const imageReplacements = new Map(); - const regex = /@([^\s]+(?:\\ [^\s]*)*)/g; - let match; - - while ((match = regex.exec(text)) !== null) { - const fullMatch = match[0]; - const filePath = match[1]; - const cleanPath = filePath.replace(/\\ /g, ' '); - - if (isImagePath(cleanPath) && !imageReplacements.has(fullMatch)) { - imageReplacements.set(fullMatch, `[Image #${imageCounter++}]`); - } - } - - // Second pass: replace all @ commands - return text.replace(/@([^\s]+(?:\\ [^\s]*)*)/g, (match, filePath) => { - const cleanPath = filePath.replace(/\\ /g, ' '); - - if (isImagePath(cleanPath)) { - return imageReplacements.get(match) || match; - } - - // Keep non-image @ commands as-is - return match; - }); -} - -/** - * Maps a cursor position in the original text to the corresponding position in the formatted text - * @param originalText The original text - * @param cursorPos The cursor position in the original text - * @param startingImageNumber The number to start counting from (for continuing sequences) - */ -export function mapCursorPosition( - originalText: string, - cursorPos: number, - startingImageNumber: number = 1, -): number { - // If cursor is before any @ commands, no mapping needed - if (cursorPos === 0) return 0; - - // First pass: create the same image replacement mapping as formatUserMessageForDisplay - let imageCounter = startingImageNumber; - const imageReplacements = new Map(); - const regex = /@([^\s]+(?:\\ [^\s]*)*)/g; - let match; - - // Reset regex - regex.lastIndex = 0; - while ((match = regex.exec(originalText)) !== null) { - const fullMatch = match[0]; - const filePath = match[1]; - const cleanPath = filePath.replace(/\\ /g, ' '); - - if (isImagePath(cleanPath) && !imageReplacements.has(fullMatch)) { - imageReplacements.set(fullMatch, `[Image #${imageCounter++}]`); - } - } - - // Second pass: calculate cursor position with replacements - let offset = 0; - regex.lastIndex = 0; // Reset regex - - while ((match = regex.exec(originalText)) !== null) { - const matchStart = match.index; - const matchEnd = matchStart + match[0].length; - - // If cursor is before this match, we're done - if (cursorPos <= matchStart) { - break; - } - - // Check if this is an image path - const cleanPath = match[1].replace(/\\ /g, ' '); - if (isImagePath(cleanPath)) { - const replacement = imageReplacements.get(match[0]) || match[0]; - const lengthDifference = match[0].length - replacement.length; - - // If cursor is within this match, place it at the end of the replacement - if (cursorPos >= matchStart && cursorPos <= matchEnd) { - return matchStart - offset + replacement.length; - } - - // If cursor is after this match, accumulate the offset - if (cursorPos > matchEnd) { - offset += lengthDifference; - } - } - } - - // Return cursor position adjusted by total offset - return Math.max(0, cursorPos - offset); -} - -/** - * Counts the number of images in previous user messages to determine the starting number for new images - */ -export function countImagesInHistory(userMessages: readonly string[]): number { - let totalImages = 0; - - for (const message of userMessages) { - const regex = /@([^\s]+(?:\\ [^\s]*)*)/g; - let match; - - while ((match = regex.exec(message)) !== null) { - const filePath = match[1]; - const cleanPath = filePath.replace(/\\ /g, ' '); - - if (isImagePath(cleanPath)) { - totalImages++; - } - } - } - - return totalImages + 1; // Return the next number to use -} - From cb65919d74f441ea5c2224f562332e5c2cd95364 Mon Sep 17 00:00:00 2001 From: Jayson Dasher <58889274+jaysondasher@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:29:41 -0400 Subject: [PATCH 3/7] feat: improve clipboard image paste with multi-format support and tests Based on PR feedback from jacob314 and security review: - Add support for multiple image formats (PNG, JPEG, TIFF, GIF) - Fix JPEG detection by matching "JPEG picture" in clipboard info - Add basic test coverage for clipboard utilities - Run code formatter to fix whitespace issues - Improve error handling for different image formats The implementation now tries each format in order until one succeeds, making the feature more robust for different clipboard content types. --- .../cli/src/ui/components/InputPrompt.tsx | 57 ++++++------- .../cli/src/ui/utils/clipboardUtils.test.ts | 76 +++++++++++++++++ packages/cli/src/ui/utils/clipboardUtils.ts | 84 ++++++++++++------- 3 files changed, 159 insertions(+), 58 deletions(-) create mode 100644 packages/cli/src/ui/utils/clipboardUtils.test.ts diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e21fedf7b9c..32b28bb7544 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -178,26 +178,27 @@ export const InputPrompt: React.FC = ({ 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] : ''; - + 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); } @@ -453,31 +454,31 @@ export const InputPrompt: React.FC = ({ if (visualIdxInRenderedSet === cursorVisualRow) { const relativeVisualColForHighlight = cursorVisualColAbsolute; - if (relativeVisualColForHighlight >= 0) { - if (relativeVisualColForHighlight < cpLen(display)) { - const charToHighlight = - cpSlice( - display, - relativeVisualColForHighlight, - relativeVisualColForHighlight + 1, - ) || ' '; - const highlighted = chalk.inverse(charToHighlight); - display = - cpSlice(display, 0, relativeVisualColForHighlight) + - highlighted + - cpSlice(display, relativeVisualColForHighlight + 1); - } else if ( - relativeVisualColForHighlight === cpLen(display) && - cpLen(display) === inputWidth - ) { - display = display + chalk.inverse(' '); - } + if (relativeVisualColForHighlight >= 0) { + if (relativeVisualColForHighlight < cpLen(display)) { + const charToHighlight = + cpSlice( + display, + relativeVisualColForHighlight, + relativeVisualColForHighlight + 1, + ) || ' '; + const highlighted = chalk.inverse(charToHighlight); + display = + cpSlice(display, 0, relativeVisualColForHighlight) + + highlighted + + cpSlice(display, relativeVisualColForHighlight + 1); + } else if ( + relativeVisualColForHighlight === cpLen(display) && + cpLen(display) === inputWidth + ) { + display = display + chalk.inverse(' '); } } - return ( - {display} - ); - }) + } + return ( + {display} + ); + }) )} diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts new file mode 100644 index 00000000000..30258889edb --- /dev/null +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -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); + } 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(); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 455f995cba9..74554495c2d 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -23,7 +23,7 @@ export async function clipboardHasImage(): Promise { try { // Use osascript to check clipboard type const { stdout } = await execAsync( - `osascript -e 'clipboard info' 2>/dev/null | grep -q "«class PNGf»\\|TIFF\\|JPEG" && echo "true" || echo "false"`, + `osascript -e 'clipboard info' 2>/dev/null | grep -qE "«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»" && echo "true" || echo "false"`, { shell: '/bin/bash' }, ); return stdout.trim() === 'true'; @@ -53,41 +53,60 @@ export async function saveClipboardImage( // Generate a unique filename with timestamp const timestamp = new Date().getTime(); - const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); - - // Use a simpler, more reliable AppleScript approach - const script = ` - try - set imageData to the clipboard as «class PNGf» - 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 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}`, + ); + + // Try to save clipboard as this format + const script = ` try - close access POSIX file "${tempFilePath}" + 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 - return "error" - end try - `; + `; - const { stdout } = await execAsync(`osascript -e '${script}'`); + const { stdout } = await execAsync(`osascript -e '${script}'`); - if (stdout.trim() === 'success') { - // Verify the file was created and has content - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; + 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 + } } - } - // Clean up on failure - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors + // Clean up failed attempt + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } } + // No format worked return null; } catch (error) { console.error('Error saving clipboard image:', error); @@ -110,7 +129,13 @@ export async function cleanupOldClipboardImages( const oneHourAgo = Date.now() - 60 * 60 * 1000; for (const file of files) { - if (file.startsWith('clipboard-') && file.endsWith('.png')) { + if ( + file.startsWith('clipboard-') && + (file.endsWith('.png') || + file.endsWith('.jpg') || + file.endsWith('.tiff') || + file.endsWith('.gif')) + ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); if (stats.mtimeMs < oneHourAgo) { @@ -122,4 +147,3 @@ export async function cleanupOldClipboardImages( // Ignore errors in cleanup } } - From 78408391d8b97daa313c1a29a15a0b22a2fbaff3 Mon Sep 17 00:00:00 2001 From: Jayson Dasher <58889274+jaysondasher@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:12:08 -0400 Subject: [PATCH 4/7] test: add InputPrompt clipboard paste tests per review feedback --- .../src/ui/components/InputPrompt.test.tsx | 118 +++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 7d0cfcbb153..779fd09a883 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -12,10 +12,12 @@ 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'; vi.mock('../hooks/useShellHistory.js'); vi.mock('../hooks/useCompletion.js'); vi.mock('../hooks/useInputHistory.js'); +vi.mock('../utils/clipboardUtils.js'); type MockedUseShellHistory = ReturnType; type MockedUseCompletion = ReturnType; @@ -46,6 +48,7 @@ describe('InputPrompt', () => { mockBuffer.viewportVisualLines = [newText]; mockBuffer.allVisualLines = [newText]; }), + replaceRangeByOffset: vi.fn(), viewportVisualLines: [''], allVisualLines: [''], visualCursor: [0, 0], @@ -57,7 +60,6 @@ describe('InputPrompt', () => { killLineLeft: vi.fn(), openInExternalEditor: vi.fn(), newline: vi.fn(), - replaceRangeByOffset: vi.fn(), } as unknown as TextBuffer; mockShellHistory = { @@ -184,4 +186,118 @@ describe('InputPrompt', () => { expect(props.onSubmit).toHaveBeenCalledWith('some text'); 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(); + await wait(); + + // Send Ctrl+V + stdin.write('\x16'); // Ctrl+V + await wait(100); // Give async operations time to complete + + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith( + props.config.getTargetDir() + ); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith( + props.config.getTargetDir() + ); + // The implementation now uses replaceRangeByOffset instead of setText + 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(); + await wait(); + + stdin.write('\x16'); // Ctrl+V + await wait(100); + + 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(); + await wait(); + + stdin.write('\x16'); // Ctrl+V + await wait(100); + + 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(); + await wait(); + + stdin.write('\x16'); // Ctrl+V + await wait(100); + + // 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(); + await wait(); + + stdin.write('\x16'); // Ctrl+V + await wait(100); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error handling clipboard image:', + expect.any(Error) + ); + expect(mockBuffer.setText).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + unmount(); + }); + }); }); From eb059c50d4b6dc22f30a9f18a1dfecb8cd695175 Mon Sep 17 00:00:00 2001 From: Jayson Dasher <58889274+jaysondasher@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:15:57 -0400 Subject: [PATCH 5/7] test: remove timeouts and unnecessary comments from clipboard tests Per jacob314's review feedback: - Replace all wait(100) with wait() to speed up tests - Remove outdated comment about setText implementation - Remove timing comment on async wait --- packages/cli/src/ui/components/InputPrompt.test.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 779fd09a883..f2ff7ca49d0 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -205,7 +205,7 @@ describe('InputPrompt', () => { // Send Ctrl+V stdin.write('\x16'); // Ctrl+V - await wait(100); // Give async operations time to complete + await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith( @@ -214,7 +214,6 @@ describe('InputPrompt', () => { expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith( props.config.getTargetDir() ); - // The implementation now uses replaceRangeByOffset instead of setText expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); unmount(); }); @@ -226,7 +225,7 @@ describe('InputPrompt', () => { await wait(); stdin.write('\x16'); // Ctrl+V - await wait(100); + await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled(); @@ -242,7 +241,7 @@ describe('InputPrompt', () => { await wait(); stdin.write('\x16'); // Ctrl+V - await wait(100); + await wait(); expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); expect(mockBuffer.setText).not.toHaveBeenCalled(); @@ -265,7 +264,7 @@ describe('InputPrompt', () => { await wait(); stdin.write('\x16'); // Ctrl+V - await wait(100); + await wait(); // Should insert at cursor position with spaces expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); @@ -288,7 +287,7 @@ describe('InputPrompt', () => { await wait(); stdin.write('\x16'); // Ctrl+V - await wait(100); + await wait(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error handling clipboard image:', From db57457382272791e6cf614d53c957fc526e4226 Mon Sep 17 00:00:00 2001 From: Jayson Dasher <58889274+jaysondasher@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:08:34 -0400 Subject: [PATCH 6/7] rebased / reformatted. git auto-resolved conflicts all clipboard functionality preserved, handleClipboardImage and Ctrl+V handler are implemented properly still, and all tests passing. --- .../src/ui/components/InputPrompt.test.tsx | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index f2ff7ca49d0..aa86f377fde 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -191,13 +191,15 @@ describe('InputPrompt', () => { beforeEach(() => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null); - vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(undefined); + 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' + '/test/.gemini-clipboard/clipboard-123.png', ); const { stdin, unmount } = render(); @@ -209,10 +211,10 @@ describe('InputPrompt', () => { expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith( - props.config.getTargetDir() + props.config.getTargetDir(), ); expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith( - props.config.getTargetDir() + props.config.getTargetDir(), ); expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); unmount(); @@ -251,7 +253,7 @@ describe('InputPrompt', () => { 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' + '/test/.gemini-clipboard/clipboard-456.png', ); // Set initial text and cursor position @@ -268,19 +270,24 @@ describe('InputPrompt', () => { // 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]; + 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 + 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(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue( - new Error('Clipboard error') + new Error('Clipboard error'), ); const { stdin, unmount } = render(); @@ -291,10 +298,10 @@ describe('InputPrompt', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error handling clipboard image:', - expect.any(Error) + expect.any(Error), ); expect(mockBuffer.setText).not.toHaveBeenCalled(); - + consoleErrorSpy.mockRestore(); unmount(); }); From 2611b07936d3ccdf16fedaa0d64d9e59f1132c3f Mon Sep 17 00:00:00 2001 From: Jayson Dasher <58889274+jaysondasher@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:03:33 -0400 Subject: [PATCH 7/7] style: remove trailing newlines from InputPrompt files Prettier formatting removed trailing newlines from: - packages/cli/src/ui/components/InputPrompt.tsx - packages/cli/src/ui/components/InputPrompt.test.tsx --- packages/cli/src/ui/components/InputPrompt.test.tsx | 2 +- packages/cli/src/ui/components/InputPrompt.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 595bf3306ed..ad7a3985375 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -527,4 +527,4 @@ describe('InputPrompt', () => { expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); -}); \ No newline at end of file +}); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 9e97c77110a..371fb48d33f 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -475,4 +475,4 @@ export const InputPrompt: React.FC = ({ )} ); -}; \ No newline at end of file +};