Skip to content

fix(cli): auto-prepend @ when pasting or dropping multiple file paths#4544

Merged
wenshao merged 9 commits into
QwenLM:mainfrom
MikeWang0316tw:feat/support-multi-file-paste
May 27, 2026
Merged

fix(cli): auto-prepend @ when pasting or dropping multiple file paths#4544
wenshao merged 9 commits into
QwenLM:mainfrom
MikeWang0316tw:feat/support-multi-file-paste

Conversation

@MikeWang0316tw

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes an inconsistency where pasting or drag-and-dropping multiple file
paths did not auto-prepend @, while single-file paste/drag worked
correctly.

Before

Action Result Status
Drag & drop 1 file @file
Drag & drop 3 files 'file1' 'file2' 'file3'
Paste 1 file @file
Paste 3 files file1 file2 file3

After

Action Result Status
Drag & drop 1 file @file ✅ (unchanged)
Drag & drop 3 files @file1 @file2 @file3 ✅ (fixed)
Paste 1 file @file ✅ (unchanged)
Paste 3 files @file1 @file2 @file3 ✅ (fixed)

Changes

  • Modified packages/cli/src/ui/components/shared/text-buffer.ts
    • Added newline-separated path detection for paste input
    • Added whitespace-separated path detection for paste input
    • Both paths now prepend @ to each detected file path
    • Strips surrounding quotes before path validation (common in drag-and-drop)

Testing

  • Tested pasting 2-3 file paths separated by newlines
  • Tested pasting 2-3 file paths separated by whitespace
  • Tested drag-and-dropping 2-3 file paths (with surrounding quotes)
  • Tested pasting quoted file paths
  • Verified single-file paste and drag-and-drop still work correctly
  • All 138 tests pass (6 new tests added)

🤖 Generated with Qwen Code (35-shotted by Qwen-Coder)

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

Hi @MikeWang0316tw, thanks for this fix! The UX inconsistency with multi-file paste is a real issue and worth fixing.

Quick observation on the diff: this PR includes version bumps from 0.16.00.16.1 across all packages (package.json, package-lock.json, sandboxImageUri, etc. — 14 files total). This looks like it might have been pulled in accidentally during a merge or rebase, rather than being intentional.

Could you check and remove those version-related changes? This PR should only contain the text-buffer.ts and text-buffer.test.ts changes. Keeping version bumps out of feature/bugfix PRs avoids unnecessary merge conflicts and keeps the commit history clean.

I also have a few other findings on the code changes — will share once this is sorted.

中文说明

PR 里混入了从 0.16.0 到 0.16.1 的全量版本号变更(14 个文件),看起来像是 merge/rebase 时带进来的。麻烦检查下,只保留 text-buffer.tstext-buffer.test.ts 的改动就好。版本号 bump 应该走发布流程,不应该出现在功能 PR 里。

— Qwen Code

When pasting or drag-and-dropping a single file path, Qwen Code
automatically prepends @ to convert it into an @file reference. However,
pasting or dragging multiple file paths (separated by newlines or
whitespace) did not receive the same treatment, requiring users to
manually add @ to each path.

This change extends the paste detection logic in text-buffer.ts to
detect and prepend @ to multiple file paths, matching the behavior
of single-file paste/drag. The fix also strips surrounding quotes
(common in drag-and-drop input) before path validation.

Before:
  - Drag & drop 1 file → @file ✅
  - Drag & drop 3 files → 'file1' 'file2' 'file3' ❌
  - Paste 1 file → @file ✅
  - Paste 3 files → file1 file2 file3 ❌

After:
  - Drag & drop 1 file → @file ✅ (unchanged)
  - Drag & drop 3 files → @file1 @FILE2 @file3 ✅ (fixed)
  - Paste 1 file → @file ✅ (unchanged)
  - Paste 3 files → @file1 @FILE2 @file3 ✅ (fixed)
@MikeWang0316tw MikeWang0316tw force-pushed the feat/support-multi-file-paste branch from 94bba10 to 6d8ae66 Compare May 26, 2026 09:36
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Hi @pomelo-nwu, thanks for the quick review!

I've rebased the PR to the latest upstream/main and removed all the version-related changes. Now the PR only contains the text-buffer.ts and text-buffer.test.ts changes.

Thanks for looking into this! I'll wait for your other findings.

@pomelo-nwu

pomelo-nwu commented May 26, 2026

Copy link
Copy Markdown
Collaborator

E2E Test Report: Multi-File Paste Auto-@ Prefix

Tested on feat/support-multi-file-paste (commit 6d8ae669d0) using a real tmux TUI harness with bracketed paste events (tmux paste-buffer -p).

Result: ✅ ALL PASS — 8 scenarios, 18 assertions, 138 unit tests

Unit Tests

  • text-buffer.test.ts: 138/138 passed (88ms)

Test 1 — Single file paste (baseline regression) ✅

Paste input: /tmp/pr4544-test-files/alpha.txt

TUI input box after paste:

┌──────────────────────────────────────────────────────────────────────────────────┐
│ >_ Qwen Code (vdev)                                                              │
│                                                                                  │
│ Standard API Key | qwen3.7-max (/model to change)                                │
│ ~/.../qwen-code/.qwen/worktrees/pr4544-multi-file-paste                          │
└──────────────────────────────────────────────────────────────────────────────────┘

──────────────────────────────────────────────────────────────────────────────────
* @/tmp/pr4544-test-files/alpha.txt
──────────────────────────────────────────────────────────────────────────────────
  qwen3.7-max | Context 100% left | 6d8ae669d0 | 1.0m window

Test 2 — Multi-file paste, space-separated, quoted ✅

Paste input: '<alpha.txt>' '<beta.ts>' '<gamma.md>'

TUI input box after paste:

──────────────────────────────────────────────────────────────────────────────────
* @/tmp/pr4544-test-files/alpha.txt @/tmp/pr4544-test-files/beta.ts @/tmp/pr4544-test-files/gamma.md
──────────────────────────────────────────────────────────────────────────────────

Quotes stripped, all 3 paths got @ prefix. ✅ (3/3 assertions)


Test 3 — Multi-file paste, space-separated, unquoted ✅

Paste input: <alpha.txt> <beta.ts> <gamma.md>

TUI input box after paste:

──────────────────────────────────────────────────────────────────────────────────
* @/tmp/pr4544-test-files/alpha.txt @/tmp/pr4544-test-files/beta.ts @/tmp/pr4544-test-files/gamma.md
──────────────────────────────────────────────────────────────────────────────────

✅ (3/3 assertions)


Test 4 — Multi-file paste, newline-separated ✅

Paste input: <alpha.txt>\n<beta.ts>\n<gamma.md>

TUI input box after paste:

──────────────────────────────────────────────────────────────────────────────────
* @/tmp/pr4544-test-files/alpha.txt @/tmp/pr4544-test-files/beta.ts @/tmp/pr4544-test-files/gamma.md
──────────────────────────────────────────────────────────────────────────────────

Newlines normalized to spaces, all paths got @. ✅ (3/3 assertions)


Test 5 — Multi-file paste, newline-separated, quoted ✅

Paste input: '<alpha.txt>'\n'<beta.ts>'\n'<gamma.md>'

TUI input box after paste:

──────────────────────────────────────────────────────────────────────────────────
* @/tmp/pr4544-test-files/alpha.txt @/tmp/pr4544-test-files/beta.ts @/tmp/pr4544-test-files/gamma.md
──────────────────────────────────────────────────────────────────────────────────

✅ (3/3 assertions)


Test 6 — Mixed quoted and unquoted ✅

Paste input: '<alpha.txt>' <beta.ts> '<gamma.md>'

TUI input box after paste:

──────────────────────────────────────────────────────────────────────────────────
* @/tmp/pr4544-test-files/alpha.txt @/tmp/pr4544-test-files/beta.ts @/tmp/pr4544-test-files/gamma.md
──────────────────────────────────────────────────────────────────────────────────

✅ (3/3 assertions)


Test 7 — Single quoted file (regression) ✅

Paste input: '/tmp/pr4544-test-files/alpha.txt'

TUI input box after paste:

──────────────────────────────────────────────────────────────────────────────────
* @/tmp/pr4544-test-files/alpha.txt
──────────────────────────────────────────────────────────────────────────────────

Single-file path still works correctly through the new code path. ✅


Test 8 — Non-path text (negative test) ✅

Paste input: hello world this is just text

TUI input box after paste:

──────────────────────────────────────────────────────────────────────────────────
* hello world this is just text
──────────────────────────────────────────────────────────────────────────────────

No @ added — correct behavior for non-path content. ✅


Remaining Review Items

The code-level concerns from the earlier comment still apply:

  • shellModeActive guard missing in the newline branch
  • isPaste variable is redundant (equivalent to paste after early return)
  • Code complexity: extracting a tryExtractFilePaths() helper would simplify
  • Version bumps should be removed from this PR

But functionally, the paste behavior is verified correct across all tested scenarios.

中文说明

在 PR 分支上通过 tmux 真实 TUI 测试验证了多文件粘贴自动添加 @ 前缀的功能。使用 tmux paste-buffer -p 发送真实的 bracketed paste 事件(\e[200~ / \e[201~)模拟终端粘贴行为。

8 个测试场景全部通过:

  • 单文件粘贴(基线回归)
  • 多文件空格分隔(引号/非引号)
  • 多文件换行分隔(引号/非引号)
  • 混合引号/非引号
  • 单引号文件回归
  • 非路径文本的负向测试

138 个单元测试也全部通过。

每个场景上方都附了 TUI 输入框的实际截图(来自 tmux capture-pane),可以直接看到粘贴后输入框里显示的内容。

功能层面验证通过,但代码层面仍有待处理项: 换行分支缺少 shellModeActive 守卫、isPaste 变量冗余、代码复杂度可通过抽取纯函数降低、版本号 bump 需要移除。

— Qwen Code

pomelo-nwu
pomelo-nwu previously approved these changes May 26, 2026
Refactor multi-file paste @-path detection:
- Extract tryExtractFilePaths() and extractPathsFromSegment() helpers
  to eliminate ~100 lines of duplicated path-extraction logic
- Add shellModeActive guard to newline paste branch for consistency
- Remove redundant isPaste variable
- Escape spaces in paths with backslash so downstream parseAllAtCommands
  parser correctly handles filenames with spaces (e.g. @/path/to/my\ file.txt)
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

The macOS CI failure (AskUserQuestionDialog.test.tsx) is a flaky test unrelated to this PR's changes.

Verification:

  • All 138 text-buffer.test.ts tests pass locally ✅
  • AskUserQuestionDialog.test.tsx passes locally (17 passed) ✅
  • ubuntu-latest and windows-latest CI checks pass ✅
  • The failing test is in a completely different file (AskUserQuestionDialog) with no connection to text-buffer.ts

This appears to be a pre-existing race condition in the TUI dialog test, not caused by the multi-file paste changes.

@wenshao

wenshao commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Verification Report — local tmux real-CLI testing

Reproduced and verified on pr-4544 (commits 6d8ae669d + 04d73efbf) against main. Built with npm run build, ran the resulting packages/cli/dist/index.js inside tmux and drove pastes via tmux load-buffer + tmux paste-buffer -p (which wraps the buffer in bracketed-paste ESC[200~…ESC[201~ — exactly the escape sequence a real terminal emits on paste/drop).

Automated checks

Check Result
vitest run text-buffer.test.ts 138/138 pass (6 new tests included)
vitest run src/ui/components/shared/ (broader regression) 323 pass / 1 skipped across 10 files
tsc --noEmit (cli pkg) clean
eslint text-buffer.ts text-buffer.test.ts clean
npm run build (full repo) OK (qwen v0.16.1)

Interactive tmux paste scenarios

Files used: /tmp/qwen-pr4544-test/files/{file1,file2,file3}.txt and file with space.txt (with a literal space).

# Paste content Result in input Pass
1 …/file1.txt (single) @…/file1.txt
2 …/file1.txt …/file2.txt …/file3.txt (space-sep, unquoted) @…/file1.txt @…/file2.txt @…/file3.txt
3 …/file1.txt\n…/file2.txt\n…/file3.txt (newline-sep) @…/file1.txt @…/file2.txt @…/file3.txt
4 '…/file1.txt' '…/file2.txt' '…/file3.txt' (quoted drag-drop) @…/file1.txt @…/file2.txt @…/file3.txt
5 '…/file1.txt' …/file2.txt '…/file3.txt' (mixed quoted/unquoted) @…/file1.txt @…/file2.txt @…/file3.txt
6 '…/file with space.txt' (quoted path containing space) @…/file\ with\ space.txt ✅ — space escaped so parseAllAtCommands doesn't truncate
7 '…/file1.txt' '…/file with space.txt' '…/file2.txt' @…/file1.txt @…/file\ with\ space.txt @…/file2.txt
8 please review my code changes (prose) please review my code changes ✅ — no @ injected
9 …/file1.txt …/nonexistent.txt …/file2.txt @…/file1.txt @…/file2.txt ✅ — invalid path filtered out
10 '…/file1.txt'\n'…/file2.txt'\n'…/file3.txt' (quoted + newline) @…/file1.txt @…/file2.txt @…/file3.txt
11 hi (2 chars, below minLengthToInferAsDragDrop) hi ✅ — short-paste guard intact
12 line 1 of text\nline 2 of text (multi-line, no paths) preserved as 2 lines, no @ ✅ — newline branch falls through cleanly when tryExtractFilePaths returns null
13 @…/file1.txt (already @-prefixed) @…/file1.txt ✅ — no double-prefix

Code-level notes

  • tryExtractFilePaths and extractPathsFromSegment are pure functions outside useTextBuffer, so they are trivially unit-testable and don't add hook-rerender cost.
  • The new paste && /[\n\r]/.test(ch) && !shellModeActive branch runs before the existing early-return on newlines, so the newline-paste path is now reachable for the first time without otherwise changing how plain newline pastes (no paths) are handled — confirmed by T12.
  • escapePath is now wrapping the @<path> output. This is the right call: parseAllAtCommands terminates at unescaped whitespace, so without it any path containing a space would be silently truncated downstream. T6/T7 exercise this end-to-end.
  • Shell-mode guard (!shellModeActive) is preserved on both the newline and the single-line branches — non-regression on existing "Shell Mode Behavior" tests confirms this.

Recommendation

LGTM for merge. The fix is well-scoped to text-buffer.ts, the new tests cover the matrix in the PR description, the broader CLI test suite has no regressions, and real bracketed-paste exercising in tmux matches the expected "After" table in the PR description — including the spaces-in-paths edge case introduced by the follow-up commit.

potentialPath = potentialPath.trim();
if (isValidPath(unescapePath(potentialPath))) {
ch = `@${potentialPath} `;
const validPaths = tryExtractFilePaths(ch.trim(), isValidPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Silent data loss regression: tryExtractFilePaths extracts paths from within arbitrary text and replaces the entire paste with only @-prefixed paths. Non-path text is silently discarded.

The old code was all-or-nothing: it only prepended @ if the entire pasted string was a single valid path, otherwise it kept the text unchanged. The new code breaks this — pasting "Check out /src/index.ts and /src/utils.ts" produces "@/src/index.ts @/src/utils.ts " (words "Check out" and "and" are lost).

This affects both the single-line branch (here) and the multi-line branch above. The existing test "should only prepend @ to valid paths among mixed content" actually demonstrates this data loss by treating /path/to/invalid.txt being dropped as correct behavior — but prose text between valid paths is equally dropped.

Suggested fix: Only transform the paste when the content looks like a pure list of paths (every token is either a valid path or whitespace/newline). If any non-path, non-separator token exists, fall back to inserting the raw paste unchanged. This preserves the original all-or-nothing semantics while still supporting the multi-file use case.

— qwen3.7-max via Qwen Code /review

let i = 0;
while (i < tokens.length) {
let found = false;
for (let j = i + 1; j <= tokens.length; j++) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] O(n²) synchronous filesystem calls on the UI thread. The inner loop tries every j from i+1 to tokens.length, calling isValidPath (which does fs.existsSync + fs.statSync) on each candidate. For N tokens of non-path text, this is N*(N+1)/2 iterations with ~2 sync syscalls each.

Pasting a 40-word paragraph of non-path text produces ~820 isValidPath calls (~1640 sync fs operations), all blocking the React/Ink render thread. This can cause a visible UI freeze.

Suggested fix: Pre-filter tokens that look like path prefixes (start with /, ./, ~/, or a drive letter) before entering the inner loop. For tokens that can't plausibly be paths, skip directly to i++. This reduces the worst case dramatically for typical prose pastes.

— qwen3.7-max via Qwen Code /review

segment: string,
isValidPath: (p: string) => boolean,
): string[] {
const tokens = segment.split(/\s+/).filter(Boolean);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] No test exercises the greedy multi-token matching or the escapePath integration. All 6 new tests use paths without spaces (/path/to/file1.txt), yet the greedy for (let j = i + 1; ...) loop and escapePath (which backslash-escapes spaces) exist specifically to handle paths with spaces.

If the greedy matching or escaping has a bug, it would go completely undetected. Users dragging files like My Document.txt from Finder would get broken @ references.

Suggested fix: Add tests like:

it('should handle unquoted paths with spaces via greedy matching', () => {
  const { result } = renderHook(() =>
    useTextBuffer({
      viewport,
      isValidPath: (p: string) => p === '/path/to/my file.txt',
    }),
  );
  act(() => result.current.insert('/path/to/my file.txt', { paste: true }));
  expect(getBufferState(result).text).toBe('@/path/to/my\\ file.txt ');
});

— qwen3.7-max via Qwen Code /review

for (const line of lines) {
const quotedPathRegex = /'([^']*)'/g;
let lastIndex = 0;
let match;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The regex /'([^']*)'/g matches text between any two apostrophes, including English contractions. Input like don't /src/file.ts it's done produces a false match 't /src/file.ts it' (capturing everything between the apostrophe in "don't" and the one in "it's"). This sets hasQuotedPaths = true, which changes how the line is processed — the actual path /src/file.ts gets trapped inside the false quoted match and fails isValidPath, then is silently dropped.

Suggested fix: Require the quoted content to look path-like before treating it as a quoted path:

const quotedPathRegex = /'([\/~.][^']*)'/g;

Or only set hasQuotedPaths = true when at least one quoted match is actually a valid path.

— qwen3.7-max via Qwen Code /review

// We need to process these before the newline check below, which would
// otherwise cause an early return and skip the @-path detection.
if (paste && /[\n\r]/.test(ch) && !shellModeActive) {
const validPaths = tryExtractFilePaths(ch, isValidPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Both the if (validPaths) branch and the else branch below dispatch the identical action dispatch({ type: 'insert', payload: ch }) followed by return. This can be flattened:

if (paste && /[\n\r]/.test(ch) && !shellModeActive) {
  const validPaths = tryExtractFilePaths(ch, isValidPath);
  if (validPaths) {
    ch = `${validPaths.join(' ')} `;
  }
  dispatch({ type: 'insert', payload: ch });
  return;
}

— qwen3.7-max via Qwen Code /review

isValidPath: (p: string) => boolean,
): string[] | null {
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = normalized.split(/\n/).filter((s) => s.trim().length > 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Several branches in the new code lack test coverage:

  1. CRLF normalization (this line): content.replace(/\r\n/g, '\n').replace(/\r/g, '\n') is never tested with \r\n or bare \r input.
  2. Shell mode + newline paste: the !shellModeActive guard above means newline pastes in shell mode skip path extraction — no test verifies this fallthrough.
  3. Null return from tryExtractFilePaths: when no valid paths are found in a newline paste, the raw content is dispatched — no test covers this branch.

Suggested fix: Add tests for each branch:

// CRLF
act(() => result.current.insert('/a.txt\r\n/b.txt', { paste: true }));
// Shell mode + newline
useTextBuffer({ viewport, isValidPath: () => true, shellModeActive: true });
act(() => result.current.insert('/a.txt\n/b.txt', { paste: true }));
// No valid paths
useTextBuffer({ viewport, isValidPath: () => false });
act(() => result.current.insert('line one\nline two', { paste: true }));

— qwen3.7-max via Qwen Code /review

* IMPORTANT: Escapes spaces in paths with backslash so that the downstream
* `parseAllAtCommands` parser (which terminates at unescaped whitespace)
* correctly includes the entire path.
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The JSDoc says escapePath escapes for parseAllAtCommands, but the two functions have a mismatched character set. The parser terminates at /[,\s;!?()[\]{}]/ (line 94 of atCommandProcessor.ts) — note the comma. But SHELL_SPECIAL_CHARS used by escapePath is /[ \t()[\]{};|*?$'"#&<>!~]/` — no comma.

A file like /path/to/report,v2.txt will be output as @/path/to/report,v2.txt (comma unescaped), and the parser will truncate at the comma, reading only @/path/to/report.

Suggested fix: Add , to SHELL_SPECIAL_CHARS in packages/core/src/utils/paths.ts, or additionally escape commas in the output after escapePath.

— qwen3.7-max via Qwen Code /review

Address 3 critical and 4 suggestion issues from wenshao's review:

Critical fixes:
- Prevent silent data loss: only transform paste when all tokens are
  valid paths. If any non-path token exists, preserve original content
  (hadNonPathToken tracking)
- Fix O(n²) synchronous fs calls: pre-filter tokens with looksLikePath()
  to skip non-path tokens before expensive fs.existsSync calls
- Add tests for paths with spaces and escapePath integration (6 new tests)

Suggestion fixes:
- Fix quoted path regex: require path-like prefix to avoid false matches
  on English contractions (don't → /'([~/.][^']*)'/g)
- Flatten if/else in newline paste branch
- Add CRLF, shell mode, and null-return tests
- Escape commas in paths for parseAllAtCommands compatibility
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Fixed
Use escapePath() for whitespace escaping instead of manual regex to
avoid CodeQL's incomplete-string-escaping warning, then additionally
escape commas.
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Fixed
- Add comma to SHELL_SPECIAL_CHARS in core so escapePath handles it
- Simplify escapePathAndCommas to just call escapePath(path)
- This avoids CodeQL's incomplete-string-escaping warning
@MikeWang0316tw MikeWang0316tw requested a review from wenshao May 26, 2026 22:49
* Used to pre-filter tokens before expensive fs calls.
*/
function looksLikePath(str: string): boolean {
// Strip surrounding quotes first to handle quoted paths

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] looksLikePath is too restrictive compared to the old single-path paste logic it replaces. The old code (on main) passed any token directly to isValidPath with no prefix check. This new pre-filter rejects bare relative paths like package.json, .gitignore, and src/index.ts — none of which start with /, ./, ../, ~/, or a drive letter.

This is a silent regression: pasting package.json (which exists on disk) used to produce @package.json but now preserves the original text unchanged.

Suggested change
// Strip surrounding quotes first to handle quoted paths
function looksLikePath(str: string): boolean {
// Strip surrounding quotes first to handle quoted paths
const unquoted = str.replace(/^'(.*)'$/, '$1');
return (
unquoted.startsWith('/') ||
unquoted.startsWith('./') ||
unquoted.startsWith('../') ||
unquoted.startsWith('~/') ||
unquoted.startsWith('.') ||
/^[A-Za-z]:/.test(unquoted) ||
unquoted.includes('/')
);
}

Adding unquoted.startsWith('.') covers dotfiles (.gitignore, .env). Adding unquoted.includes('/') covers bare relative paths (src/index.ts, packages/core/src/utils/paths.ts) while still rejecting plain words.

— qwen3.7-max via Qwen Code /review

let found = false;
for (let j = i + 1; j <= tokens.length; j++) {
const candidate = tokens.slice(i, j).join(' ');
let unquoted = candidate;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Shortest-match-first greedy matching can miss transformations for unquoted paths with spaces. If /tmp/a exists as a file and the user pastes /tmp/a b.txt, the algorithm matches /tmp/a at j = i + 1 (shortest), then b.txt fails looksLikePath, setting hadNonPathToken = true. The entire paste falls back to original content — the valid longer path /tmp/a b.txt is never tried.

Consider reversing the loop to longest-match-first:

Suggested change
let unquoted = candidate;
for (let j = tokens.length; j >= i + 1; j--) {

This tries the longest candidate first and falls back to shorter ones, matching user intent more accurately for unquoted paths with spaces. Trade-off: more isValidPath calls in the worst case, but looksLikePath pre-filtering limits this to path-like tokens only.

— qwen3.7-max via Qwen Code /review

* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
*/
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Adding , to SHELL_SPECIAL_CHARS has global side effects on all 15+ callers of escapePath()/unescapePath() across every tool (edit, read-file, write-file, glob, grep, etc.). Comma is not a POSIX shell metacharacter — it's included here because parseAllAtCommands treats it as a path terminator, but the JSDoc still says "shell metacharacters."

Two follow-up items:

  1. The vscode-ide-companion package has its own copy of SHELL_SPECIAL_CHARS at packages/vscode-ide-companion/src/utils/imageSupport.ts:31 that does NOT include comma. If a comma-escaped path from core flows into the extension's unescapePath, the \, won't be unescaped, leaving a stray backslash.
  2. Consider adding a test in paths.test.ts for comma-escaping to prevent silent regression.

— qwen3.7-max via Qwen Code /review

act(() => result.current.insert(shortText, { paste: true }));
expect(getBufferState(result).text).toBe(shortText);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Several code paths in the new helpers lack test coverage:

  1. Relative and home-dir paths: looksLikePath has explicit branches for ./, ../, and ~/ but all 11 tests use absolute paths starting with /. A test like './src/index.ts ../lib/utils.ts ~/notes.md' would cover these.
  2. Unquoted path that looks like a path but is invalid: The !found branch in extractPathsFromSegment (line 2048) is never exercised. Existing non-path tests use quoted invalid paths which are handled by quotedPathRegex, not this branch. A test like '/valid/file.txt /nonexistent/path' (where only the first is valid) would cover it.
  3. paths.test.ts comma test: SHELL_SPECIAL_CHARS was modified to include comma but the exhaustive special-characters test in paths.test.ts was not updated.

— qwen3.7-max via Qwen Code /review

- looksLikePath: add support for relative paths (./, ../, ~/) and dotfiles
- Greedy matching: reverse to longest-match-first for paths with spaces
- Add tests for relative paths, longest-match-first, and invalid paths
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Hi @wenshao, thanks for the detailed review! I've addressed all the remaining findings:

1. looksLikePath too restrictive (line 1911)
Added to cover dotfiles and to cover bare relative paths like .

2. Greedy matching order (line 2035)
Reversed the loop to longest-match-first so paths with spaces are tried before shorter prefixes.

3. Comma escaping global side effects (line 47, paths.ts)
Added to in core. Note: the copy of still needs to be updated separately to avoid the mismatch issue you mentioned.

4. Missing tests (line 581, text-buffer.test.ts)
Added 3 new tests:

  • Relative paths (, , )
  • Longest-match-first greedy matching for paths with spaces
  • Invalid path fallback (preserves original content)

All 148 tests pass. Thanks for the thorough review!

@wenshao

wenshao commented May 27, 2026

Copy link
Copy Markdown
Collaborator

本地真实测试报告(merge 参考)

按 PR body 的 Testing 列出的 6 类场景 + 一个关键 negative case(含 prose 不能被误覆盖),通过真 tmux + bracketed-paste 直接灌进 npm run dev 跑起来的 TUI,全部端到端通过。统计上 148/148 单测、tsc/eslint 干净,手测 6/6 case 都给出了与 PR 表格完全一致的输入框结果,包含逗号/空格转义这条分支。可以合并。

测试环境

测试 commit 160ecf085 (fix(cli): address wenshao's remaining code review findings)
平台 macOS arm64 (Darwin 25.4.0)
Node v22.17.0
Worktree /Users/wenshao/Work/git/qwen-code/.qwen/tmp/review-pr-4544(基于 pull/4544/head

1. 静态检查与单测

命令 结果 PR body 声明
npx tsc --noEmit -p packages/cli CLI=0
npx tsc --noEmit -p packages/core CORE=0
npx eslint <3 个 touched 文件> ESLINT=0
npx vitest run text-buffer.test.ts 148/148 138(review 期间又补了 10 个 case)

2. Diff 对账

  • packages/core/src/utils/paths.ts:1 行——SHELL_SPECIAL_CHARS 正则增加 , 字符(保证 path 里的逗号在 parseAllAtCommands 不会被误当成分隔符)
  • packages/cli/src/ui/components/shared/text-buffer.ts:+349/-111
    • 新增 tryExtractFilePaths(content, isValidPath):把 newline / whitespace / 引号 三种分隔的多 path 字符串拆出来、unescape、valid-check、然后 @ + escapePathAndCommas 再拼回
    • 新增 looksLikePath 快速预筛 + extractPathsFromSegment 子段拆分
    • 关键 invariant:"只有当所有非空白 token 都是 valid path 时才返回 paths,否则返回 null 让原文照常落入 buffer"——避免在 prose 里夹一个路径就把整段 paste 吃掉的 silent data loss
  • useTextBuffer hook 内部 insert(ch, { paste: true }) 的两条分支——newline 路径 / 大于 3 字符的非换行 paste——都先调 tryExtractFilePaths,命中就替换为 validPaths.join(' ') + ' '

3. Manual smoke — 真 tmux + bracketed paste 端到端

Setup

mkdir -p '/tmp/pr4544-test-paths/with comma,name'
echo alpha > /tmp/pr4544-test-paths/file1.txt
echo beta  > /tmp/pr4544-test-paths/file2.txt
echo gamma > /tmp/pr4544-test-paths/file3.txt
echo with-comma-content > '/tmp/pr4544-test-paths/with comma,name/file.txt'

tmux new-session -d -s pr4544 -x 220 -y 50 -c <worktree>
tmux send-keys -t pr4544 'npm run dev' Enter

每个 case 通过 tmux load-buffer 把 payload 放进 tmux paste buffer,tmux paste-buffer -t pr4544 -p 走 bracketed paste(让 Ink 的 keypress 处理认它是 paste: true),然后 tmux capture-pane 抓输入框那一行 ^\* 文本。每轮之间用 Esc + C-u + ~200 个 BSpace 把输入框清干净避免串扰。

6/6 端到端结果

# Case Payload 输入框中实际显示 PR 期望
1 3 paths newline-separated file1.txt\nfile2.txt\nfile3.txt @file1.txt @file2.txt @file3.txt @file1 @file2 @file3
2 3 paths whitespace-separated file1.txt file2.txt file3.txt @file1.txt @file2.txt @file3.txt @file1 @file2 @file3
3 3 paths quoted(drag-and-drop 形态) 'file1.txt' 'file2.txt' 'file3.txt' @file1.txt @file2.txt @file3.txt @file1 @file2 @file3,引号被剥掉 ✅
4 单 path(regression check) file1.txt @/tmp/pr4544-test-paths/file1.txt @file 单文件 path 仍然原样工作 ✅
5 path 含空格 + 逗号 '/tmp/pr4544-test-paths/with comma,name/file.txt' @/tmp/pr4544-test-paths/with\ comma\,name/file.txt 空格与逗号都用 \ 转义,下游 parseAllAtCommands 不会被在中间截断 ✅
6 prose 夹一个 path(negative,必须@-化) Please summarize /tmp/pr4544-test-paths/file1.txt for me Please summarize /tmp/pr4544-test-paths/file1.txt for me 原文照旧,不做 @ 前缀(防 silent data loss)✅

Case 5 直接验证了 packages/core/src/utils/paths.ts 那一行 SHELL_SPECIAL_CHARS 把逗号加进来的语义:path 里的逗号被 escapePath 加上 \,@/tmp/.../with\ comma\,name/file.txt 这种形态在 parseAllAtCommands 里能被作为单一 @-token 解析,不会在逗号那里截。

完整 stdout 摘录(tmux paste-buffer 实测,未删改)

=== Test 1: 3 paths newline-separated ===
INPUT BOX: * @/tmp/pr4544-test-paths/file1.txt @/tmp/pr4544-test-paths/file2.txt @/tmp/pr4544-test-paths/file3.txt  ​

=== Test 2: 3 paths whitespace-separated ===
INPUT BOX: * @/tmp/pr4544-test-paths/file1.txt @/tmp/pr4544-test-paths/file2.txt @/tmp/pr4544-test-paths/file3.txt  ​

=== Test 3: 3 paths quoted (drag-and-drop) ===
INPUT BOX: * @/tmp/pr4544-test-paths/file1.txt @/tmp/pr4544-test-paths/file2.txt @/tmp/pr4544-test-paths/file3.txt  ​

=== Test 4: single path (regression check) ===
INPUT BOX: * @/tmp/pr4544-test-paths/file1.txt  ​

=== Test 5: path with comma in name (SHELL_SPECIAL_CHARS update) ===
INPUT BOX: * @/tmp/pr4544-test-paths/with\ comma\,name/file.txt  ​

=== Test 6: prose mixed with path (must NOT auto-prepend, preserve) ===
INPUT BOX: * Please summarize /tmp/pr4544-test-paths/file1.txt for me ​

4. 与 PR body 的 Before/After 表对账

Action Before(PR body 声称) After(PR body 声称) 实测 一致
Drag & drop 1 file @file @file ✅ unchanged Test 4 / single quoted(同形)= @file
Drag & drop 3 files 'file1' 'file2' 'file3' @file1 @file2 @file3 ✅ fixed Test 3 = @file1.txt @file2.txt @file3.txt
Paste 1 file @file @file ✅ unchanged Test 4 = @/tmp/.../file1.txt
Paste 3 files file1 file2 file3 @file1 @file2 @file3 ✅ fixed Test 1 (newline) + Test 2 (whitespace) 都是 @file1 @file2 @file3

PR body 的 Before/After 表的所有 4 行都在真 CLI 上复现到了对应的 After 状态。

5. 结论

  • ✅ tsc / eslint / 单测全绿,单测数 148 (PR body 声明 138 → review 期间又补 10)
  • ✅ 真 tmux + bracketed paste 端到端 6/6 通过:3 paths 的三种分隔形态、单 path regression、空格+逗号的 escape、prose 的 negative case 全部对得上
  • ✅ Before/After 表的 4 行都被复现到 After 状态
  • ✅ Silent data loss 防御("任一 token 不像 path 就保留原文")实测有效——case 6 的混合 prose 没被改动

从本地真实测试角度,该 PR 可以合并。 修复了多文件 paste / drag-and-drop 与单文件不一致的体验问题;引入的 tryExtractFilePaths + 仅当全部 token 是 valid path 时才转换的 invariant 是合理的保守设计;SHELL_SPECIAL_CHARS, 让带逗号的目录名(macOS 截图、Excel 导出之类的常见命名)也能被 parseAllAtCommands 正确切分。

pomelo-nwu
pomelo-nwu previously approved these changes May 27, 2026
wenshao
wenshao previously approved these changes May 27, 2026
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Thanks @wenshao for the thorough review and detailed testing reports! Both tmux verification reports are very helpful. I appreciate the time you put into verifying all the edge cases.


/**
* Extract file paths from a whitespace-separated segment.
* Tries shortest possible path first to handle paths with spaces.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Stale JSDoc: says "Tries shortest possible path first" but the implementation at line 2037 iterates for (let j = tokens.length; j >= i + 1; j--) — longest-match-first. The inline comment three lines below correctly says "Try longest-match-first". A future reader relying on this JSDoc will misunderstand the algorithm and may "fix" the code to match the docstring, silently breaking paths with spaces.

Suggested change
* Tries shortest possible path first to handle paths with spaces.
* Tries longest possible path first (greedy) so paths with spaces are matched before shorter prefixes.

— qwen3.7-max via Qwen Code /review

unquoted.startsWith('~/') ||
unquoted.startsWith('.') ||
/^[A-Za-z]:/.test(unquoted) ||
unquoted.includes('/')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] unquoted.includes('/') is overly broad as a path pre-filter — it matches URLs (https://example.com/foo), dates (2026/05/27), MIME types (text/plain), and git diff lines (+++ b/src/foo.ts). These all pass through to the greedy matching loop where each candidate triggers synchronous fs.existsSync + fs.statSync calls.

The preceding checks (startsWith('/'), ./, ../, ~/, drive letter) already cover all standard path formats. The new test for relative paths only covers ./, ../, and ~/ prefixed paths — no test exercises bare src/foo.ts paths (without prefix), which was presumably the reason for adding this catch-all.

Consider removing unquoted.includes('/'). If bare relative paths like src/foo.ts need support, add a test for them and use a more targeted check (e.g., require a known file extension).

— qwen3.7-max via Qwen Code /review

*/
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] vscode-ide-companion has its own copy of SHELL_SPECIAL_CHARS at packages/vscode-ide-companion/src/utils/imageSupport.ts:31 that does NOT include ,:

// imageSupport.ts:31 (unchanged by this PR):
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;

// paths.ts:47 (this PR):
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/;

The two packages' escape/unescape behavior now diverges — core's escapePath escapes commas, vscode's does not. If the vscode extension generates @-paths consumed by core's parseAllAtCommands, paths with commas will be truncated.

Consider syncing the vscode copy, or better: eliminating the duplication by importing from core.

— qwen3.7-max via Qwen Code /review

result.current.insert("'/path/to/report,v2.txt'", { paste: true }),
);
// Comma should be escaped so parseAllAtCommands doesn't truncate
expect(getBufferState(result).text).toBe('@/path/to/report\\,v2.txt ');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage gap for shell metacharacters: only spaces and commas are tested. Real-world filenames commonly contain parentheses (file(1).txt, macOS downloads), semicolons (report;v2.txt), or brackets (data[2024].csv). If escapePath fails to escape any of these, parseAllAtCommands (which terminates at /[,\s;!?()[\]{}]/) would silently truncate the @-path with no user-visible error.

Also: the single-file paste path (line 2185) now goes through escapePath — the old code did NOT escape, so this silently fixes a pre-existing truncation bug for paths like /Downloads/report(v2).txt. No test validates this fix.

Suggest adding at least one test:

it('should escape shell metacharacters in paths', () => {
  const { result } = renderHook(() =>
    useTextBuffer({
      viewport,
      isValidPath: (p: string) => p === '/Downloads/report(v2).txt',
    }),
  );
  act(() => result.current.insert("'/Downloads/report(v2).txt'", { paste: true }));
  expect(getBufferState(result).text).toBe('@/Downloads/report\\(v2\\).txt ');
});

— qwen3.7-max via Qwen Code /review

const tokens = segment.split(/\s+/).filter(Boolean);
const paths: string[] = [];
let i = 0;
while (i < tokens.length) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing short-circuit when hadNonPathToken is already set. Once any token is flagged as a non-path, the all-or-nothing check at the end of tryExtractFilePaths guarantees the result will be null — but both loops continue processing all remaining tokens, making unnecessary isValidPath (fs) calls.

Suggested change
while (i < tokens.length) {
while (i < tokens.length) {
if (hadNonPathToken.value) break;

And in the outer tryExtractFilePaths line loop:

for (const line of lines) {
  if (hadNonPathToken.value) break;
  // ... existing logic
}

This eliminates wasted fs calls when the paste contains mixed path/non-path content.

— qwen3.7-max via Qwen Code /review


/**
* Escape spaces and commas so that downstream @-path parsing doesn't truncate.
* parseAllAtCommands terminates at unescaped whitespace and commas.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] escapePathAndCommas is a trivial pass-through to escapePath — the comma escaping only works because , was added to the global SHELL_SPECIAL_CHARS in this PR. Two follow-ups:

  1. Missing test in paths.test.ts: the escapePath describe block (line 68) has no test for comma escaping. The special-char test string at line 189 omits ,. A future change could revert the comma from SHELL_SPECIAL_CHARS and no core-level test would catch it. Add: expect(escapePath('file,v2.txt')).toBe('file\\,v2.txt');

  2. Consider either inlining escapePath(path) at call sites (removing the misleading wrapper name) or making the comma logic explicit here: return escapePath(path).replace(/,/g, '\\,') — this decouples from the global regex change.

— qwen3.7-max via Qwen Code /review

- Fix stale JSDoc: change 'shortest' to 'longest' match-first
- Remove overly broad includes('/') from looksLikePath
- Sync SHELL_SPECIAL_CHARS in vscode-ide-companion (add comma)
- Add tests for shell metacharacters (parentheses, brackets, semicolons)
- Add short-circuit when hadNonPathToken is set
- Inline escapePathAndCommas (now redundant with comma in SHELL_SPECIAL_CHARS)
let lastIndex = 0;
let match;
let hasQuotedPaths = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] quotedPathRegex = /'([~/.][^']*)'/g does not match Windows drive-letter paths. The character class [~/.] requires the quoted content to start with ~, /, or ., so paths like 'C:/Users/My Documents/file.txt' are never matched by this regex. They fall through to extractPathsFromSegment, which splits by whitespace — 'C:/Users/My fails looksLikePath (quote-strip requires both quotes, drive-letter test sees ' not C), setting hadNonPathToken = true. The paste silently preserves the original text unchanged — a regression from the old code which handled 'C:/Users/My Documents/file.txt' via potentialPath.match(/^'(.*)'$/).

Suggested change
const quotedPathRegex = /'((?:[~/.]|[A-Za-z]:)[^']*)'/g;

Also add a test for quoted Windows paths with spaces, and for the drive-letter branch of looksLikePath (/^[A-Za-z]:/) which currently has zero test coverage.

— qwen3.7-max via Qwen Code /review

function looksLikePath(str: string): boolean {
// Strip surrounding quotes first to handle quoted paths
const unquoted = str.replace(/^'(.*)'$/, '$1');
return (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] looksLikePath drops bare filenames — behavioral regression for single-file paste. The old code on main called isValidPath(unescapePath(potentialPath)) directly on any pasted text ≥ 3 chars with no prefix check. The new code routes through tryExtractFilePathsextractPathsFromSegmentlooksLikePath, which rejects tokens not starting with /, ./, ../, ~/, ., or a drive letter. A paste of README.md (when it exists as a file in CWD) previously produced @README.md ; it now produces README.md unchanged.

Suggested change
return (
// Pre-filter: skip tokens that can't possibly be paths.
// For single-token segments, let isValidPath decide (preserves
// old behavior for bare filenames like README.md).
if (tokens.length > 1 && !looksLikePath(tokens[i])) {

— qwen3.7-max via Qwen Code /review

function looksLikePath(str: string): boolean {
// Strip surrounding quotes first to handle quoted paths
const unquoted = str.replace(/^'(.*)'$/, '$1');
return (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] looksLikePath silently rejects quoted Windows drive-letter paths with spaces (e.g., 'C:\Users\my file.txt').

When extractPathsFromSegment splits by whitespace, the first token becomes 'C:\Users\my (with leading '). looksLikePath strips quotes via str.replace(/^'(.*)'$/, '$1') which requires both quotes — so the leading ' survives. Then ^[A-Za-z]: fails because the first char is ', not a letter. looksLikePath returns falsehadNonPathToken = true → entire paste preserved unchanged.

The greedy loop (line 2033) would correctly reconstruct the full quoted string and strip quotes — but it is unreachable because looksLikePath rejects the first token before the loop is entered.

Regression: the old code stripped quotes from the whole string before validating, so 'C:\Users\my file.txt' worked correctly.

Suggested change
return (
function looksLikePath(str: string): boolean {
// Strip surrounding quotes first to handle quoted paths
const unquoted = str.replace(/^'(.*)'$/, '$1');
// Also handle tokens that are the start of a quoted path split by whitespace
const inner = unquoted.startsWith("'") ? unquoted.slice(1) : unquoted;
return (
inner.startsWith('/') ||
inner.startsWith('./') ||
inner.startsWith('../') ||
inner.startsWith('~/') ||
inner.startsWith('.') ||
/^[A-Za-z]:/.test(inner)
);
}

— qwen3.7-max via Qwen Code /review

expect(getBufferState(result).text).toBe('@/report\\;v2.txt ');
});

it('should handle relative paths like ./src/index.ts', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing test for Windows drive-letter paths. looksLikePath has an explicit /^[A-Za-z]:/ branch (line 1919) but no test exercises it. All 16+ new tests use Unix-style paths (/, ./, ../, ~/). A regression in the drive-letter branch would go undetected.

it('should handle Windows drive-letter paths', () => {
  const { result } = renderHook(() =>
    useTextBuffer({
      viewport,
      isValidPath: (p: string) =>
        p === 'C:\\Users\\file.txt' || p === 'D:\\data\\report.csv',
    }),
  );
  act(() =>
    result.current.insert('C:\\Users\\file.txt D:\\data\\report.csv', {
      paste: true,
    }),
  );
  expect(getBufferState(result).text).toBe(
    '@C:\\Users\\file.txt @D:\\data\\report.csv ',
  );
});

— qwen3.7-max via Qwen Code /review

* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
*/
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The exhaustive escapePath test (paths.test.ts:188, "should handle paths with only special characters") does not include , in its input or expected output. Comma has zero unit-test coverage in escapePath/unescapePath despite being added to SHELL_SPECIAL_CHARS. The text-buffer integration test (comma escaping) covers it indirectly, but if escapePath itself regresses on comma, only that one integration test would catch it.

Update paths.test.ts:188 to include comma:

expect(escapePath(' ()[]{};&|*?$`\'"#!~<>,')).toBe(
  '\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>\\,',
);

— qwen3.7-max via Qwen Code /review

- Fix quotedPathRegex to support Windows drive-letter paths
- Fix looksLikePath to handle quoted Windows paths split by whitespace
- Allow bare filenames for single-token segments
- Add tests for Windows drive-letter and quoted Windows paths
- Add comma to paths.test.ts escapePath test
@MikeWang0316tw MikeWang0316tw requested a review from wenshao May 27, 2026 04:30
// --- Path extraction helpers (pure functions, outside useTextBuffer) ---

/**
* Check if a string looks like a path prefix (starts with /, ./, ~/, or drive letter).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] JSDoc summary is incomplete — lists only /, ./, ~/, and drive letter, but the implementation also checks ../ (line 1919) and bare ./.. (line 1921).

Suggested change
* Check if a string looks like a path prefix (starts with /, ./, ~/, or drive letter).
* Check if a string looks like a path prefix (starts with /, ./, ../, ~/, ., .., or a drive letter).

— qwen3.7-max via Qwen Code /review

* Handles quoted paths, unquoted paths, whitespace-separated, and newline-separated.
* Supports file paths with spaces using greedy matching.
* IMPORTANT: Escapes spaces and commas in paths with backslash so that the
* downstream `parseAllAtCommands` parser (which terminates at unescaped

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] JSDoc says "Escapes spaces and commas" but escapePath actually escapes all SHELL_SPECIAL_CHARS (22 characters including parentheses, brackets, semicolons, pipes, etc.). The tests at lines 768-797 verify parentheses and brackets are escaped, contradicting this doc.

Suggested change
* downstream `parseAllAtCommands` parser (which terminates at unescaped
* IMPORTANT: Escapes shell-special characters (spaces, commas, parentheses,
* brackets, semicolons, etc.) with backslash so that the downstream
* `parseAllAtCommands` parser correctly includes the entire path.

— qwen3.7-max via Qwen Code /review

// Pre-filter: skip tokens that can't possibly be paths.
// For single-token segments, let isValidPath decide (preserves
// old behavior for bare filenames like README.md).
if (tokens.length > 1 && !looksLikePath(tokens[i])) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The tokens.length > 1 guard intentionally bypasses looksLikePath for single-token segments to preserve bare-filename behavior (e.g., pasting README.md). The comment documents this intent, but no test exercises it. A regression that removes this guard would silently break bare-filename paste with no test failure.

Consider adding:

it('should prepend @ to a bare filename when isValidPath returns true', () => {
  const { result } = renderHook(() =>
    useTextBuffer({ viewport, isValidPath: (p) => p === 'README.md' }),
  );
  act(() => result.current.insert('README.md', { paste: true }));
  expect(getBufferState(result).text).toBe('@README.md ');
});

— qwen3.7-max via Qwen Code /review

- Fix looksLikePath JSDoc to include ../ and ..
- Fix tryExtractFilePaths JSDoc to reflect all shell-special chars
- Add test for bare filename (README.md) single-token segment
@MikeWang0316tw MikeWang0316tw requested a review from wenshao May 27, 2026 05:18
@wenshao

wenshao commented May 27, 2026

Copy link
Copy Markdown
Collaborator

本地真实测试报告(追加,针对最新 commit 7f068731 — merge 参考)

接续我前两份 tmux 真实 CLI 验证(commit 04d73efb)与 第二轮(commit 160ecf08)。这一轮覆盖在那之后又新加的两个 commit:

  • 58a85887 — Windows drive-letter / quoted Windows paths / 允许 bare filename / 补 shell-meta 字符相关测试
  • 7f068731 — JSDoc 修订 + bare filename (README.md) 单 token 测试

CI 已全绿(Lint / ubuntu / macos / windows / CodeQL 全 SUCCESS)。本地真实测试 24/24 端到端通过,可以合并。

1. 本地静态检查与单测

检查 结果
npm ci OK
npm run build (full repo) OK, packages/cli/dist/index.js 正常生成
npx tsc --noEmit -p packages/cli clean
npx tsc --noEmit -p packages/core clean
npx eslint <5 touched files> clean (0 problems)
vitest run packages/cli/src/ui/components/shared/text-buffer.test.ts 152/152 pass (较 PR body 138 多了 14 个新增 case)
vitest run packages/core/src/utils/paths.test.ts 115/115 pass + 1 skipped(含逗号 escape 校验)
vitest run packages/cli/src/ui/components/shared/ (regression) 337/337 pass + 1 skipped 跨 10 个文件,无回归

152/152 = 138(初稿)+ 6(critical/suggestion 第一轮)+ 3(greedy / relative / 非法路径)+ 5(Windows / parens / brackets / 分号 / bare README)

2. tmux 真实 paste 端到端 — 24/24 通过

环境:Linux x86_64 (Debian 13),Node v22.22.2,tmux 3.5a。
驱动方式:tmux load-buffer 灌进 paste buffer,tmux paste-buffer -p 用 bracketed paste(ESC[200~ … ESC[201~,即终端 emit 的真实粘贴序列)送进 node packages/cli/dist/index.js。每个 case 之间用 Esc + C-u × 8 + BSpace × 80 把输入框清空,避免串扰。

测试目录:/tmp/pr4544-rerun/ 下有 file1/2/3.txtREADME.mdspace file.txtwith comma,name/file.txtdir(parens)/x.txtdir[brackets]/y.txtdir;semi/z.txt

多文件 paste 基础矩阵(沿用 PR body Before/After 表)

# Case Payload 输入框实际显示
T01 单 path(regression) /tmp/.../file1.txt @/tmp/.../file1.txt
T02 3 paths whitespace-sep …/file1.txt …/file2.txt …/file3.txt @…/file1.txt @…/file2.txt @…/file3.txt
T03 3 paths newline-sep …/file1.txt\n…/file2.txt\n…/file3.txt @…/file1.txt @…/file2.txt @…/file3.txt
T04 3 quoted paths(drag-drop) '…/file1.txt' '…/file2.txt' '…/file3.txt' @…/file1.txt @…/file2.txt @…/file3.txt
T05 mixed quoted/unquoted '…/file1.txt' …/file2.txt '…/file3.txt' @…/file1.txt @…/file2.txt @…/file3.txt
T08 quoted + newline-sep '…/f1' \n '…/f2' \n '…/f3' @…/file1 @…/file2 @…/file3

Silent-data-loss 防御(关键 invariant)

# Case Payload 实际显示 期望
T09 纯 prose please review my code please review my code 不加 @
T10 prose 夹一个 path Please summarize /tmp/.../file1.txt for me 原文照旧 整段保留 ✅
T11 有效 + 无效 path 混合 …/file1.txt …/nonexistent.txt 原文照旧 任一非 path → 整段保留 ✅
T12 多行 prose(无 path) line one\nline two of prose 原文照旧(保留换行) 落入 newline fallback ✅
T13 极短 paste hi hi < 3 字符短路守卫 ✅
T19 含 contraction (don't) I don't think this needs the @ prefix 原文照旧 quotedPathRegex 不误匹配 ✅
T16 已经 @-prefixed @/tmp/.../file1.txt @/tmp/.../file1.txt 无双前缀 ✅

Shell 转义(escapePath 集成验证)

# Case Payload 实际显示
T06 quoted 含空格 '…/space file.txt' @…/space\ file.txt
T07 quoted 含空格 + 逗号 '…/with comma,name/file.txt' @…/with\ comma\,name/file.txt
T20 含括号 '…/dir(parens)/x.txt' @…/dir\(parens\)/x.txt
T21 含方括号 '…/dir[brackets]/y.txt' @…/dir\[brackets\]/y.txt
T22 含分号 '…/dir;semi/z.txt' @…/dir\;semi/z.txt
T23 三 quoted 混含空格+逗号 '…/f1' '…/with comma,name/file.txt' '…/f2' @…/f1 @…/with\ comma\,name/file.txt @…/f2
T24 unquoted 含空格(greedy) /tmp/.../space file.txt @/tmp/.../space\ file.txt

T24 直接验证了 extractPathsFromSegment 的 longest-match-first:单纯按空格切会得到 /tmp/.../space + file.txt 两个非法 token,但贪婪先试 /tmp/.../space file.txt,匹中后整段当作单 path 处理。

新 commit 关注点(58a85887 + 7f068731

# Case Payload 实际显示
T14 bare filename 单 token README.md @README.md ✅ — tokens.length > 1 pre-filter 跳过,保留旧行为
T17 dotfile 形态 .env 不存在 .env .env ✅ — looksLikePath ok 但 isValidPath 失败 → silent-loss 守卫保留原文
T18 relative ./path/in/cwd ./packages/cli/dist/index.js @./packages/cli/dist/index.js ✅ — relative 路径支持生效
T15 Windows drive-letter on Linux C:\Users\file.txt D:\data\report.csv C:\Users\file.txt D:\data\report.csv ✅ — looksLikePath 认它像 path,但 Linux fs 上无效 → silent-loss 守卫保留

T15 验证了在 Linux 上 C:\... 形态的 paste 不会被错误地转换或丢失,恰恰是因为 silent-loss invariant 起作用——Windows 上同一个 payload 才会触发 @C:\Users\file.txt @D:\data\report.csv(unit test should handle Windows drive-letter paths 覆盖)。

3. 与 PR body Before/After 表对账

PR body Before/After 表 4 行全部在真 CLI 上复现到 After 状态:

Action After(PR 声称) tmux 实测 一致
Drag & drop 1 file → @file unchanged T01 ✅
Drag & drop 3 files → @file1 @file2 @file3 fixed T04 ✅
Paste 1 file → @file unchanged T01 ✅
Paste 3 files → @file1 @file2 @file3 fixed T02 (whitespace) / T03 (newline) ✅

4. tmux paste 摘录(按场景节选)

=== T02 3 paths whitespace-sep ===
rendered: > @/tmp/pr4544-rerun/file1.txt @/tmp/pr4544-rerun/file2.txt @/tmp/pr4544-rerun/file3.txt

=== T03 3 paths newline-sep ===
rendered: > @/tmp/pr4544-rerun/file1.txt @/tmp/pr4544-rerun/file2.txt @/tmp/pr4544-rerun/file3.txt

=== T07 path with comma+space ===
payload : '/tmp/pr4544-rerun/with comma,name/file.txt'
rendered: > @/tmp/pr4544-rerun/with\ comma\,name/file.txt

=== T10 prose mixed with path (must NOT @-prefix) ===
payload : Please summarize /tmp/pr4544-rerun/file1.txt for me
rendered: > Please summarize /tmp/pr4544-rerun/file1.txt for me

=== T14 bare README (single-token) ===
payload : README.md
rendered: > @README.md

=== T15 Windows path on Linux (preserved) ===
payload : C:\Users\file.txt D:\data\report.csv
rendered: > C:\Users\file.txt D:\data\report.csv

=== T20 path with parentheses ===
payload : '/tmp/pr4544-rerun/dir(parens)/x.txt'
rendered: > @/tmp/pr4544-rerun/dir\(parens\)/x.txt

=== T24 unquoted path-with-space (greedy) ===
payload : /tmp/pr4544-rerun/space file.txt
rendered: > @/tmp/pr4544-rerun/space\ file.txt

5. 结论

  • ✅ 152/152 单测 + 115/115 paths 单测 + 337 broader shared tests 全绿,无回归
  • ✅ tsc / eslint 全 clean,CI 也全 SUCCESS
  • ✅ 24 个 tmux + bracketed paste 端到端场景全部对得上:多文件 paste 基础矩阵、silent-loss 守卫、shell-meta escape(空格/逗号/括号/方括号/分号)、bare filename、relative path、Windows path 在 Linux 上的安全降级,全部行为符合预期
  • ✅ PR body Before/After 表 4 行全部复现到 After 状态
  • tryExtractFilePaths 的关键 invariant —— "只有所有非空白 token 都是 valid path 才转换" —— 在 T10/T11/T15 三个不同形态的混入场景中都生效,没有 silent data loss 风险

LGTM for merge. 从最新 commit 7f068731 的本地真实测试角度看,这个 PR 已经经过两轮 wenshao review + 1 轮 pomelo-nwu review + 多轮 tmux 真实端到端验证,覆盖范围远超 PR body 所述。修复的 UX 不一致问题(多文件 paste/drag-drop 与单文件不一致)已彻底解决;新引入的 tryExtractFilePaths + silent-loss invariant 是非常保守的设计;SHELL_SPECIAL_CHARS 同步加 , 也已在 core 与 vscode-ide-companion 两边对齐。

@wenshao wenshao merged commit ce53283 into QwenLM:main May 27, 2026
10 checks passed
let match;
let hasQuotedPaths = false;

while ((match = quotedPathRegex.exec(line)) !== null) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The quotedPathRegex while-loop is missing the hadNonPathToken short-circuit check that was added to the outer for loop (line 1949) and extractPathsFromSegment (line 2021). When a gap between two quoted paths sets hadNonPathToken = true via extractPathsFromSegment, the while-loop continues calling isValidPath() on subsequent quoted matches — making unnecessary sync fs calls that will be discarded by the final !hadNonPathToken.value guard.

Suggested change
while ((match = quotedPathRegex.exec(line)) !== null) {
while ((match = quotedPathRegex.exec(line)) !== null) {
if (hadNonPathToken.value) break;
const gap = line.slice(lastIndex, match.index).trim();

— qwen3.7-max via Qwen Code /review

// Handle pastes that contain newlines (e.g., file paths separated by newlines).
// We need to process these before the newline check below, which would
// otherwise cause an early return and skip the @-path detection.
if (paste && /[\n\r]/.test(ch) && !shellModeActive) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No size guard before calling tryExtractFilePaths on newline-containing pastes. A large paste (e.g., a 100KB log snippet that happens to contain newlines) would run the full extraction pipeline (normalize + split + regex + greedy fs loop) on the UI thread, causing a visible freeze.

Consider adding an early size check:

Suggested change
if (paste && /[\n\r]/.test(ch) && !shellModeActive) {
if (paste && /[\n\r]/.test(ch) && !shellModeActive && ch.length <= 4096) {

— qwen3.7-max via Qwen Code /review

ch = `@${potentialPath} `;
const validPaths = tryExtractFilePaths(ch.trim(), isValidPath);
if (validPaths) {
ch = `${validPaths.join(' ')} `;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Single-line paste now routes through the full tryExtractFilePaths pipeline (CRLF normalize, split, filter, per-line regex exec, greedy token loop) instead of the previous O(1) direct check (trim → quote strip → isValidPath). For the most common case — pasting a single file path — this adds unnecessary allocations and sync fs overhead.

Consider a fast-path for single-token input:

        const trimmed = ch.trim();
        if (!/\s/.test(trimmed)) {
          let p = trimmed;
          const q = p.match(/^'(.*)'$/);
          if (q) p = q[1];
          const unescaped = unescapePath(p);
          if (isValidPath(unescaped)) {
            ch = `@${escapePath(unescaped)} `;
          }
        } else {
          const validPaths = tryExtractFilePaths(trimmed, isValidPath);
          if (validPaths) ch = `${validPaths.join(' ')} `;
        }

— qwen3.7-max via Qwen Code /review


// --- Path extraction helpers (pure functions, outside useTextBuffer) ---

/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Zero debugLogger calls in ~160 lines of new path-extraction code, while the rest of this file uses debugLogger extensively (14 call sites). When the all-or-nothing transformation silently falls back to raw paste, there is no diagnostic trail — no log of which token triggered hadNonPathToken, no count of tokens processed, no indication that tryExtractFilePaths was even called.

At minimum, add logging when:

  1. tryExtractFilePaths is entered (log token count)
  2. hadNonPathToken is set (log the failing token and reason — looksLikePath rejection vs isValidPath false)
  3. Paths are successfully extracted (log count)

— qwen3.7-max via Qwen Code /review

inner.startsWith('./') ||
inner.startsWith('../') ||
inner.startsWith('~/') ||
inner.startsWith('.') ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] inner.startsWith('.') is overly broad — it matches .5, .env, .bashrc, and any dot-prefixed string. In multi-token mode, these pass looksLikePath but fail isValidPath, setting hadNonPathToken and aborting the entire multi-file paste. For example, pasting /valid/path.txt .5 silently preserves the original content because .5 is treated as a failed path.

Also, inner.startsWith('./') and inner.startsWith('../') above are dead code — inner.startsWith('.') subsumes them via short-circuit OR.

Tighten to avoid false positives:

Suggested change
inner.startsWith('.') ||
return (
inner.startsWith('/') ||
inner.startsWith('./') ||
inner.startsWith('../') ||
inner.startsWith('~/') ||
inner === '.' ||
inner === '..' ||
/^[A-Za-z]:/.test(inner)
);

— qwen3.7-max via Qwen Code /review

function extractPathsFromSegment(
segment: string,
isValidPath: (p: string) => boolean,
hadNonPathToken: { value: boolean },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] split(/\s+/) collapses consecutive whitespace, then tokens.slice(i, j).join(' ') reconstructs with single spaces. A filename with consecutive spaces (e.g., my file.txt) pasted unquoted would be split into ["my", "file.txt"], joined as my file.txt (one space), and isValidPath called on the wrong string.

Quoted paths handle this correctly (the regex preserves inner content). For unquoted paths, this is a silent misreference edge case. Consider documenting this limitation in a code comment so future maintainers know quoted paths are the safe path for filenames with unusual whitespace.

— qwen3.7-max via Qwen Code /review

// Short-circuit: once any token is flagged as non-path, the result will be null
if (hadNonPathToken.value) break;

// Pre-filter: skip tokens that can't possibly be paths.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] looksLikePath rejects bare filenames like README.md or file.txt (no path prefix). In multi-token mode (tokens.length > 1), any such token sets hadNonPathToken, aborting the entire paste. But single-file paste of README.md works because the pre-filter is skipped when tokens.length === 1.

This means README.md alone gets @-prefixed, but README.md src/index.ts does not — both are valid files, with no feedback to the user about why the transformation was skipped.

Consider: when tokens.length is small (e.g., ≤ 10), still try isValidPath as a fallback before setting hadNonPathToken.

— qwen3.7-max via Qwen Code /review

const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => true }),
);
const filePaths =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The "trailing" branch in tryExtractFilePaths (content after the last quoted-path regex match, line 1979-1987) is never exercised by any test. All existing mixed quoted/unquoted tests place unquoted paths between quoted paths (the "gap" branch) or end with a quoted path. For example, this test's input '/path/to/file1.txt' /path/to/file2.txt '/path/to/file3.txt' ends with a quoted path, so trailing is always empty.

Consider adding a test with trailing unquoted content after the last quoted path:

it('should handle trailing unquoted paths after quoted paths', () => {
  const { result } = renderHook(() =>
    useTextBuffer({
      viewport,
      isValidPath: (p: string) =>
        p === '/path/to/a.txt' || p === '/path/to/b.txt',
    }),
  );
  act(() =>
    result.current.insert("'/path/to/a.txt' /path/to/b.txt", {
      paste: true,
    }),
  );
  expect(getBufferState(result).text).toBe(
    '@/path/to/a.txt @/path/to/b.txt ',
  );
});

— qwen3.7-max via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants