Skip to content

Commit 7df73fe

Browse files
committed
fix(cli): support pasting image files copied from file managers
- Add readClipboardFilePath() and readClipboardImageFilePath() to detect files copied via Cmd+C in Finder/Explorer/file managers (cross-platform) - Fix @ mention menu intermittently failing after long conversations by using inputValue instead of deferredInput for cursor position sync - Enhance paste handler to detect when terminal provides filename but clipboard has full path
1 parent f457fb6 commit 7df73fe

File tree

4 files changed

+277
-35
lines changed

4 files changed

+277
-35
lines changed

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

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

4-
import { hasClipboardImage, readClipboardText, getImageFilePathFromText } from '../utils/clipboard-image'
4+
import { hasClipboardImage, readClipboardText, readClipboardImageFilePath, getImageFilePathFromText } from '../utils/clipboard-image'
55
import { getProjectRoot } from '../project-files'
66
import {
77
resolveChatKeyboardAction,
@@ -173,10 +173,19 @@ function dispatchAction(
173173
handlers.onBashHistoryDown()
174174
return true
175175
case 'paste': {
176-
// First, read clipboard text to check if it's a file path
176+
const cwd = getProjectRoot() ?? process.cwd()
177+
178+
// First, check if clipboard contains a copied image file (e.g., from Finder)
179+
// This is different from text - it's when you Cmd+C a file in Finder
180+
const copiedImagePath = readClipboardImageFilePath()
181+
if (copiedImagePath) {
182+
handlers.onPasteImagePath(copiedImagePath)
183+
return true
184+
}
185+
186+
// Next, read clipboard text to check if it's a file path
177187
// This handles the case where a file is dragged/dropped - we want to use
178188
// the file path, not any stale image data that might be in the clipboard
179-
const cwd = getProjectRoot() ?? process.cwd()
180189
const text = readClipboardText()
181190
if (text) {
182191
// Check if the text is a path to an image file

cli/src/hooks/use-suggestion-engine.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,9 +591,13 @@ export const useSuggestionEngine = ({
591591
[deferredInput],
592592
)
593593

594+
// Note: mentionContext uses inputValue directly (not deferredInput) because
595+
// the cursor position must match the text being parsed. Using deferredInput
596+
// with current cursorPosition causes desync during heavy renders, making the
597+
// @ menu fail to appear intermittently (especially after long conversations).
594598
const mentionContext = useMemo(
595-
() => parseMentionContext(deferredInput, cursorPosition),
596-
[deferredInput, cursorPosition],
599+
() => parseMentionContext(inputValue, cursorPosition),
600+
[inputValue, cursorPosition],
597601
)
598602

599603
useEffect(() => {

cli/src/utils/clipboard-image.ts

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ function hasImageWindows(): boolean {
218218
Add-Type -AssemblyName System.Windows.Forms
219219
if ([System.Windows.Forms.Clipboard]::ContainsImage()) { Write-Output "true" } else { Write-Output "false" }
220220
`
221-
const result = spawnSync('powershell', ['-Command', script], {
221+
const result = spawnSync('powershell', ['-STA', '-Command', script], {
222222
encoding: 'utf-8',
223223
timeout: 5000,
224224
})
@@ -249,7 +249,7 @@ function readImageWindows(): ClipboardImageResult {
249249
}
250250
`
251251

252-
const result = spawnSync('powershell', ['-Command', script], {
252+
const result = spawnSync('powershell', ['-STA', '-Command', script], {
253253
encoding: 'utf-8',
254254
timeout: 10000,
255255
})
@@ -353,6 +353,165 @@ export function getImageFilePathFromText(text: string, cwd: string): string | nu
353353
}
354354
}
355355

356+
/**
357+
* Read file URL/path from clipboard when a file has been copied (e.g., from Finder).
358+
* Returns the POSIX path if a file URL is found, null otherwise.
359+
*
360+
* When you copy a file in Finder (Cmd+C), the clipboard contains a file reference,
361+
* not plain text. pbpaste won't return the path, but we can use AppleScript to
362+
* extract it.
363+
*/
364+
function readClipboardFilePathMacOS(): string | null {
365+
try {
366+
// First check if clipboard contains a file URL
367+
const infoResult = spawnSync('osascript', [
368+
'-e',
369+
'clipboard info',
370+
], { encoding: 'utf-8', timeout: 1000 })
371+
372+
if (infoResult.status !== 0) return null
373+
374+
const info = infoResult.stdout || ''
375+
// Check for file URL type in clipboard (furl = file URL)
376+
if (!info.includes('«class furl»') && !info.includes('public.file-url')) {
377+
return null
378+
}
379+
380+
// Extract the file path using AppleScript
381+
const script = `
382+
try
383+
set theFile to the clipboard as «class furl»
384+
return POSIX path of theFile
385+
on error
386+
return ""
387+
end try
388+
`
389+
390+
const result = spawnSync('osascript', ['-e', script], {
391+
encoding: 'utf-8',
392+
timeout: 1000,
393+
})
394+
395+
if (result.status === 0 && result.stdout) {
396+
const filePath = result.stdout.trim()
397+
if (filePath && existsSync(filePath)) {
398+
return filePath
399+
}
400+
}
401+
return null
402+
} catch {
403+
return null
404+
}
405+
}
406+
407+
/**
408+
* Read file path from clipboard when a file has been copied (Windows).
409+
* Returns the file path if found, null otherwise.
410+
*/
411+
function readClipboardFilePathWindows(): string | null {
412+
try {
413+
const script = `
414+
Add-Type -AssemblyName System.Windows.Forms
415+
$files = [System.Windows.Forms.Clipboard]::GetFileDropList()
416+
if ($files.Count -gt 0) {
417+
Write-Output $files[0]
418+
}
419+
`
420+
const result = spawnSync('powershell', ['-STA', '-Command', script], {
421+
encoding: 'utf-8',
422+
timeout: 1000,
423+
})
424+
425+
if (result.status === 0 && result.stdout) {
426+
const filePath = result.stdout.trim()
427+
if (filePath && existsSync(filePath)) {
428+
return filePath
429+
}
430+
}
431+
return null
432+
} catch {
433+
return null
434+
}
435+
}
436+
437+
/**
438+
* Read file path from clipboard when a file has been copied (Linux).
439+
* Returns the file path if found, null otherwise.
440+
*/
441+
function readClipboardFilePathLinux(): string | null {
442+
try {
443+
// Try to get file URI from clipboard
444+
let result = spawnSync('xclip', [
445+
'-selection', 'clipboard',
446+
'-t', 'text/uri-list',
447+
'-o',
448+
], { encoding: 'utf-8', timeout: 1000 })
449+
450+
if (result.status !== 0) {
451+
// Try wl-paste for Wayland
452+
result = spawnSync('wl-paste', ['--type', 'text/uri-list'], {
453+
encoding: 'utf-8',
454+
timeout: 1000,
455+
})
456+
}
457+
458+
if (result.status === 0 && result.stdout) {
459+
const output = result.stdout.trim()
460+
// Parse file:// URLs
461+
const lines = output.split('\n')
462+
for (const line of lines) {
463+
const trimmed = line.trim()
464+
if (trimmed.startsWith('file://')) {
465+
const filePath = decodeURIComponent(trimmed.slice(7))
466+
if (existsSync(filePath)) {
467+
return filePath
468+
}
469+
}
470+
}
471+
}
472+
return null
473+
} catch {
474+
return null
475+
}
476+
}
477+
478+
/**
479+
* Read file path from clipboard when a file has been copied.
480+
* This handles the case where a user copies a file in their file manager.
481+
* Returns the file path if found, null otherwise.
482+
*
483+
* Note: This returns ANY file path, not just images. Callers should check
484+
* if the file is an image using isImageFile() if needed.
485+
*/
486+
export function readClipboardFilePath(): string | null {
487+
const platform = process.platform
488+
489+
switch (platform) {
490+
case 'darwin':
491+
return readClipboardFilePathMacOS()
492+
case 'win32':
493+
return readClipboardFilePathWindows()
494+
case 'linux':
495+
return readClipboardFilePathLinux()
496+
default:
497+
return null
498+
}
499+
}
500+
501+
/**
502+
* Read image file path from clipboard when an image file has been copied.
503+
* This is a convenience wrapper that combines readClipboardFilePath() with
504+
* an image file check.
505+
* Returns the file path if it's an image file, null otherwise.
506+
*/
507+
export function readClipboardImageFilePath(): string | null {
508+
const filePath = readClipboardFilePath()
509+
if (filePath && isImageFile(filePath)) {
510+
return filePath
511+
}
512+
return null
513+
}
514+
356515
/**
357516
* Read text from clipboard. Returns null if reading fails.
358517
*/

0 commit comments

Comments
 (0)