fix: convert dragged file paths to @mentions for attachment (#382)
* fix: convert dragged file paths to @mentions for attachment
When non-image files are dragged into the terminal, the file path was
inserted as plain text and never attached. Now detected absolute paths
are converted to @mentions so they get picked up by the attachment system.
* test: add tests for drag-and-drop file path detection
* fix: multi-image drag-and-drop only showing last image
insertTextAtCursor read input and cursorOffset from the React closure,
which is stale when called in a synchronous loop (e.g. onImagePaste for
multiple dragged images). Now uses refs so each insertion chains on the
previous one.
* fix: quote Windows absolute paths to avoid MCP mention collision
Paths containing ':' (e.g. Windows drive letters) are now emitted in
quoted @"..." form so they don't match the MCP resource mention regex.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: decouple dragDropPaths from imagePaste and harden image checks
- Check image extension against the cleaned path (post quote/escape
stripping) so quoted or backslash-escaped image drops are reliably
routed to the image paste handler.
- Inline the image extension regex and drop the imagePaste/fsOperations
imports so the module (and its tests) no longer pull in `bun:bundle`
and the heavier fs wrapper chain. Use plain `fs.existsSync` for the
on-disk check.
- Add tests covering quoted image paths, uppercase extensions,
backslash-escaped image paths, escaped real files with spaces, mixed
segments containing an image, quoted-nonexistent paths, and leading
or trailing whitespace.
* test: verify dragged paths with an `@` segment are preserved
Adds a fixture under a scoped-package-style subdir (`@types/index.d.ts`)
so we exercise the realistic `node_modules/@types/...` drag case and
lock in that `extractDraggedFilePaths` returns the raw path unchanged —
the `@` inside the path must not collide with the mention prefix the
caller prepends downstream.
* test: parametrize dragDropPaths cases with test.each
Groups the 21 scenarios into four table-driven describes
(empty-result, single-path, multi-path, backslash-escaped) so that
adding a new case is a one-line row instead of a new `test()` block.
Fixture directories are now created synchronously at describe-load
time so their paths are available to the test.each tables, which are
built before any hook runs.
* test: add contract tests for @-mention extractor boundary
Pins the contract between `extractAtMentionedFiles` and
`extractMcpResourceMentions` so the MCP regex can't silently swallow
quoted file-path mentions.
These tests fail on current HEAD — 3 of 11 cases expose the regression
pointed out in the review on #382: `extractMcpResourceMentions`'s
trailing `\b` backtracks past the closing `"` of a quoted mention and
produces a ghost match for `@"C:\Users\..."`, `@C:\Users\...`, and
`@"/tmp/weird:name.txt"`. The remaining 8 cases lock in the behaviour
that must not change (legitimate `server:resource` mentions and plain
file-path mentions).
Committed failing on purpose as the first half of a test-then-fix
pair; the regex fix follows in a subsequent commit.
* fix: prevent MCP extractor from ghost-matching quoted/Windows paths
The MCP resource regex used `\b` as a trailing anchor with `[^\s]+`
character classes. On any quoted file mention containing a colon
(`@"C:\Users\me\file.txt"`, `@"/tmp/weird:name.txt"`), the engine
backtracked past the closing `"` to satisfy `\b`, producing a ghost
match that collided with `extractAtMentionedFiles`. Unquoted Windows
drive-letter paths (`@C:\Users\me\file.txt`) also matched because a
drive letter is structurally identical to an MCP `server:resource`
token.
Two guards:
1. `(?!")` right after `@` drops quoted tokens entirely, and adding
`"` to the character classes blocks any mid-match backtracking.
2. A post-match filter discards `^[A-Za-z]:[\\/]` — a single-letter
server followed by a path separator is always a Windows drive
prefix, never a real MCP resource.
Legitimate MCP forms (`@server:resource/path`, plugin-scoped like
`@asana-plugin:project-status/123`, inline prose mentions) remain
matched and are pinned by the contract tests added in 04998d5.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ import { isBilledAsExtraUsage } from '../../utils/extraUsage.js';
|
||||
import { getFastModeUnavailableReason, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js';
|
||||
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
|
||||
import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js';
|
||||
import { extractDraggedFilePaths } from '../../utils/dragDropPaths.js';
|
||||
import { getImageFromClipboard, PASTE_THRESHOLD } from '../../utils/imagePaste.js';
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js';
|
||||
import { cacheImagePath, storeImage } from '../../utils/imageStore.js';
|
||||
@@ -1204,6 +1205,22 @@ function PromptInput({
|
||||
// Clean up pasted text - strip ANSI escape codes and normalize line endings and tabs
|
||||
let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' ');
|
||||
|
||||
// Detect file paths from drag-and-drop and convert to @mentions.
|
||||
// When files are dragged into the terminal, the terminal sends their
|
||||
// absolute paths via bracketed paste. Image files are handled by the
|
||||
// image paste handler upstream; here we handle non-image files by
|
||||
// converting them to @mentions so they get attached on submit.
|
||||
const draggedPaths = extractDraggedFilePaths(text);
|
||||
if (draggedPaths.length > 0) {
|
||||
const mentions = draggedPaths
|
||||
.map(p => (p.includes(' ') || p.includes(':') ? `@"${p}"` : `@${p}`))
|
||||
.join(' ');
|
||||
// Ensure spacing around the mention(s) relative to existing input
|
||||
const charBefore = input[cursorOffset - 1];
|
||||
const prefix = charBefore && !/\s/.test(charBefore) ? ' ' : '';
|
||||
text = prefix + mentions + ' ';
|
||||
}
|
||||
|
||||
// Match typed/auto-suggest: `!cmd` pasted into empty input enters bash mode.
|
||||
if (input.length === 0) {
|
||||
const pastedMode = getModeFromInput(text);
|
||||
@@ -1245,12 +1262,23 @@ function PromptInput({
|
||||
if (isNonSpacePrintable(input, key)) return ' ' + input;
|
||||
return input;
|
||||
}, []);
|
||||
// Ref mirrors cursorOffset for use in synchronous loops (e.g. multi-image
|
||||
// paste) where React batches state updates and the closure value is stale.
|
||||
const cursorOffsetRef = useRef(cursorOffset);
|
||||
cursorOffsetRef.current = cursorOffset;
|
||||
|
||||
function insertTextAtCursor(text: string) {
|
||||
// Push current state to buffer before inserting
|
||||
pushToBuffer(input, cursorOffset, pastedContents);
|
||||
const newInput = input.slice(0, cursorOffset) + text + input.slice(cursorOffset);
|
||||
// Use refs for input/cursor so back-to-back calls in the same event
|
||||
// (e.g. onImagePaste loop for multiple dragged images) chain correctly
|
||||
// instead of each reading the same stale closure values.
|
||||
const currentInput = lastInternalInputRef.current;
|
||||
const currentOffset = cursorOffsetRef.current;
|
||||
pushToBuffer(currentInput, currentOffset, pastedContents);
|
||||
const newInput = currentInput.slice(0, currentOffset) + text + currentInput.slice(currentOffset);
|
||||
trackAndSetInput(newInput);
|
||||
setCursorOffset(cursorOffset + text.length);
|
||||
const newOffset = currentOffset + text.length;
|
||||
cursorOffsetRef.current = newOffset;
|
||||
setCursorOffset(newOffset);
|
||||
}
|
||||
const doublePressEscFromEmpty = useDoublePress(() => {}, () => onShowMessageSelector());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user