Skip to content
Closed
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
29 changes: 27 additions & 2 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useClipboardImages } from './hooks/useClipboardImages.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
Expand All @@ -127,6 +128,7 @@ import { enableBracketedPaste } from './utils/bracketedPaste.js';
import { useBanner } from './hooks/useBanner.js';

const WARNING_PROMPT_DURATION_MS = 1000;
const IMAGE_WARNING_DURATION_MS = 3000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;

function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
Expand Down Expand Up @@ -343,6 +345,9 @@ export const AppContainer = (props: AppContainerProps) => {
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();

// Clipboard images for pasted images in the input
const clipboardImages = useClipboardImages();

const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
// Derive widths for InputPrompt using shared helper
const { inputWidth, suggestionsWidth } = useMemo(() => {
Expand Down Expand Up @@ -776,6 +781,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalWidth,
terminalHeight,
embeddedShellFocused,
clipboardImages,
);

// Auto-accept indicator
Expand Down Expand Up @@ -1020,14 +1026,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
useEffect(() => {
let timeoutId: NodeJS.Timeout;

const handleWarning = (message: string) => {
const handleWarning = (
message: string,
durationMs = WARNING_PROMPT_DURATION_MS,
) => {
setWarningMessage(message);
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
setWarningMessage(null);
}, WARNING_PROMPT_DURATION_MS);
}, durationMs);
};

const handleSelectionWarning = () => {
Expand All @@ -1036,11 +1045,25 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
};
const handleImageWarning = (message: string) => {
handleWarning(message, IMAGE_WARNING_DURATION_MS);
};
const handleImageProcessing = (message: string) => {
if (message) {
setWarningMessage(message);
} else {
setWarningMessage(null);
}
};
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
appEvents.on(AppEvent.ImageWarning, handleImageWarning);
appEvents.on(AppEvent.ImageProcessing, handleImageProcessing);
return () => {
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
appEvents.off(AppEvent.ImageWarning, handleImageWarning);
appEvents.off(AppEvent.ImageProcessing, handleImageProcessing);
if (timeoutId) {
clearTimeout(timeoutId);
}
Expand Down Expand Up @@ -1527,6 +1550,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
warningMessage,
bannerData,
bannerVisible,
clipboardImages,
terminalBackgroundColor: config.getTerminalBackground(),
}),
[
Expand Down Expand Up @@ -1619,6 +1643,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
warningMessage,
bannerData,
bannerVisible,
clipboardImages,
config,
],
);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export const Composer = () => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
clipboardImages={uiState.clipboardImages}
/>
)}

Expand Down
143 changes: 134 additions & 9 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode } from '@google/gemini-cli-core';
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
Expand All @@ -34,7 +34,11 @@ import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
mayContainImagePaths,
categorizePathsByType,
} from '../utils/clipboardUtils.js';
import type { UseClipboardImagesReturn } from '../hooks/useClipboardImages.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import {
isAutoExecutableCommand,
isSlashCommand,
Expand Down Expand Up @@ -86,6 +90,7 @@ export interface InputPromptProps {
popAllMessages?: () => string | undefined;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
clipboardImages?: UseClipboardImagesReturn;
}

// The input content, input container, and input suggestions list may have different widths
Expand Down Expand Up @@ -128,6 +133,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
popAllMessages,
suggestionsPosition = 'below',
setBannerVisible,
clipboardImages,
}) => {
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
Expand Down Expand Up @@ -316,22 +322,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleClipboardPaste = useCallback(async () => {
try {
if (await clipboardHasImage()) {
// Show processing indicator immediately
appEvents.emit(AppEvent.ImageProcessing, 'Processing image...');

const imagePath = await saveClipboardImage(config.getTargetDir());

// Clear processing indicator
appEvents.emit(AppEvent.ImageProcessing, '');

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);
// Register image and get display text (e.g., "[Image #1]")
// If clipboardImages is not provided, fall back to the old @path behavior
let insertText: string;
if (clipboardImages) {
// Validate image before registration
const validation = await clipboardImages.validateImage(imagePath);
if (!validation.valid) {
appEvents.emit(
AppEvent.ImageWarning,
validation.error ?? 'Invalid image',
);
return;
}
insertText = clipboardImages.registerImage(imagePath);
} else {
const relativePath = path.relative(
config.getTargetDir(),
imagePath,
);
insertText = `@${relativePath}`;
}

// Insert @path reference at cursor position
const insertText = `@${relativePath}`;
const currentText = buffer.text;
const offset = buffer.getOffset();

// Add spaces around the path if needed
// Add spaces around the display text if needed
let textToInsert = insertText;
const charBefore = offset > 0 ? currentText[offset - 1] : '';
const charAfter =
Expand All @@ -354,9 +384,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const offset = buffer.getOffset();
buffer.replaceRangeByOffset(offset, offset, textToInsert);
} catch (error) {
console.error('Error handling clipboard image:', error);
debugLogger.error('Error handling clipboard image:', error);
}
}, [buffer, config]);
}, [buffer, config, clipboardImages]);

useMouseClick(
innerBoxRef,
Expand Down Expand Up @@ -414,6 +444,98 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null;
}, 40);
}

// Check if pasted content could be image file path(s) (drag and drop)
// Use synchronous check first to avoid async handling for normal text
if (
clipboardImages &&
key.sequence &&
mayContainImagePaths(key.sequence)
) {
// Capture state at paste time to handle the async operation correctly
const sequence = key.sequence;
const pasteOffset = buffer.getOffset();
const currentText = buffer.text;

// Only go async for potential image paths to verify file existence
void (async () => {
try {
const { imagePaths, nonImagePaths } =
await categorizePathsByType(sequence);

if (imagePaths.length > 0 || nonImagePaths.length > 0) {
// Validate all images in parallel
const validationResults = await Promise.all(
imagePaths.map(async (imagePath) => ({
imagePath,
validation: await clipboardImages.validateImage(imagePath),
})),
);

// Register valid images and collect errors
const placeholders: string[] = [];
const skippedImages: string[] = [];

for (const { imagePath, validation } of validationResults) {
if (validation.valid) {
placeholders.push(clipboardImages.registerImage(imagePath));
} else {
skippedImages.push(validation.error ?? 'Invalid image');
}
}

// Show warnings for skipped images
for (const error of skippedImages) {
appEvents.emit(AppEvent.ImageWarning, error);
}

// Non-image files use @path syntax for file references
const atPrefixedPaths = nonImagePaths.map((p) => `@${p}`);

// Build insertion text: image placeholders + @path references
const insertParts = [...placeholders, ...atPrefixedPaths];

// If all images were invalid but we have non-image paths, still insert those
if (insertParts.length === 0) {
// All paths were invalid images with no non-image files
return;
}

let insertText = insertParts.join(' ');

// Add spacing around the insert text based on context at paste time
const charBefore =
pasteOffset > 0 ? currentText[pasteOffset - 1] : '';
const charAfter =
pasteOffset < currentText.length
? currentText[pasteOffset]
: '';

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

// Insert at the original paste position
buffer.replaceRangeByOffset(
pasteOffset,
pasteOffset,
insertText,
);
} else {
// No valid paths found, insert as normal text
buffer.replaceRangeByOffset(pasteOffset, pasteOffset, sequence);
}
} catch {
// On error, insert as normal text
buffer.replaceRangeByOffset(pasteOffset, pasteOffset, sequence);
}
})();
return;
}

// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
return;
Expand Down Expand Up @@ -863,6 +985,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
kittyProtocol.enabled,
tryLoadQueuedMessages,
setBannerVisible,
clipboardImages,
],
);

Expand Down Expand Up @@ -1163,7 +1286,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}

const color =
seg.type === 'command' || seg.type === 'file'
seg.type === 'command' ||
seg.type === 'file' ||
seg.type === 'image'
? theme.text.accent
: theme.text.primary;

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/contexts/UIStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { ExtensionUpdateState } from '../state/extensions.js';
import type { UpdateObject } from '../utils/updateCheck.js';
import type { UseClipboardImagesReturn } from '../hooks/useClipboardImages.js';

export interface ProQuotaDialogRequest {
failedModel: string;
Expand Down Expand Up @@ -137,6 +138,7 @@ export interface UIState {
};
bannerVisible: boolean;
customDialog: React.ReactNode | null;
clipboardImages: UseClipboardImagesReturn;
terminalBackgroundColor: TerminalBackgroundColor;
}

Expand Down
Loading