Skip to content

Commit db79339

Browse files
committed
feat(cli): add support for pasting/dragging image file paths
- Add onPasteImagePath handler for image file paths - Check clipboard text for file paths before checking for image data - Ensure consistent priority: file paths > clipboard images > text - Add Windows line ending support in path detection - Use consistent cwd resolution (getProjectRoot)
1 parent 0a1e27e commit db79339

File tree

4 files changed

+130
-18
lines changed

4 files changed

+130
-18
lines changed

cli/src/chat.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import { useUsageMonitor } from './hooks/use-usage-monitor'
4545
import { getProjectRoot } from './project-files'
4646
import { useChatStore } from './state/chat-store'
4747
import { useFeedbackStore } from './state/feedback-store'
48-
import { addClipboardPlaceholder, addPendingImageFromFile } from './utils/add-pending-image'
48+
import { addClipboardPlaceholder, addPendingImageFromFile, validateAndAddImage } from './utils/add-pending-image'
4949
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
5050
import { showClipboardMessage } from './utils/clipboard'
5151
import { readClipboardImage } from './utils/clipboard-image'
@@ -979,6 +979,10 @@ export const Chat = ({
979979
void addPendingImageFromFile(result.imagePath, cwd, placeholderPath)
980980
}, 0)
981981
},
982+
onPasteImagePath: (imagePath: string) => {
983+
const cwd = getProjectRoot() ?? process.cwd()
984+
void validateAndAddImage(imagePath, cwd)
985+
},
982986
onPasteText: (text: string) => {
983987
setInputValue((prev) => {
984988
const before = prev.text.slice(0, prev.cursorPosition)
@@ -1240,6 +1244,8 @@ export const Chat = ({
12401244
cursorPosition,
12411245
onChange: setInputValue,
12421246
onPasteImage: chatKeyboardHandlers.onPasteImage,
1247+
onPasteImagePath: chatKeyboardHandlers.onPasteImagePath,
1248+
cwd: getProjectRoot() ?? process.cwd(),
12431249
})}
12441250
/>
12451251
</box>

cli/src/hooks/use-chat-keyboard.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useKeyboard } from '@opentui/react'
22
import { useCallback } from 'react'
33

4-
import { hasClipboardImage, readClipboardText } from '../utils/clipboard-image'
4+
import { hasClipboardImage, readClipboardText, getImageFilePathFromText } from '../utils/clipboard-image'
5+
import { getProjectRoot } from '../project-files'
56
import {
67
resolveChatKeyboardAction,
78
type ChatKeyboardState,
@@ -66,6 +67,7 @@ export type ChatKeyboardHandlers = {
6667

6768
// Clipboard handlers
6869
onPasteImage: () => void
70+
onPasteImagePath: (imagePath: string) => void
6971
onPasteText: (text: string) => void
7072
}
7173

@@ -171,14 +173,28 @@ function dispatchAction(
171173
handlers.onBashHistoryDown()
172174
return true
173175
case 'paste': {
174-
// Check for image FIRST - many apps put both text and image on clipboard
175-
// when copying an image (e.g., Chrome, Slack, Finder), so we prioritize image
176+
// First, read clipboard text to check if it's a file path
177+
// This handles the case where a file is dragged/dropped - we want to use
178+
// the file path, not any stale image data that might be in the clipboard
179+
const cwd = getProjectRoot() ?? process.cwd()
180+
const text = readClipboardText()
181+
if (text) {
182+
// Check if the text is a path to an image file
183+
const imagePath = getImageFilePathFromText(text, cwd)
184+
if (imagePath) {
185+
handlers.onPasteImagePath(imagePath)
186+
return true
187+
}
188+
}
189+
190+
// Check for actual image data in clipboard (screenshots, copied images)
191+
// This comes AFTER the file path check so dragged files take priority
176192
if (hasClipboardImage()) {
177193
handlers.onPasteImage()
178194
return true
179195
}
180-
// No image - try text
181-
const text = readClipboardText()
196+
197+
// Regular text paste
182198
if (text) {
183199
handlers.onPasteText(text)
184200
return true

cli/src/utils/clipboard-image.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, writeFileSync } from 'fs'
33
import os from 'os'
44
import path from 'path'
55

6+
import { isImageFile, resolveFilePath } from './image-handler'
7+
68
export interface ClipboardImageResult {
79
success: boolean
810
imagePath?: string
@@ -31,7 +33,12 @@ function generateImageFilename(): string {
3133

3234
/**
3335
* Check if clipboard contains an image (macOS)
34-
* Uses 'clipboard info' which is the fastest way to check clipboard types
36+
* Uses 'clipboard info' which is the fastest way to check clipboard types.
37+
*
38+
* Note: We do NOT filter out clipboards that contain file URLs here, because
39+
* copying images from Finder/Preview/Safari often includes both a file URL
40+
* AND the actual image data. The caller handles priority (file paths are
41+
* checked first via clipboard text, then we fall back to image data).
3542
*/
3643
function hasImageMacOS(): boolean {
3744
try {
@@ -45,6 +52,7 @@ function hasImageMacOS(): boolean {
4552
}
4653

4754
const output = result.stdout || ''
55+
4856
// Check for image types in clipboard info
4957
return output.includes('«class PNGf»') ||
5058
output.includes('TIFF') ||
@@ -302,6 +310,49 @@ export function readClipboardImage(): ClipboardImageResult {
302310
}
303311
}
304312

313+
/**
314+
* Check if text looks like a single file path pointing to an existing image.
315+
* Used to detect drag-drop of image files into the terminal.
316+
* Returns the resolved absolute path if valid, null otherwise.
317+
*/
318+
export function getImageFilePathFromText(text: string, cwd: string): string | null {
319+
// Must be single line (no internal newlines, including Windows \r\n)
320+
if (text.includes('\n') || text.includes('\r')) return null
321+
322+
// Must not be empty or have only whitespace
323+
let trimmed = text.trim()
324+
if (!trimmed) return null
325+
326+
// Handle file:// URLs that some systems use for dragged files
327+
if (trimmed.startsWith('file://')) {
328+
trimmed = decodeURIComponent(trimmed.slice(7))
329+
}
330+
331+
// Skip if it looks like a URL (but not file:// which we already handled)
332+
if (trimmed.includes('://')) return null
333+
334+
// Remove surrounding quotes that some terminals add
335+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
336+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
337+
trimmed = trimmed.slice(1, -1)
338+
}
339+
340+
try {
341+
// Try to resolve the path
342+
const resolvedPath = resolveFilePath(trimmed, cwd)
343+
344+
// Check if file exists
345+
if (!existsSync(resolvedPath)) return null
346+
347+
// Check if it's a supported image format
348+
if (!isImageFile(resolvedPath)) return null
349+
350+
return resolvedPath
351+
} catch {
352+
return null
353+
}
354+
}
355+
305356
/**
306357
* Read text from clipboard. Returns null if reading fails.
307358
*/

cli/src/utils/strings.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hasClipboardImage, readClipboardText } from './clipboard-image'
1+
import { hasClipboardImage, readClipboardText, getImageFilePathFromText } from './clipboard-image'
22
import type { InputValue } from '../state/chat-store'
33

44
export function getSubsequenceIndices(
@@ -62,26 +62,65 @@ export function createTextPasteHandler(
6262

6363
/**
6464
* Creates a paste handler that supports both image and text paste.
65-
* Checks for image first, falls back to text paste.
65+
*
66+
* When fallbackText is provided (from drag-drop or native paste event),
67+
* it takes FULL priority over the clipboard. This is because:
68+
* - Drag operations provide file paths directly without updating the clipboard
69+
* - The clipboard might contain stale data from a previous copy operation
70+
*
71+
* Only when NO fallbackText is provided do we read from the clipboard.
6672
*/
6773
export function createPasteHandler(options: {
6874
text: string
6975
cursorPosition: number
7076
onChange: (value: InputValue) => void
7177
onPasteImage?: () => void
78+
onPasteImagePath?: (imagePath: string) => void
79+
cwd?: string
7280
}): (fallbackText?: string) => void {
73-
const { text, cursorPosition, onChange, onPasteImage } = options
81+
const { text, cursorPosition, onChange, onPasteImage, onPasteImagePath, cwd } = options
7482
return (fallbackText) => {
75-
// Check for image first if handler provided
83+
// If we have direct input text from the paste event (e.g., from drag-drop),
84+
// use it exclusively and ignore the clipboard entirely.
85+
// Drag operations don't update the clipboard, so clipboard data would be stale.
86+
if (fallbackText) {
87+
// Check if it's a path to an image file
88+
if (onPasteImagePath && cwd) {
89+
const imagePath = getImageFilePathFromText(fallbackText, cwd)
90+
if (imagePath) {
91+
onPasteImagePath(imagePath)
92+
return
93+
}
94+
}
95+
96+
// Not an image path, insert as regular text
97+
const { newText, newCursor } = insertTextAtCursor(text, cursorPosition, fallbackText)
98+
onChange({ text: newText, cursorPosition: newCursor, lastEditDueToNav: false })
99+
return
100+
}
101+
102+
// No direct text provided - read from clipboard
103+
const pasteText = readClipboardText()
104+
105+
// First check if clipboard text is a path to an image file
106+
// File paths take priority over clipboard image data
107+
if (pasteText && onPasteImagePath && cwd) {
108+
const imagePath = getImageFilePathFromText(pasteText, cwd)
109+
if (imagePath) {
110+
onPasteImagePath(imagePath)
111+
return
112+
}
113+
}
114+
115+
// Check for actual image data (screenshots, copied images)
76116
if (onPasteImage && hasClipboardImage()) {
77117
onPasteImage()
78118
return
79119
}
80-
// Handle text paste
81-
const pasteText = readClipboardText() ?? fallbackText
82-
if (pasteText) {
83-
const { newText, newCursor } = insertTextAtCursor(text, cursorPosition, pasteText)
84-
onChange({ text: newText, cursorPosition: newCursor, lastEditDueToNav: false })
85-
}
120+
121+
// Regular text paste
122+
if (!pasteText) return
123+
const { newText, newCursor } = insertTextAtCursor(text, cursorPosition, pasteText)
124+
onChange({ text: newText, cursorPosition: newCursor, lastEditDueToNav: false })
86125
}
87-
}
126+
}

0 commit comments

Comments
 (0)