Skip to content

Commit 5a06e33

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 5a06e33

6 files changed

Lines changed: 212 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: 28 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,28 @@ 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
2973+
.trim()
2974+
.split(' ')
2975+
.map((f) => f.substring(1));
2976+
if (files.length > 0) {
2977+
const displayNames = files.map((f) =>
2978+
// Extract the basename and strip enclosing quotes
2979+
pathMod.basename(f.replace(/^["']|["']$/g, '')),
2980+
);
2981+
const message =
2982+
displayNames.length === 1
2983+
? `Added ${displayNames[0]} to prompt context`
2984+
: `Added ${displayNames.length} files to prompt context`;
2985+
appEvents.emit(AppEvent.TransientMessage, {
2986+
message,
2987+
type: TransientMessageType.Hint,
2988+
});
2989+
}
29642990
}
29652991
}
29662992

@@ -2980,7 +3006,7 @@ export function useTextBuffer({
29803006
dispatch({ type: 'insert', payload: currentText, isPaste: paste });
29813007
}
29823008
},
2983-
[shellModeActive, escapePastedPaths],
3009+
[shellModeActive, escapePastedPaths, targetDir],
29843010
);
29853011

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

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,5 +597,27 @@ describe('clipboardUtils', () => {
597597
expect(result).toBe('@\\\\server\\share\\file.txt ');
598598
});
599599
});
600+
601+
describe('with targetDir', () => {
602+
it('should resolve path relative to targetDir if within it', () => {
603+
const targetDir = '/mock/workspace';
604+
const absolutePath = '/mock/workspace/src/image.png';
605+
vi.mocked(existsSync).mockImplementation((p) => p === absolutePath);
606+
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
607+
608+
const result = parsePastedPaths(absolutePath, targetDir);
609+
expect(result).toBe('@src/image.png ');
610+
});
611+
612+
it('should keep absolute path if outside of targetDir', () => {
613+
const targetDir = '/mock/workspace';
614+
const absolutePath = '/mock/image.png';
615+
vi.mocked(existsSync).mockImplementation((p) => p === absolutePath);
616+
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
617+
618+
const result = parsePastedPaths(absolutePath, targetDir);
619+
expect(result).toBe('@/mock/image.png ');
620+
});
621+
});
600622
});
601623
});

0 commit comments

Comments
 (0)