Skip to content

Commit c0b4dd3

Browse files
committed
feat(cli): add native drag-and-drop and Cmd+V clipboard image pasting
- Implement background clipboard image synchronizer to extract raw images from system clipboard and reframe their relative text paths back to the clipboard on macOS, Windows, and Linux. - Update parsePastedPaths to support targetDir and convert absolute file paths to clean relative paths inside the workspace. - Update text-buffer to pass targetDir and emit a transient status toast confirming dropped/pasted files. - Add comprehensive unit tests for relative path resolution with targetDir. Closes #27855
1 parent 4e10a34 commit c0b4dd3

6 files changed

Lines changed: 222 additions & 38 deletions

File tree

package-lock.json

Lines changed: 3 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/src/ui/AppContainer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ export const AppContainer = (props: AppContainerProps) => {
619619
stdin,
620620
setRawMode,
621621
escapePastedPaths: true,
622+
targetDir: config.getTargetDir(),
622623
shellModeActive,
623624
getPreferredEditor,
624625
});

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ import {
6767
clipboardHasImage,
6868
saveClipboardImage,
6969
cleanupOldClipboardImages,
70+
readClipboardText,
71+
writeClipboardText,
7072
} from '../utils/clipboardUtils.js';
7173
import {
7274
isAutoExecutableCommand,
@@ -263,6 +265,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
263265
windowMs: DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS,
264266
});
265267
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
268+
269+
// Background clipboard image watcher to automatically convert copied raw images
270+
// into temporary files and write their relative path back to the clipboard.
271+
// This allows the user to paste raw images directly into the terminal with Cmd+V!
272+
useEffect(() => {
273+
if (!focus || shellModeActive) {
274+
return;
275+
}
276+
277+
const intervalId = setInterval(async () => {
278+
try {
279+
if (await clipboardHasImage()) {
280+
const currentText = await readClipboardText();
281+
// Only process if the clipboard text does not already reference a clipboard image or file path
282+
if (
283+
!currentText.startsWith('@') ||
284+
!currentText.includes('clipboard-')
285+
) {
286+
const imagePath = await saveClipboardImage(config.getTargetDir());
287+
if (imagePath) {
288+
// Clean up old clipboard images
289+
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {});
290+
291+
// Get relative path from current workspace
292+
const relativePath = path.relative(
293+
config.getTargetDir(),
294+
imagePath,
295+
);
296+
const insertText = `@${relativePath} `;
297+
298+
// Write the text reference back to the clipboard!
299+
await writeClipboardText(insertText);
300+
}
301+
}
302+
}
303+
} catch {
304+
// Ignore clipboard watcher errors silently
305+
}
306+
}, 1000);
307+
308+
return () => clearInterval(intervalId);
309+
}, [focus, shellModeActive, config]);
266310
const { handlePress: handleEscPress, resetCount: resetEscapeState } =
267311
useRepeatedKeyPress({
268312
windowMs: 500,

packages/cli/src/ui/components/shared/text-buffer.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import {
2525
getCachedStringWidth,
2626
} from '../../utils/textUtils.js';
2727
import { parsePastedPaths } from '../../utils/clipboardUtils.js';
28+
import {
29+
appEvents,
30+
AppEvent,
31+
TransientMessageType,
32+
} from '../../../utils/events.js';
2833
import type { Key } from '../../contexts/KeypressContext.js';
2934
import { Command } from '../../key/keyMatchers.js';
3035
import type { VimAction } from './vim-buffer-actions.js';
@@ -769,6 +774,7 @@ interface UseTextBufferProps {
769774
setRawMode?: (mode: boolean) => void; // For external editor
770775
onChange?: (text: string) => void; // Callback for when text changes
771776
escapePastedPaths?: boolean;
777+
targetDir?: string;
772778
shellModeActive?: boolean; // Whether the text buffer is in shell mode
773779
inputFilter?: (text: string) => string; // Optional filter for input text
774780
singleLine?: boolean;
@@ -2837,6 +2843,7 @@ export function useTextBuffer({
28372843
setRawMode,
28382844
onChange,
28392845
escapePastedPaths = false,
2846+
targetDir,
28402847
shellModeActive = false,
28412848
inputFilter,
28422849
singleLine = false,
@@ -2958,9 +2965,24 @@ export function useTextBuffer({
29582965
paste &&
29592966
escapePastedPaths
29602967
) {
2961-
const processed = parsePastedPaths(ch.trim());
2968+
const processed = parsePastedPaths(ch.trim(), targetDir);
29622969
if (processed) {
29632970
textToInsert = processed;
2971+
// Emit transient toast message for dragging/pasting file
2972+
const files = (processed.match(/@("[^"]+"|[^ ]+)/g) || []).map((f) =>
2973+
f.slice(1).replace(/^["']|["']$/g, ''),
2974+
);
2975+
if (files.length > 0) {
2976+
const displayNames = files.map((f) => pathMod.basename(f));
2977+
const message =
2978+
displayNames.length === 1
2979+
? `Added ${displayNames[0]} to prompt context`
2980+
: `Added ${displayNames.length} files to prompt context`;
2981+
appEvents.emit(AppEvent.TransientMessage, {
2982+
message,
2983+
type: TransientMessageType.Hint,
2984+
});
2985+
}
29642986
}
29652987
}
29662988

@@ -2980,7 +3002,7 @@ export function useTextBuffer({
29803002
dispatch({ type: 'insert', payload: currentText, isPaste: paste });
29813003
}
29823004
},
2983-
[shellModeActive, escapePastedPaths],
3005+
[shellModeActive, escapePastedPaths, targetDir],
29843006
);
29853007

29863008
const newline = useCallback((): void => {

packages/cli/src/ui/utils/clipboardUtils.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
4747
return {
4848
...actual,
4949
spawnAsync: vi.fn(),
50+
resolveToRealPath: vi.fn((p) => p),
51+
isSubpath: vi.fn((parent, child) => child.startsWith(parent)),
5052
debugLogger: {
5153
debug: vi.fn(),
5254
warn: vi.fn(),
@@ -597,5 +599,27 @@ describe('clipboardUtils', () => {
597599
expect(result).toBe('@\\\\server\\share\\file.txt ');
598600
});
599601
});
602+
603+
describe('with targetDir', () => {
604+
it('should resolve path relative to targetDir if within it', () => {
605+
const targetDir = '/mock/workspace';
606+
const absolutePath = '/mock/workspace/src/image.png';
607+
vi.mocked(existsSync).mockImplementation((p) => p === absolutePath);
608+
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
609+
610+
const result = parsePastedPaths(absolutePath, targetDir);
611+
expect(result).toBe('@src/image.png ');
612+
});
613+
614+
it('should keep absolute path if outside of targetDir', () => {
615+
const targetDir = '/mock/workspace';
616+
const absolutePath = '/mock/image.png';
617+
vi.mocked(existsSync).mockImplementation((p) => p === absolutePath);
618+
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
619+
620+
const result = parsePastedPaths(absolutePath, targetDir);
621+
expect(result).toBe('@/mock/image.png ');
622+
});
623+
});
600624
});
601625
});

0 commit comments

Comments
 (0)