From 592984e51aa5d17a215b1b1b48ed4d4adbca0cc2 Mon Sep 17 00:00:00 2001
From: uhyeon <98dngus@gmail.com>
Date: Tue, 25 Aug 2026 13:35:04 +0900
Subject: [PATCH 1/5] fix(file-links): trim CJK particles after ASCII
extensions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Space-less languages glue particles onto paths (README.mdへ). Widen
path classes with \p{L} for mid-path CJK (#13396) and trim non-ASCII
letters after an ASCII extension so the particle is not part of the
link (#15240 symmetry).
---
mobile/src/components/MobileMarkdown.tsx | 5 +-
.../markdown-file-path-detection.test.ts | 26 +++++++
.../markdown-file-path-detection.ts | 19 +++--
.../terminal/terminal-path-tap-injected.ts | 24 +++++--
mobile/src/terminal/terminal-path-tap.test.ts | 21 ++++++
mobile/src/terminal/terminal-path-tap.ts | 23 ++++--
.../lib/terminal-bare-file-link-detection.ts | 6 +-
.../src/lib/terminal-links-redos.test.ts | 5 +-
src/renderer/src/lib/terminal-links.test.ts | 72 +++++++++++++++++++
src/renderer/src/lib/terminal-links.ts | 57 +++------------
.../terminal-spaced-path-trailing-prose.ts | 50 +++++++++++++
src/shared/file-link-trailing-prose.test.ts | 46 ++++++++++++
src/shared/file-link-trailing-prose.ts | 37 ++++++++++
13 files changed, 320 insertions(+), 71 deletions(-)
create mode 100644 src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
create mode 100644 src/shared/file-link-trailing-prose.test.ts
create mode 100644 src/shared/file-link-trailing-prose.ts
diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx
index cc88c01e5644..85059edbe0b3 100644
--- a/mobile/src/components/MobileMarkdown.tsx
+++ b/mobile/src/components/MobileMarkdown.tsx
@@ -7,6 +7,7 @@ import {
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
+import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
@@ -132,7 +133,9 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
onOpenFile(normalizeFilePath(code.trim()))}
+ onPress={() =>
+ onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiLetters(code.trim())))
+ }
>
{code}
diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts
index 19e608ec413c..c62512464a8d 100644
--- a/mobile/src/components/markdown-file-path-detection.test.ts
+++ b/mobile/src/components/markdown-file-path-detection.test.ts
@@ -210,6 +210,26 @@ describe('detectFilePathSegments', () => {
{ type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }
])
})
+
+ it('detects CJK directory segments and trims trailing particles', () => {
+ expect(detectFilePathSegments('열기 /Users/me/docs/한글폴더/파일.md로 완료')).toEqual([
+ { type: 'text', value: '열기 ' },
+ {
+ type: 'file',
+ value: '/Users/me/docs/한글폴더/파일.md',
+ path: '/Users/me/docs/한글폴더/파일.md'
+ },
+ { type: 'text', value: '로 완료' }
+ ])
+ })
+
+ it('detects ASCII separator paths with glued CJK particles in prose', () => {
+ expect(detectFilePathSegments('저장 경로는 plans/foo.md로 확정')).toEqual([
+ { type: 'text', value: '저장 경로는 ' },
+ { type: 'file', value: 'plans/foo.md', path: 'plans/foo.md' },
+ { type: 'text', value: '로 확정' }
+ ])
+ })
})
describe('isFilePathCodeSpan', () => {
@@ -256,6 +276,12 @@ describe('isFilePathCodeSpan', () => {
expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false)
expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false)
})
+
+ it('accepts code spans with CJK particles glued after the extension', () => {
+ expect(isFilePathCodeSpan('src/foo.ts로')).toBe(true)
+ expect(isFilePathCodeSpan('plans/foo.md에')).toBe(true)
+ expect(isFilePathCodeSpan('package.jsonです')).toBe(true)
+ })
})
describe('splitFilePathLineSuffix', () => {
diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts
index c51151b9ebf1..82c8cba7357e 100644
--- a/mobile/src/components/markdown-file-path-detection.ts
+++ b/mobile/src/components/markdown-file-path-detection.ts
@@ -4,6 +4,7 @@
// annoyance, but a false positive on prose or a version number is a broken tap.
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
+import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose'
export type FilePathSegment =
| { type: 'text'; value: string }
@@ -88,8 +89,11 @@ const EXTENSION_SET = new Set(FILE_EXTENSIONS)
// connected runtime, which may be Windows even when the phone is not. Leading
// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
// tail captures only bounded agent-style :line(:col) citations.
+// Why \p{L}\p{M}\p{N}: ASCII-only segments truncated CJK directory names. Keep
+// the extension ASCII so particles after `.md` stay outside the match; still
+// run trimFileLinkTrailingNonAsciiLetters for code-span / line:col edges.
const CANDIDATE_PATTERN =
- /(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g
+ /(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])*|(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])+)[\p{L}\p{M}\p{N}_.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![A-Za-z0-9_@%]))?/gu
// A path candidate in chat prose is short; a much longer run can't hold one worth
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
@@ -124,8 +128,9 @@ export function splitFilePathLineSuffix(pathText: string): {
}
function isOpenablePath(pathText: string): boolean {
- // A :line(:col) tail is part of the citation, not the file name.
- const { path: candidate } = splitFilePathLineSuffix(pathText)
+ // A :line(:col) tail is part of the citation, not the file name. Strip CJK
+ // particles glued after an ASCII extension before extension lookup.
+ const { path: candidate } = splitFilePathLineSuffix(trimFileLinkTrailingNonAsciiLetters(pathText))
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
@@ -178,12 +183,12 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
CANDIDATE_PATTERN.lastIndex = 0
while ((match = CANDIDATE_PATTERN.exec(text))) {
- const candidate = match[0]
+ const candidate = trimFileLinkTrailingNonAsciiLetters(match[0])
// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
// a preceding slash (the leading slash of an absolute path is part of the
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
const prev = match.index > 0 ? text[match.index - 1]! : ''
- if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) {
+ if (prev === ':' || prev === '/' || prev === '\\' || /[A-Za-z0-9_.@]/.test(prev)) {
continue
}
if (!isOpenablePath(candidate)) {
@@ -212,7 +217,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
* bare `file.ts` here even without a slash.
*/
export function isFilePathCodeSpan(code: string): boolean {
- const trimmed = code.trim()
+ const trimmed = trimFileLinkTrailingNonAsciiLetters(code.trim())
if (!trimmed || /\s/.test(trimmed)) {
return false
}
@@ -234,7 +239,7 @@ export function isFilePathCodeSpan(code: string): boolean {
}
const name = path.slice(0, dot)
const ext = path.slice(dot + 1).toLowerCase()
- if (/[^\w.@+-]/.test(name)) {
+ if (/[^\p{L}\p{M}\p{N}_.@+-]/u.test(name)) {
return false
}
return EXTENSION_SET.has(ext)
diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts
index 766c381ea22b..1529aa6ef772 100644
--- a/mobile/src/terminal/terminal-path-tap-injected.ts
+++ b/mobile/src/terminal/terminal-path-tap-injected.ts
@@ -14,10 +14,11 @@
// often print a bare filename (the markdown link target is consumed, leaving
// only the label text), so requiring a slash would miss the common case.
export const TERMINAL_PATH_TAP_JS = String.raw`
- var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
- var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
+ var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu;
+ var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/])[^()[\]{}'",;<>|\x60\r\n]+(?::\d+)?(?::\d+)?/gu;
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
+ var ASCII_EXT_THEN_NON_ASCII_LETTERS = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)((?:(?![A-Za-z])\p{L})+)$/u;
function parsePathLineCol(value) {
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
@@ -39,6 +40,12 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
}
+ function trimTrailingNonAsciiLetters(range) {
+ var match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(range.text);
+ if (!match) return range;
+ return { text: match[1], startIndex: range.startIndex, endIndex: range.startIndex + match[1].length };
+ }
+
function hasSeparatorAfterWhitespace(text) {
var sawWhitespace = false;
for (var i = 0; i < text.length; i++) {
@@ -53,7 +60,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
// A line-end extension token only extends the span when the added segment
// is path-like (contains a separator) — prose must not be swallowed.
var selected = null;
- var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
+ // Also stop before non-ASCII letters (CJK particles glued to the ext).
+ var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu;
var match;
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
var end = match.index + match[0].length;
@@ -91,8 +99,9 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
- var candidate = trimSpacedPathTrailingProse(trimmed, col);
- if (!candidate) continue;
+ var proseTrimmed = trimSpacedPathTrailingProse(trimmed, col);
+ if (!proseTrimmed) continue;
+ var candidate = trimTrailingNonAsciiLetters(proseTrimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
@@ -110,8 +119,9 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
if (!trimmed) continue;
- if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
- var parsed = parsePathLineCol(trimmed.text);
+ var candidate = trimTrailingNonAsciiLetters(trimmed);
+ if (col < candidate.startIndex || col >= candidate.endIndex) continue;
+ var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
}
return null;
diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts
index ebb8e0b9ec3b..6025820f44dc 100644
--- a/mobile/src/terminal/terminal-path-tap.test.ts
+++ b/mobile/src/terminal/terminal-path-tap.test.ts
@@ -164,6 +164,27 @@ describe('matchFilePathAtColumn', () => {
const line = '• Here you go: README.md'
expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull()
})
+
+ it('trims CJK particles from bare filenames under the tap', () => {
+ const line = 'See README.md로 for details'
+ expect(matchFilePathAtColumn(line, colOf(line, 'README'))).toEqual({
+ pathText: 'README.md',
+ line: null,
+ column: null
+ })
+ expect(matchFilePathAtColumn(line, colOf(line, '로'))).toBeNull()
+ })
+
+ it('matches non-Latin separator paths and trims trailing particles', () => {
+ const path = '/Users/me/docs/한글폴더/파일.md'
+ const line = `${path}로 열었습니다`
+ expect(matchFilePathAtColumn(line, colOf(line, '파일'))).toEqual({
+ pathText: path,
+ line: null,
+ column: null
+ })
+ expect(matchFilePathAtColumn(line, colOf(line, '로'))).toBeNull()
+ })
})
describe('injected matchFilePathAtColumn', () => {
diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts
index 8e7545a4cfb4..fccbf323f060 100644
--- a/mobile/src/terminal/terminal-path-tap.ts
+++ b/mobile/src/terminal/terminal-path-tap.ts
@@ -7,6 +7,7 @@ import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
+import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose'
export type TappedFilePath = ParsedFileLinkLocation
@@ -15,10 +16,13 @@ export type TappedFilePath = ParsedFileLinkLocation
// with :line or :line:col. Like desktop, we propose candidates and let the host
// existence-check reject non-files — agents often print a bare filename, so
// requiring a slash would miss the common case.
+// Why \p{L}\p{M}\p{N}: ASCII-only classes truncated CJK path segments (#13396).
+// Pair with trimFileLinkRangeTrailingNonAsciiLetters so glued particles after
+// an extension are not part of the tap target.
const LOCAL_PATH_REGEX =
- /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
+ /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu
const SPACED_PATH_REGEX =
- /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
+ /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/gu
const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
@@ -82,7 +86,10 @@ function trimSpacedPathTrailingProse(
// segment is path-like (contains a separator) — "v1.2 reports/result.json"
// extends, prose like "failed to start app.py" must not be swallowed.
let selected: string | null = null
- const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
+ // Why: also stop before non-ASCII letters so `…/파일.md로 열었습니다` keeps
+ // only the path after `\p{L}` widening (space-only trim leaves the particle).
+ const extensionPrefixPattern =
+ /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu
let match: RegExpExecArray | null
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
const end = match.index + match[0].length
@@ -148,10 +155,11 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP
) {
continue
}
- const candidate = trimSpacedPathTrailingProse(trimmed, col)
- if (!candidate) {
+ const proseTrimmed = trimSpacedPathTrailingProse(trimmed, col)
+ if (!proseTrimmed) {
continue
}
+ const candidate = trimFileLinkRangeTrailingNonAsciiLetters(proseTrimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
@@ -181,10 +189,11 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
if (!trimmed) {
continue
}
- if (col < trimmed.startIndex || col >= trimmed.endIndex) {
+ const candidate = trimFileLinkRangeTrailingNonAsciiLetters(trimmed)
+ if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
- const parsed = parsePathWithOptionalLineColumn(trimmed.text)
+ const parsed = parsePathWithOptionalLineColumn(candidate.text)
if (parsed) {
return parsed
}
diff --git a/src/renderer/src/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
index 364ebaffd6cd..31a4d351634c 100644
--- a/src/renderer/src/lib/terminal-bare-file-link-detection.ts
+++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
@@ -1,3 +1,4 @@
+import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose'
import {
detectTerminalFileLinkRanges,
terminalFileLinkRangesOverlap,
@@ -58,7 +59,10 @@ export function detectBareFilenameLinks(
if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) {
continue
}
- const link = toParsedTerminalFileLink(range)
+ // Why: space-less languages glue particles onto bare filenames
+ // (`README.md로`); trim before the ASCII filename check or the token
+ // fails looksLikeFilename and never becomes a link.
+ const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiLetters(range))
if (!link || !looksLikeFilename(link.pathText)) {
continue
}
diff --git a/src/renderer/src/lib/terminal-links-redos.test.ts b/src/renderer/src/lib/terminal-links-redos.test.ts
index 2de66b55d871..9f3312e104ba 100644
--- a/src/renderer/src/lib/terminal-links-redos.test.ts
+++ b/src/renderer/src/lib/terminal-links-redos.test.ts
@@ -9,7 +9,10 @@ import { extractTerminalFileLinks, extractTerminalFileLinkCandidates } from './t
describe('terminal-links ReDoS guard (#5970)', () => {
const cases: [string, string][] = [
['separator + space padding', `a/${' '.repeat(30_000)}`],
- ['advertised url + space padding', `Web Interface http://127.0.0.1:4040${' '.repeat(30_000)}`]
+ ['advertised url + space padding', `Web Interface http://127.0.0.1:4040${' '.repeat(30_000)}`],
+ // The local-path class accepts Unicode letters/marks; keep that scan linear too.
+ ['non-latin separator + space padding', `한/${' '.repeat(30_000)}`],
+ ['non-latin run + space padding', `${'한글'.repeat(15_000)}/${' '.repeat(30_000)}`]
]
for (const [name, line] of cases) {
diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts
index 66ff8d870a8b..d2bc8218fb06 100644
--- a/src/renderer/src/lib/terminal-links.test.ts
+++ b/src/renderer/src/lib/terminal-links.test.ts
@@ -112,6 +112,17 @@ describe('terminal path helpers', () => {
expect(performance.now() - startedAt).toBeLessThan(100)
expect(links.map((link) => link.displayText)).toEqual(['package.json'])
})
+
+ it.each([
+ ['README.md로', 'README.md'],
+ ['AGENTS.md에', 'AGENTS.md'],
+ ['file.tsです', 'file.ts']
+ ])('trims CJK/JP particles from bare filenames: %s', (token, pathText) => {
+ const links = extractTerminalFileLinks(token)
+ expect(links).toHaveLength(1)
+ expect(links[0]).toMatchObject({ pathText, displayText: pathText })
+ expect(token.slice(links[0].startIndex, links[0].endIndex)).toBe(pathText)
+ })
})
describe('extractTerminalFileLinks local path tokens', () => {
@@ -215,6 +226,67 @@ describe('terminal path helpers', () => {
expect(links).toHaveLength(20_000)
expect(links[0].pathText).toBe('/tmp/Foo Bar/file')
}, 5_000)
+
+ // An ASCII-only character class truncated these at the first non-Latin
+ // character, so only the ASCII prefix was linkified and the filename sat
+ // outside every link range (#13396).
+ it.each([
+ ['Korean', '/Users/me/docs/한글폴더/파일.md'],
+ ['Japanese', '/Users/me/docs/日本語/ファイル.md'],
+ ['Chinese', '/Users/me/docs/中文目录/文件.md'],
+ ['Cyrillic', '/Users/me/docs/Документы/файл.md'],
+ ['Greek', '/Users/me/docs/έγγραφα/αρχείο.md']
+ ])('detects unspaced %s paths end to end', (_script, path) => {
+ const links = extractTerminalFileLinks(path)
+ expect(links).toHaveLength(1)
+ expect(links[0]).toMatchObject({ pathText: path, displayText: path })
+ })
+
+ it('detects relative paths that start with a non-Latin segment', () => {
+ const links = extractTerminalFileLinks('한글폴더/파일.md')
+ expect(links).toHaveLength(1)
+ expect(links[0]).toMatchObject({ pathText: '한글폴더/파일.md' })
+ })
+
+ it('keeps line and column suffixes on non-Latin paths', () => {
+ const links = extractTerminalFileLinks('/Users/me/docs/한글폴더/파일.ts:42:7')
+ expect(links).toHaveLength(1)
+ expect(links[0]).toMatchObject({
+ pathText: '/Users/me/docs/한글폴더/파일.ts',
+ line: 42,
+ column: 7
+ })
+ })
+
+ // macOS stores many names decomposed, while terminal output is composed.
+ it('detects NFD-decomposed non-Latin paths', () => {
+ const path = `/Users/me/docs/${'한글폴더'.normalize('NFD')}/${'파일.md'.normalize('NFD')}`
+ const links = extractTerminalFileLinks(path)
+ expect(links).toHaveLength(1)
+ expect(links[0]).toMatchObject({ pathText: path })
+ })
+
+ it('trims particles glued after a separator path extension', () => {
+ const line = '저장 경로는 plans/foo.md로 확정했습니다.'
+ const links = extractTerminalFileLinks(line)
+ expect(links.map((link) => link.pathText)).toContain('plans/foo.md')
+ const link = links.find((entry) => entry.pathText === 'plans/foo.md')
+ expect(link).toBeDefined()
+ expect(line.slice(link!.startIndex, link!.endIndex)).toBe('plans/foo.md')
+ })
+
+ it('trims particles after a non-Latin path while keeping the CJK segments', () => {
+ const path = '/Users/me/docs/한글폴더/파일.md'
+ const line = `${path}로 열었습니다`
+ const links = extractTerminalFileLinks(line)
+ expect(links).toHaveLength(1)
+ expect(links[0]).toMatchObject({ pathText: path, displayText: path })
+ })
+
+ it('does not trim Latin tails after an extension', () => {
+ const links = extractTerminalFileLinks('file.mdbackup')
+ expect(links.map((link) => link.pathText)).toEqual(['file.mdbackup'])
+ })
})
it('supports Windows cwd resolution for terminal file links', () => {
diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts
index 729f2d839e70..4120697130ce 100644
--- a/src/renderer/src/lib/terminal-links.ts
+++ b/src/renderer/src/lib/terminal-links.ts
@@ -1,3 +1,4 @@
+import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose'
import { normalizeAbsolutePath } from './terminal-path-normalization'
import { resolveExplicitFileLinkTarget } from './explicit-file-link-target'
import { detectBareFilenameLinks } from './terminal-bare-file-link-detection'
@@ -10,6 +11,7 @@ import {
type DetectedTerminalFileLinkRange
} from './terminal-file-link-detection-ranges'
import { detectTerminalFileUriLinks } from './terminal-file-uri-link'
+import { trimSpacedPathTrailingProse } from './terminal-spaced-path-trailing-prose'
export type ParsedTerminalFileLink = {
pathText: string
@@ -33,8 +35,11 @@ export type ResolvedTerminalFileLink = Pick 1) {
- continue
- }
- if (
- end < range.text.length ||
- selected === null ||
- /[\\/]/.test(range.text.slice(selected.length, end))
- ) {
- selected = text
- }
- }
- if (!selected) {
- return range
- }
- return {
- text: selected,
- startIndex: range.startIndex,
- endIndex: range.startIndex + selected.length
- }
-}
-
-function countPathStarts(text: string): number {
- let count = 0
- for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
- void match
- count += 1
- }
- return count
-}
-
function trimTrailingWhitespace(
range: DetectedTerminalFileLinkRange
): DetectedTerminalFileLinkRange {
@@ -217,7 +178,7 @@ function detectLocalPathLinks(
if (!/[\\/]/.test(range.text)) {
continue
}
- const link = toParsedTerminalFileLink(range)
+ const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiLetters(range))
if (link) {
links.push(link)
}
@@ -259,7 +220,9 @@ function detectSpacedLocalPathLinks(
const candidateLinks = candidateRanges
.map((candidateRange) =>
toParsedTerminalFileLink(
- trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))
+ trimFileLinkRangeTrailingNonAsciiLetters(
+ trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))
+ )
)
)
.filter((link): link is ParsedTerminalFileLink => link !== null)
diff --git a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
new file mode 100644
index 000000000000..e79127d18cde
--- /dev/null
+++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
@@ -0,0 +1,50 @@
+import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detection-ranges'
+
+// Why: keep one extension-terminated path, but drop trailing prose or a second
+// unrelated path that the broad spaced-path scan also captured. A line-end
+// extension token only extends the span when the added segment is path-like
+// (contains a separator) — "v1.2 reports/result.json" extends, prose like
+// "failed to start app.py" must not be swallowed.
+// Also stop before non-ASCII letters so `…/파일.md로 열었습니다` keeps only
+// the path after `\p{L}` widening (space-only trim leaves the particle).
+const EXTENSION_PREFIX_PATTERN =
+ /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu
+
+function countPathStarts(text: string): number {
+ let count = 0
+ for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
+ void match
+ count += 1
+ }
+ return count
+}
+
+export function trimSpacedPathTrailingProse(
+ range: DetectedTerminalFileLinkRange
+): DetectedTerminalFileLinkRange {
+ let selected: string | null = null
+ EXTENSION_PREFIX_PATTERN.lastIndex = 0
+ let match: RegExpExecArray | null
+ while ((match = EXTENSION_PREFIX_PATTERN.exec(range.text)) !== null) {
+ const end = match.index + match[0].length
+ const text = range.text.slice(0, end)
+ if (countPathStarts(text) > 1) {
+ continue
+ }
+ if (
+ end < range.text.length ||
+ selected === null ||
+ /[\\/]/.test(range.text.slice(selected.length, end))
+ ) {
+ selected = text
+ }
+ }
+ if (!selected) {
+ return range
+ }
+ return {
+ text: selected,
+ startIndex: range.startIndex,
+ endIndex: range.startIndex + selected.length
+ }
+}
diff --git a/src/shared/file-link-trailing-prose.test.ts b/src/shared/file-link-trailing-prose.test.ts
new file mode 100644
index 000000000000..0928a997d830
--- /dev/null
+++ b/src/shared/file-link-trailing-prose.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import {
+ trimFileLinkRangeTrailingNonAsciiLetters,
+ trimFileLinkTrailingNonAsciiLetters
+} from './file-link-trailing-prose'
+
+describe('trimFileLinkTrailingNonAsciiLetters', () => {
+ it.each([
+ ['README.md로', 'README.md'],
+ ['AGENTS.md에', 'AGENTS.md'],
+ ['file.tsです', 'file.ts'],
+ ['plans/foo.md로', 'plans/foo.md'],
+ ['src/foo.ts:12로', 'src/foo.ts:12'],
+ ['src/foo.ts:12:3에', 'src/foo.ts:12:3'],
+ ['/Users/me/docs/한글폴더/파일.md로', '/Users/me/docs/한글폴더/파일.md'],
+ ['파일.md了', '파일.md']
+ ])('trims non-ASCII letters after an ASCII extension: %s', (input, expected) => {
+ expect(trimFileLinkTrailingNonAsciiLetters(input)).toBe(expected)
+ })
+
+ it.each([
+ 'README.md',
+ 'README.md for',
+ 'file.mdbackup',
+ 'file.mdBackup',
+ 'plans/foo.md',
+ '/Users/me/docs/한글폴더/파일.md',
+ 'Makefile',
+ 'src/foo.ts:12:3'
+ ])('leaves non-particle text unchanged: %s', (input) => {
+ expect(trimFileLinkTrailingNonAsciiLetters(input)).toBe(input)
+ })
+
+ it('adjusts endIndex when trimming a range', () => {
+ const range = {
+ text: 'README.md로',
+ startIndex: 10,
+ endIndex: 10 + 'README.md로'.length
+ }
+ expect(trimFileLinkRangeTrailingNonAsciiLetters(range)).toEqual({
+ text: 'README.md',
+ startIndex: 10,
+ endIndex: 19
+ })
+ })
+})
diff --git a/src/shared/file-link-trailing-prose.ts b/src/shared/file-link-trailing-prose.ts
new file mode 100644
index 000000000000..99eb078f3243
--- /dev/null
+++ b/src/shared/file-link-trailing-prose.ts
@@ -0,0 +1,37 @@
+/**
+ * Strip CJK (and other non-ASCII) letter runs that glue onto a file extension
+ * in space-less languages. Agents and comments often write:
+ *
+ * plans/foo.md로 확정했습니다.
+ * See `README.md에`
+ *
+ * English splits on spaces (`…md for`), but Korean/Japanese/Chinese particles
+ * stay in the same token. ASCII Latin tails like `file.mdbackup` are kept —
+ * only non-ASCII letters after an ASCII extension (and optional :line[:col])
+ * count as trailing prose.
+ *
+ * Pair this with any `\p{L}` widening of path regexes: without the trim,
+ * `…/파일.md로` would be swallowed whole (#13396 / #15240).
+ */
+
+const ASCII_EXT_THEN_NON_ASCII_LETTERS =
+ /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u
+
+export function trimFileLinkTrailingNonAsciiLetters(text: string): string {
+ const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text)
+ return match?.groups?.path ?? text
+}
+
+export function trimFileLinkRangeTrailingNonAsciiLetters<
+ T extends { text: string; startIndex: number; endIndex: number }
+>(range: T): T {
+ const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text)
+ if (trimmed === range.text) {
+ return range
+ }
+ return {
+ ...range,
+ text: trimmed,
+ endIndex: range.startIndex + trimmed.length
+ }
+}
From f890b3ffbfc84b6c17c1c8ff89232577f7405da7 Mon Sep 17 00:00:00 2001
From: uhyeon <98dngus@gmail.com>
Date: Tue, 25 Aug 2026 14:26:02 +0900
Subject: [PATCH 2/5] refactor(file-links): fold trim into #15240 shared
text-boundary module
Move non-ASCII prose boundary from terminal-http-url-extraction into
src/shared/non-ascii-terminal-text-boundary.ts and wire file-link trim
through the same module (#15240 mirror).
---
mobile/src/components/MobileMarkdown.tsx | 2 +-
.../markdown-file-path-detection.ts | 2 +-
mobile/src/terminal/terminal-path-tap.ts | 2 +-
.../terminal-http-url-extraction.ts | 11 +-----
.../lib/terminal-bare-file-link-detection.ts | 2 +-
src/renderer/src/lib/terminal-links.ts | 2 +-
.../terminal-spaced-path-trailing-prose.ts | 2 +-
src/shared/file-link-trailing-prose.ts | 37 ------------------
... non-ascii-terminal-text-boundary.test.ts} | 20 +++++++++-
.../non-ascii-terminal-text-boundary.ts | 38 +++++++++++++++++++
10 files changed, 64 insertions(+), 54 deletions(-)
delete mode 100644 src/shared/file-link-trailing-prose.ts
rename src/shared/{file-link-trailing-prose.test.ts => non-ascii-terminal-text-boundary.test.ts} (60%)
create mode 100644 src/shared/non-ascii-terminal-text-boundary.ts
diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx
index 85059edbe0b3..fbccbb681f3f 100644
--- a/mobile/src/components/MobileMarkdown.tsx
+++ b/mobile/src/components/MobileMarkdown.tsx
@@ -7,7 +7,7 @@ import {
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
-import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose'
+import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts
index 82c8cba7357e..9cd2aeb0c796 100644
--- a/mobile/src/components/markdown-file-path-detection.ts
+++ b/mobile/src/components/markdown-file-path-detection.ts
@@ -4,7 +4,7 @@
// annoyance, but a false positive on prose or a version number is a broken tap.
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
-import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose'
+import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary'
export type FilePathSegment =
| { type: 'text'; value: string }
diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts
index fccbf323f060..60a95806874c 100644
--- a/mobile/src/terminal/terminal-path-tap.ts
+++ b/mobile/src/terminal/terminal-path-tap.ts
@@ -7,7 +7,7 @@ import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
-import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose'
+import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary'
export type TappedFilePath = ParsedFileLinkLocation
diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts
index eeaa463d818c..e2dcb74e96be 100644
--- a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts
@@ -1,4 +1,5 @@
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
+import { isNonAsciiProseBoundary } from '../../../../shared/non-ascii-terminal-text-boundary'
type ParsedTerminalHttpLink = {
url: string
@@ -98,7 +99,7 @@ function trimHttpUrlTrailingPunctuation(
function isHttpUrlBodyTerminator(code: number): boolean {
return (
- isNonAsciiUrlTerminator(code) ||
+ isNonAsciiProseBoundary(code) ||
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
@@ -143,14 +144,6 @@ function isHttpUrlTrailingPunctuation(code: number): boolean {
)
}
-// Why not all non-ascii: a url path may legitimately carry unencoded CJK (see the wrapped-url
-// tests), so only punctuation and spaces end it - full-width brackets, an ideographic space, `\u30fb`.
-const NON_ASCII_URL_TERMINATOR = /[\p{P}\p{S}\p{Z}]/u
-
-function isNonAsciiUrlTerminator(code: number): boolean {
- return code > 0x7e && NON_ASCII_URL_TERMINATOR.test(String.fromCharCode(code))
-}
-
function isAsciiWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
}
diff --git a/src/renderer/src/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
index 31a4d351634c..fe37b96caf57 100644
--- a/src/renderer/src/lib/terminal-bare-file-link-detection.ts
+++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
@@ -1,4 +1,4 @@
-import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose'
+import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/non-ascii-terminal-text-boundary'
import {
detectTerminalFileLinkRanges,
terminalFileLinkRangesOverlap,
diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts
index 4120697130ce..2a5a59e31914 100644
--- a/src/renderer/src/lib/terminal-links.ts
+++ b/src/renderer/src/lib/terminal-links.ts
@@ -1,4 +1,4 @@
-import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose'
+import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/non-ascii-terminal-text-boundary'
import { normalizeAbsolutePath } from './terminal-path-normalization'
import { resolveExplicitFileLinkTarget } from './explicit-file-link-target'
import { detectBareFilenameLinks } from './terminal-bare-file-link-detection'
diff --git a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
index e79127d18cde..a478859765dc 100644
--- a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
+++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
@@ -5,7 +5,7 @@ import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detecti
// extension token only extends the span when the added segment is path-like
// (contains a separator) — "v1.2 reports/result.json" extends, prose like
// "failed to start app.py" must not be swallowed.
-// Also stop before non-ASCII letters so `…/파일.md로 열었습니다` keeps only
+// Also stop before non-ASCII letters (#15240 mirror) so `…/파일.md로` keeps only
// the path after `\p{L}` widening (space-only trim leaves the particle).
const EXTENSION_PREFIX_PATTERN =
/\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu
diff --git a/src/shared/file-link-trailing-prose.ts b/src/shared/file-link-trailing-prose.ts
deleted file mode 100644
index 99eb078f3243..000000000000
--- a/src/shared/file-link-trailing-prose.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Strip CJK (and other non-ASCII) letter runs that glue onto a file extension
- * in space-less languages. Agents and comments often write:
- *
- * plans/foo.md로 확정했습니다.
- * See `README.md에`
- *
- * English splits on spaces (`…md for`), but Korean/Japanese/Chinese particles
- * stay in the same token. ASCII Latin tails like `file.mdbackup` are kept —
- * only non-ASCII letters after an ASCII extension (and optional :line[:col])
- * count as trailing prose.
- *
- * Pair this with any `\p{L}` widening of path regexes: without the trim,
- * `…/파일.md로` would be swallowed whole (#13396 / #15240).
- */
-
-const ASCII_EXT_THEN_NON_ASCII_LETTERS =
- /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u
-
-export function trimFileLinkTrailingNonAsciiLetters(text: string): string {
- const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text)
- return match?.groups?.path ?? text
-}
-
-export function trimFileLinkRangeTrailingNonAsciiLetters<
- T extends { text: string; startIndex: number; endIndex: number }
->(range: T): T {
- const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text)
- if (trimmed === range.text) {
- return range
- }
- return {
- ...range,
- text: trimmed,
- endIndex: range.startIndex + trimmed.length
- }
-}
diff --git a/src/shared/file-link-trailing-prose.test.ts b/src/shared/non-ascii-terminal-text-boundary.test.ts
similarity index 60%
rename from src/shared/file-link-trailing-prose.test.ts
rename to src/shared/non-ascii-terminal-text-boundary.test.ts
index 0928a997d830..a106b6850695 100644
--- a/src/shared/file-link-trailing-prose.test.ts
+++ b/src/shared/non-ascii-terminal-text-boundary.test.ts
@@ -1,10 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
+ isNonAsciiProseBoundary,
trimFileLinkRangeTrailingNonAsciiLetters,
trimFileLinkTrailingNonAsciiLetters
-} from './file-link-trailing-prose'
+} from './non-ascii-terminal-text-boundary'
-describe('trimFileLinkTrailingNonAsciiLetters', () => {
+describe('isNonAsciiProseBoundary (#15240)', () => {
+ it('treats full-width punctuation and ideographic space as prose boundaries', () => {
+ expect(isNonAsciiProseBoundary('('.charCodeAt(0))).toBe(true)
+ expect(isNonAsciiProseBoundary('、'.charCodeAt(0))).toBe(true)
+ expect(isNonAsciiProseBoundary('。'.charCodeAt(0))).toBe(true)
+ expect(isNonAsciiProseBoundary('\u3000'.charCodeAt(0))).toBe(true)
+ })
+
+ it('does not treat CJK letters in a path as prose boundaries', () => {
+ expect(isNonAsciiProseBoundary('文'.charCodeAt(0))).toBe(false)
+ expect(isNonAsciiProseBoundary('档'.charCodeAt(0))).toBe(false)
+ expect(isNonAsciiProseBoundary('로'.charCodeAt(0))).toBe(false)
+ })
+})
+
+describe('trimFileLinkTrailingNonAsciiLetters (file-link mirror of #15240)', () => {
it.each([
['README.md로', 'README.md'],
['AGENTS.md에', 'AGENTS.md'],
diff --git a/src/shared/non-ascii-terminal-text-boundary.ts b/src/shared/non-ascii-terminal-text-boundary.ts
new file mode 100644
index 000000000000..24992dbc1ad7
--- /dev/null
+++ b/src/shared/non-ascii-terminal-text-boundary.ts
@@ -0,0 +1,38 @@
+/**
+ * Non-ASCII boundary rules for terminal autolink spans (#15240).
+ *
+ * URL extraction ends at non-ASCII punctuation/symbols/separators (`\p{P}\p{S}\p{Z}`)
+ * but keeps CJK letters inside the path. File-link extraction is the mirror: letters
+ * inside a path stay, but a non-ASCII letter run glued after an ASCII extension
+ * (`README.md로`) is trailing prose.
+ */
+
+// Why not all non-ascii: a url path may legitimately carry unencoded CJK (see the
+// wrapped-url tests in terminal-http-url-extraction.test.ts).
+export const NON_ASCII_PROSE_BOUNDARY = /[\p{P}\p{S}\p{Z}]/u
+
+export function isNonAsciiProseBoundary(code: number): boolean {
+ return code > 0x7e && NON_ASCII_PROSE_BOUNDARY.test(String.fromCharCode(code))
+}
+
+const ASCII_EXT_THEN_NON_ASCII_LETTERS =
+ /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u
+
+export function trimFileLinkTrailingNonAsciiLetters(text: string): string {
+ const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text)
+ return match?.groups?.path ?? text
+}
+
+export function trimFileLinkRangeTrailingNonAsciiLetters<
+ T extends { text: string; startIndex: number; endIndex: number }
+>(range: T): T {
+ const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text)
+ if (trimmed === range.text) {
+ return range
+ }
+ return {
+ ...range,
+ text: trimmed,
+ endIndex: range.startIndex + trimmed.length
+ }
+}
From 37c3b5f57b48be747ae62f1336ba9b62d96d680c Mon Sep 17 00:00:00 2001
From: lazybuttrying
Date: Mon, 31 Aug 2026 16:15:58 +0900
Subject: [PATCH 3/5] fix(file-links): end the trim at any non-ASCII, not a
letter run
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The #10573 review showed a trailing trim only fires when the tail is
exactly what it strips. The letters-only form had the same hole: a
particle followed by punctuation or ASCII left the whole span linked.
plans/foo.mdを(参照) -> plans/foo.mdを(参照
plans/foo.mdへabc -> plans/foo.mdへabc
docs/日本語.md」を 参照 -> docs/日本語.md」を 参照
The first two were regressions against main, where the ASCII-only class
truncated at the first non-Latin byte by accident. Widening to \p{L}
removes that accident, so the trim has to carry the boundary itself.
Once an ASCII extension closes the name a real path only continues with
ASCII (`/`, `\`, `:line`), so cut at the first \P{ASCII}. Same lookahead
in all three copies of the spaced-path scan: renderer, mobile tap, and
the injected webview mirror that carries its own regexes.
Also pin \p{M} in the NFD test: the fixture decomposes to a combining
mark, so dropping \p{M} now fails (flagged by coderabbit on #13399).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01QDkPfyEHWNUW1JQZRe6tKb
---
mobile/src/components/MobileMarkdown.tsx | 4 +-
.../markdown-file-path-detection.test.ts | 30 +++++++-------
.../markdown-file-path-detection.ts | 13 +++---
.../terminal/terminal-path-tap-injected.ts | 14 +++----
mobile/src/terminal/terminal-path-tap.test.ts | 12 +++---
mobile/src/terminal/terminal-path-tap.ts | 14 +++----
.../lib/terminal-bare-file-link-detection.ts | 8 ++--
.../src/lib/terminal-links-redos.test.ts | 4 +-
src/renderer/src/lib/terminal-links.test.ts | 39 ++++++++++++------
src/renderer/src/lib/terminal-links.ts | 9 ++---
.../terminal-spaced-path-trailing-prose.ts | 6 +--
.../non-ascii-terminal-text-boundary.test.ts | 40 +++++++++++--------
.../non-ascii-terminal-text-boundary.ts | 23 +++++------
src/shared/terminal-file-link-conformance.ts | 20 ++++++++++
14 files changed, 135 insertions(+), 101 deletions(-)
diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx
index fbccbb681f3f..4928b0734fee 100644
--- a/mobile/src/components/MobileMarkdown.tsx
+++ b/mobile/src/components/MobileMarkdown.tsx
@@ -7,7 +7,7 @@ import {
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
-import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
@@ -134,7 +134,7 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
key={key}
style={[styles.inlineCode, styles.inlineCodeLink]}
onPress={() =>
- onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiLetters(code.trim())))
+ onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiProse(code.trim())))
}
>
{code}
diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts
index c62512464a8d..bb6ec43c5eb7 100644
--- a/mobile/src/components/markdown-file-path-detection.test.ts
+++ b/mobile/src/components/markdown-file-path-detection.test.ts
@@ -212,22 +212,24 @@ describe('detectFilePathSegments', () => {
})
it('detects CJK directory segments and trims trailing particles', () => {
- expect(detectFilePathSegments('열기 /Users/me/docs/한글폴더/파일.md로 완료')).toEqual([
- { type: 'text', value: '열기 ' },
- {
- type: 'file',
- value: '/Users/me/docs/한글폴더/파일.md',
- path: '/Users/me/docs/한글폴더/파일.md'
- },
- { type: 'text', value: '로 완료' }
- ])
+ expect(detectFilePathSegments('開く /Users/me/docs/日本語フォルダ/ファイル.mdへ 完了')).toEqual(
+ [
+ { type: 'text', value: '開く ' },
+ {
+ type: 'file',
+ value: '/Users/me/docs/日本語フォルダ/ファイル.md',
+ path: '/Users/me/docs/日本語フォルダ/ファイル.md'
+ },
+ { type: 'text', value: 'へ 完了' }
+ ]
+ )
})
it('detects ASCII separator paths with glued CJK particles in prose', () => {
- expect(detectFilePathSegments('저장 경로는 plans/foo.md로 확정')).toEqual([
- { type: 'text', value: '저장 경로는 ' },
+ expect(detectFilePathSegments('保存先は plans/foo.mdへ 確定')).toEqual([
+ { type: 'text', value: '保存先は ' },
{ type: 'file', value: 'plans/foo.md', path: 'plans/foo.md' },
- { type: 'text', value: '로 확정' }
+ { type: 'text', value: 'へ 確定' }
])
})
})
@@ -278,8 +280,8 @@ describe('isFilePathCodeSpan', () => {
})
it('accepts code spans with CJK particles glued after the extension', () => {
- expect(isFilePathCodeSpan('src/foo.ts로')).toBe(true)
- expect(isFilePathCodeSpan('plans/foo.md에')).toBe(true)
+ expect(isFilePathCodeSpan('src/foo.tsへ')).toBe(true)
+ expect(isFilePathCodeSpan('plans/foo.mdに')).toBe(true)
expect(isFilePathCodeSpan('package.jsonです')).toBe(true)
})
})
diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts
index 9cd2aeb0c796..4317d1015f3d 100644
--- a/mobile/src/components/markdown-file-path-detection.ts
+++ b/mobile/src/components/markdown-file-path-detection.ts
@@ -4,7 +4,7 @@
// annoyance, but a false positive on prose or a version number is a broken tap.
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
-import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
export type FilePathSegment =
| { type: 'text'; value: string }
@@ -89,9 +89,8 @@ const EXTENSION_SET = new Set(FILE_EXTENSIONS)
// connected runtime, which may be Windows even when the phone is not. Leading
// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
// tail captures only bounded agent-style :line(:col) citations.
-// Why \p{L}\p{M}\p{N}: ASCII-only segments truncated CJK directory names. Keep
-// the extension ASCII so particles after `.md` stay outside the match; still
-// run trimFileLinkTrailingNonAsciiLetters for code-span / line:col edges.
+// Why \p{L}\p{M}\p{N}: ASCII-only segments truncated CJK directory names; the
+// extension stays ASCII so particles after `.md` fall outside the match.
const CANDIDATE_PATTERN =
/(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])*|(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])+)[\p{L}\p{M}\p{N}_.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![A-Za-z0-9_@%]))?/gu
@@ -130,7 +129,7 @@ export function splitFilePathLineSuffix(pathText: string): {
function isOpenablePath(pathText: string): boolean {
// A :line(:col) tail is part of the citation, not the file name. Strip CJK
// particles glued after an ASCII extension before extension lookup.
- const { path: candidate } = splitFilePathLineSuffix(trimFileLinkTrailingNonAsciiLetters(pathText))
+ const { path: candidate } = splitFilePathLineSuffix(trimFileLinkTrailingNonAsciiProse(pathText))
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
@@ -183,7 +182,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
CANDIDATE_PATTERN.lastIndex = 0
while ((match = CANDIDATE_PATTERN.exec(text))) {
- const candidate = trimFileLinkTrailingNonAsciiLetters(match[0])
+ const candidate = trimFileLinkTrailingNonAsciiProse(match[0])
// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
// a preceding slash (the leading slash of an absolute path is part of the
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
@@ -217,7 +216,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
* bare `file.ts` here even without a slash.
*/
export function isFilePathCodeSpan(code: string): boolean {
- const trimmed = trimFileLinkTrailingNonAsciiLetters(code.trim())
+ const trimmed = trimFileLinkTrailingNonAsciiProse(code.trim())
if (!trimmed || /\s/.test(trimmed)) {
return false
}
diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts
index 1529aa6ef772..c6f2706a5924 100644
--- a/mobile/src/terminal/terminal-path-tap-injected.ts
+++ b/mobile/src/terminal/terminal-path-tap-injected.ts
@@ -18,7 +18,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/])[^()[\]{}'",;<>|\x60\r\n]+(?::\d+)?(?::\d+)?/gu;
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
- var ASCII_EXT_THEN_NON_ASCII_LETTERS = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)((?:(?![A-Za-z])\p{L})+)$/u;
+ var ASCII_EXT_THEN_NON_ASCII = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(\P{ASCII}[\s\S]*)$/u;
function parsePathLineCol(value) {
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
@@ -40,8 +40,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
}
- function trimTrailingNonAsciiLetters(range) {
- var match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(range.text);
+ function trimTrailingNonAsciiProse(range) {
+ var match = ASCII_EXT_THEN_NON_ASCII.exec(range.text);
if (!match) return range;
return { text: match[1], startIndex: range.startIndex, endIndex: range.startIndex + match[1].length };
}
@@ -60,8 +60,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
// A line-end extension token only extends the span when the added segment
// is path-like (contains a separator) — prose must not be swallowed.
var selected = null;
- // Also stop before non-ASCII letters (CJK particles glued to the ext).
- var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu;
+ // Also stop before any non-ASCII char (particles and CJK brackets glued to the ext).
+ var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu;
var match;
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
var end = match.index + match[0].length;
@@ -101,7 +101,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
var proseTrimmed = trimSpacedPathTrailingProse(trimmed, col);
if (!proseTrimmed) continue;
- var candidate = trimTrailingNonAsciiLetters(proseTrimmed);
+ var candidate = trimTrailingNonAsciiProse(proseTrimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
@@ -119,7 +119,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
if (!trimmed) continue;
- var candidate = trimTrailingNonAsciiLetters(trimmed);
+ var candidate = trimTrailingNonAsciiProse(trimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts
index 6025820f44dc..9d648dadfd0e 100644
--- a/mobile/src/terminal/terminal-path-tap.test.ts
+++ b/mobile/src/terminal/terminal-path-tap.test.ts
@@ -166,24 +166,24 @@ describe('matchFilePathAtColumn', () => {
})
it('trims CJK particles from bare filenames under the tap', () => {
- const line = 'See README.md로 for details'
+ const line = 'See README.mdへ for details'
expect(matchFilePathAtColumn(line, colOf(line, 'README'))).toEqual({
pathText: 'README.md',
line: null,
column: null
})
- expect(matchFilePathAtColumn(line, colOf(line, '로'))).toBeNull()
+ expect(matchFilePathAtColumn(line, colOf(line, 'へ'))).toBeNull()
})
it('matches non-Latin separator paths and trims trailing particles', () => {
- const path = '/Users/me/docs/한글폴더/파일.md'
- const line = `${path}로 열었습니다`
- expect(matchFilePathAtColumn(line, colOf(line, '파일'))).toEqual({
+ const path = '/Users/me/docs/日本語フォルダ/ファイル.md'
+ const line = `${path}へ 開きました`
+ expect(matchFilePathAtColumn(line, colOf(line, 'ファイル'))).toEqual({
pathText: path,
line: null,
column: null
})
- expect(matchFilePathAtColumn(line, colOf(line, '로'))).toBeNull()
+ expect(matchFilePathAtColumn(line, colOf(line, 'へ'))).toBeNull()
})
})
diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts
index 60a95806874c..fea62cb2b6f5 100644
--- a/mobile/src/terminal/terminal-path-tap.ts
+++ b/mobile/src/terminal/terminal-path-tap.ts
@@ -7,7 +7,7 @@ import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
-import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
export type TappedFilePath = ParsedFileLinkLocation
@@ -17,7 +17,7 @@ export type TappedFilePath = ParsedFileLinkLocation
// existence-check reject non-files — agents often print a bare filename, so
// requiring a slash would miss the common case.
// Why \p{L}\p{M}\p{N}: ASCII-only classes truncated CJK path segments (#13396).
-// Pair with trimFileLinkRangeTrailingNonAsciiLetters so glued particles after
+// Pair with trimFileLinkRangeTrailingNonAsciiProse so glued particles after
// an extension are not part of the tap target.
const LOCAL_PATH_REGEX =
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu
@@ -86,10 +86,8 @@ function trimSpacedPathTrailingProse(
// segment is path-like (contains a separator) — "v1.2 reports/result.json"
// extends, prose like "failed to start app.py" must not be swallowed.
let selected: string | null = null
- // Why: also stop before non-ASCII letters so `…/파일.md로 열었습니다` keeps
- // only the path after `\p{L}` widening (space-only trim leaves the particle).
- const extensionPrefixPattern =
- /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu
+ // Also stop at non-ASCII: a CJK bracket closes a citation as often as a particle.
+ const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu
let match: RegExpExecArray | null
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
const end = match.index + match[0].length
@@ -159,7 +157,7 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP
if (!proseTrimmed) {
continue
}
- const candidate = trimFileLinkRangeTrailingNonAsciiLetters(proseTrimmed)
+ const candidate = trimFileLinkRangeTrailingNonAsciiProse(proseTrimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
@@ -189,7 +187,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
if (!trimmed) {
continue
}
- const candidate = trimFileLinkRangeTrailingNonAsciiLetters(trimmed)
+ const candidate = trimFileLinkRangeTrailingNonAsciiProse(trimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
diff --git a/src/renderer/src/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
index fe37b96caf57..7afc1e131b7a 100644
--- a/src/renderer/src/lib/terminal-bare-file-link-detection.ts
+++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
@@ -1,4 +1,4 @@
-import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
import {
detectTerminalFileLinkRanges,
terminalFileLinkRangesOverlap,
@@ -59,10 +59,8 @@ export function detectBareFilenameLinks(
if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) {
continue
}
- // Why: space-less languages glue particles onto bare filenames
- // (`README.md로`); trim before the ASCII filename check or the token
- // fails looksLikeFilename and never becomes a link.
- const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiLetters(range))
+ // Trim first: `README.mdへ` fails looksLikeFilename and never becomes a link.
+ const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiProse(range))
if (!link || !looksLikeFilename(link.pathText)) {
continue
}
diff --git a/src/renderer/src/lib/terminal-links-redos.test.ts b/src/renderer/src/lib/terminal-links-redos.test.ts
index 9f3312e104ba..34fd7240f672 100644
--- a/src/renderer/src/lib/terminal-links-redos.test.ts
+++ b/src/renderer/src/lib/terminal-links-redos.test.ts
@@ -11,8 +11,8 @@ describe('terminal-links ReDoS guard (#5970)', () => {
['separator + space padding', `a/${' '.repeat(30_000)}`],
['advertised url + space padding', `Web Interface http://127.0.0.1:4040${' '.repeat(30_000)}`],
// The local-path class accepts Unicode letters/marks; keep that scan linear too.
- ['non-latin separator + space padding', `한/${' '.repeat(30_000)}`],
- ['non-latin run + space padding', `${'한글'.repeat(15_000)}/${' '.repeat(30_000)}`]
+ ['non-latin separator + space padding', `あ/${' '.repeat(30_000)}`],
+ ['non-latin run + space padding', `${'日本語'.repeat(15_000)}/${' '.repeat(30_000)}`]
]
for (const [name, line] of cases) {
diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts
index d2bc8218fb06..85c4dd7344fc 100644
--- a/src/renderer/src/lib/terminal-links.test.ts
+++ b/src/renderer/src/lib/terminal-links.test.ts
@@ -114,8 +114,8 @@ describe('terminal path helpers', () => {
})
it.each([
- ['README.md로', 'README.md'],
- ['AGENTS.md에', 'AGENTS.md'],
+ ['README.mdへ', 'README.md'],
+ ['AGENTS.mdに', 'AGENTS.md'],
['file.tsです', 'file.ts']
])('trims CJK/JP particles from bare filenames: %s', (token, pathText) => {
const links = extractTerminalFileLinks(token)
@@ -231,7 +231,7 @@ describe('terminal path helpers', () => {
// character, so only the ASCII prefix was linkified and the filename sat
// outside every link range (#13396).
it.each([
- ['Korean', '/Users/me/docs/한글폴더/파일.md'],
+ ['Japanese', '/Users/me/docs/日本語フォルダ/ファイル.md'],
['Japanese', '/Users/me/docs/日本語/ファイル.md'],
['Chinese', '/Users/me/docs/中文目录/文件.md'],
['Cyrillic', '/Users/me/docs/Документы/файл.md'],
@@ -243,31 +243,46 @@ describe('terminal path helpers', () => {
})
it('detects relative paths that start with a non-Latin segment', () => {
- const links = extractTerminalFileLinks('한글폴더/파일.md')
+ const links = extractTerminalFileLinks('日本語フォルダ/ファイル.md')
expect(links).toHaveLength(1)
- expect(links[0]).toMatchObject({ pathText: '한글폴더/파일.md' })
+ expect(links[0]).toMatchObject({ pathText: '日本語フォルダ/ファイル.md' })
})
it('keeps line and column suffixes on non-Latin paths', () => {
- const links = extractTerminalFileLinks('/Users/me/docs/한글폴더/파일.ts:42:7')
+ const links = extractTerminalFileLinks('/Users/me/docs/日本語フォルダ/ファイル.ts:42:7')
expect(links).toHaveLength(1)
expect(links[0]).toMatchObject({
- pathText: '/Users/me/docs/한글폴더/파일.ts',
+ pathText: '/Users/me/docs/日本語フォルダ/ファイル.ts',
line: 42,
column: 7
})
})
- // macOS stores many names decomposed, while terminal output is composed.
+ // macOS stores many names decomposed; the assertion pins \p{M}, which a Jamo-only
+ // fixture would leave untested.
it('detects NFD-decomposed non-Latin paths', () => {
- const path = `/Users/me/docs/${'한글폴더'.normalize('NFD')}/${'파일.md'.normalize('NFD')}`
+ const path = `/Users/me/docs/${'日本語フォルダ'.normalize('NFD')}/${'ファイル.md'.normalize('NFD')}`
+ expect(/\p{M}/u.test(path)).toBe(true)
const links = extractTerminalFileLinks(path)
expect(links).toHaveLength(1)
expect(links[0]).toMatchObject({ pathText: path })
})
+ // \p{L} removes the accidental truncation at the first non-Latin byte, so the
+ // trim must cover tails that are not a bare letter run (#10573 review).
+ it.each([
+ ['plans/foo.mdを(参照)', 'plans/foo.md'],
+ ['plans/foo.mdへabc', 'plans/foo.md'],
+ ['docs/日本語.md」を 参照', 'docs/日本語.md']
+ ])('stops at prose punctuation glued after an extension: %s', (line, expected) => {
+ const links = extractTerminalFileLinks(line)
+ expect(links.map((link) => link.pathText)).toContain(expected)
+ const link = links.find((entry) => entry.pathText === expected)
+ expect(line.slice(link!.startIndex, link!.endIndex)).toBe(expected)
+ })
+
it('trims particles glued after a separator path extension', () => {
- const line = '저장 경로는 plans/foo.md로 확정했습니다.'
+ const line = '保存先は plans/foo.mdへ 確定しました.'
const links = extractTerminalFileLinks(line)
expect(links.map((link) => link.pathText)).toContain('plans/foo.md')
const link = links.find((entry) => entry.pathText === 'plans/foo.md')
@@ -276,8 +291,8 @@ describe('terminal path helpers', () => {
})
it('trims particles after a non-Latin path while keeping the CJK segments', () => {
- const path = '/Users/me/docs/한글폴더/파일.md'
- const line = `${path}로 열었습니다`
+ const path = '/Users/me/docs/日本語フォルダ/ファイル.md'
+ const line = `${path}へ 開きました`
const links = extractTerminalFileLinks(line)
expect(links).toHaveLength(1)
expect(links[0]).toMatchObject({ pathText: path, displayText: path })
diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts
index 2a5a59e31914..408e29d8d4f1 100644
--- a/src/renderer/src/lib/terminal-links.ts
+++ b/src/renderer/src/lib/terminal-links.ts
@@ -1,4 +1,4 @@
-import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
import { normalizeAbsolutePath } from './terminal-path-normalization'
import { resolveExplicitFileLinkTarget } from './explicit-file-link-target'
import { detectBareFilenameLinks } from './terminal-bare-file-link-detection'
@@ -36,8 +36,7 @@ export type ResolvedTerminalFileLink = Pick
toParsedTerminalFileLink(
- trimFileLinkRangeTrailingNonAsciiLetters(
+ trimFileLinkRangeTrailingNonAsciiProse(
trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))
)
)
diff --git a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
index a478859765dc..0081e37faec8 100644
--- a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
+++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
@@ -5,10 +5,8 @@ import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detecti
// extension token only extends the span when the added segment is path-like
// (contains a separator) — "v1.2 reports/result.json" extends, prose like
// "failed to start app.py" must not be swallowed.
-// Also stop before non-ASCII letters (#15240 mirror) so `…/파일.md로` keeps only
-// the path after `\p{L}` widening (space-only trim leaves the particle).
-const EXTENSION_PREFIX_PATTERN =
- /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu
+// Also stop at non-ASCII: a CJK bracket closes a citation as often as a particle.
+const EXTENSION_PREFIX_PATTERN = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu
function countPathStarts(text: string): number {
let count = 0
diff --git a/src/shared/non-ascii-terminal-text-boundary.test.ts b/src/shared/non-ascii-terminal-text-boundary.test.ts
index a106b6850695..27ae107f7309 100644
--- a/src/shared/non-ascii-terminal-text-boundary.test.ts
+++ b/src/shared/non-ascii-terminal-text-boundary.test.ts
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
isNonAsciiProseBoundary,
- trimFileLinkRangeTrailingNonAsciiLetters,
- trimFileLinkTrailingNonAsciiLetters
+ trimFileLinkRangeTrailingNonAsciiProse,
+ trimFileLinkTrailingNonAsciiProse
} from './non-ascii-terminal-text-boundary'
describe('isNonAsciiProseBoundary (#15240)', () => {
@@ -16,22 +16,28 @@ describe('isNonAsciiProseBoundary (#15240)', () => {
it('does not treat CJK letters in a path as prose boundaries', () => {
expect(isNonAsciiProseBoundary('文'.charCodeAt(0))).toBe(false)
expect(isNonAsciiProseBoundary('档'.charCodeAt(0))).toBe(false)
- expect(isNonAsciiProseBoundary('로'.charCodeAt(0))).toBe(false)
+ expect(isNonAsciiProseBoundary('へ'.charCodeAt(0))).toBe(false)
})
})
-describe('trimFileLinkTrailingNonAsciiLetters (file-link mirror of #15240)', () => {
+describe('trimFileLinkTrailingNonAsciiProse (file-link mirror of #15240)', () => {
it.each([
- ['README.md로', 'README.md'],
- ['AGENTS.md에', 'AGENTS.md'],
+ ['README.mdへ', 'README.md'],
+ ['AGENTS.mdに', 'AGENTS.md'],
['file.tsです', 'file.ts'],
- ['plans/foo.md로', 'plans/foo.md'],
- ['src/foo.ts:12로', 'src/foo.ts:12'],
- ['src/foo.ts:12:3에', 'src/foo.ts:12:3'],
- ['/Users/me/docs/한글폴더/파일.md로', '/Users/me/docs/한글폴더/파일.md'],
- ['파일.md了', '파일.md']
+ ['plans/foo.mdへ', 'plans/foo.md'],
+ ['src/foo.ts:12へ', 'src/foo.ts:12'],
+ ['src/foo.ts:12:3に', 'src/foo.ts:12:3'],
+ ['/Users/me/docs/日本語フォルダ/ファイル.mdへ', '/Users/me/docs/日本語フォルダ/ファイル.md'],
+ ['ファイル.md了', 'ファイル.md'],
+ // Trailing prose is not always a bare letter run: a particle may be followed by
+ // punctuation or ASCII, and CJK brackets close a citation (#15240 boundary set).
+ ['plans/foo.mdを(参照)', 'plans/foo.md'],
+ ['plans/foo.mdへabc', 'plans/foo.md'],
+ ['docs/日本語.md」を', 'docs/日本語.md'],
+ ['README.md、次', 'README.md']
])('trims non-ASCII letters after an ASCII extension: %s', (input, expected) => {
- expect(trimFileLinkTrailingNonAsciiLetters(input)).toBe(expected)
+ expect(trimFileLinkTrailingNonAsciiProse(input)).toBe(expected)
})
it.each([
@@ -40,20 +46,20 @@ describe('trimFileLinkTrailingNonAsciiLetters (file-link mirror of #15240)', ()
'file.mdbackup',
'file.mdBackup',
'plans/foo.md',
- '/Users/me/docs/한글폴더/파일.md',
+ '/Users/me/docs/日本語フォルダ/ファイル.md',
'Makefile',
'src/foo.ts:12:3'
])('leaves non-particle text unchanged: %s', (input) => {
- expect(trimFileLinkTrailingNonAsciiLetters(input)).toBe(input)
+ expect(trimFileLinkTrailingNonAsciiProse(input)).toBe(input)
})
it('adjusts endIndex when trimming a range', () => {
const range = {
- text: 'README.md로',
+ text: 'README.mdへ',
startIndex: 10,
- endIndex: 10 + 'README.md로'.length
+ endIndex: 10 + 'README.mdへ'.length
}
- expect(trimFileLinkRangeTrailingNonAsciiLetters(range)).toEqual({
+ expect(trimFileLinkRangeTrailingNonAsciiProse(range)).toEqual({
text: 'README.md',
startIndex: 10,
endIndex: 19
diff --git a/src/shared/non-ascii-terminal-text-boundary.ts b/src/shared/non-ascii-terminal-text-boundary.ts
index 24992dbc1ad7..4ea78b7eb56d 100644
--- a/src/shared/non-ascii-terminal-text-boundary.ts
+++ b/src/shared/non-ascii-terminal-text-boundary.ts
@@ -1,32 +1,31 @@
/**
* Non-ASCII boundary rules for terminal autolink spans (#15240).
*
- * URL extraction ends at non-ASCII punctuation/symbols/separators (`\p{P}\p{S}\p{Z}`)
- * but keeps CJK letters inside the path. File-link extraction is the mirror: letters
- * inside a path stay, but a non-ASCII letter run glued after an ASCII extension
- * (`README.md로`) is trailing prose.
+ * URLs stop at non-ASCII punctuation but keep CJK letters inside the path. File
+ * links mirror that: anything glued after an ASCII extension is trailing prose.
*/
-// Why not all non-ascii: a url path may legitimately carry unencoded CJK (see the
-// wrapped-url tests in terminal-http-url-extraction.test.ts).
+// Why not all non-ascii: a url path may legitimately carry unencoded CJK.
export const NON_ASCII_PROSE_BOUNDARY = /[\p{P}\p{S}\p{Z}]/u
export function isNonAsciiProseBoundary(code: number): boolean {
return code > 0x7e && NON_ASCII_PROSE_BOUNDARY.test(String.fromCharCode(code))
}
-const ASCII_EXT_THEN_NON_ASCII_LETTERS =
- /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u
+// Why \P{ASCII} and not a letter run: a real path continues in ASCII after the
+// extension, and a letters-only trim never fires on `foo.mdを(参照)` (#10573 review).
+const ASCII_EXT_THEN_NON_ASCII =
+ /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?\P{ASCII}[\s\S]*)$/u
-export function trimFileLinkTrailingNonAsciiLetters(text: string): string {
- const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text)
+export function trimFileLinkTrailingNonAsciiProse(text: string): string {
+ const match = ASCII_EXT_THEN_NON_ASCII.exec(text)
return match?.groups?.path ?? text
}
-export function trimFileLinkRangeTrailingNonAsciiLetters<
+export function trimFileLinkRangeTrailingNonAsciiProse<
T extends { text: string; startIndex: number; endIndex: number }
>(range: T): T {
- const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text)
+ const trimmed = trimFileLinkTrailingNonAsciiProse(range.text)
if (trimmed === range.text) {
return range
}
diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts
index d74af87eab26..38f0160a60e7 100644
--- a/src/shared/terminal-file-link-conformance.ts
+++ b/src/shared/terminal-file-link-conformance.ts
@@ -137,5 +137,25 @@ export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConfor
lineText: 'just some prose with no path here',
tapText: 'prose',
expected: null
+ },
+ // A glued particle is not always followed by a space: punctuation, ASCII, and CJK
+ // brackets all close the citation, and a letters-only trim never fires there.
+ {
+ name: 'particle then full-width parenthesis',
+ lineText: 'plans/foo.mdを(参照) 確認',
+ tapText: 'foo',
+ expected: { pathText: 'plans/foo.md', line: null, column: null }
+ },
+ {
+ name: 'particle then ascii tail',
+ lineText: 'plans/foo.mdへabc',
+ tapText: 'foo',
+ expected: { pathText: 'plans/foo.md', line: null, column: null }
+ },
+ {
+ name: 'cjk closing bracket after extension',
+ lineText: 'docs/日本語.md」を 参照',
+ tapText: '日本語',
+ expected: { pathText: 'docs/日本語.md', line: null, column: null }
}
]
From dd71bb9844e37b97fdfe079c09b424c2c5925ef0 Mon Sep 17 00:00:00 2001
From: lazybuttrying
Date: Tue, 1 Sep 2026 13:11:55 +0900
Subject: [PATCH 4/5] fix(file-links): link non-Latin bare names and prose
glued on the left
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two gaps remained after the trailing trim.
`파일.md` never linked at all: BARE_FILENAME_PATTERN was ASCII-only, so a
bare filename whose *name* is non-Latin failed looksLikeFilename with or
without a particle. Widen it to \p{L}\p{M}\p{N} and require an ASCII
extension, which keeps the prose rejection the function exists for -
otherwise every sentence ending in `.` reaches the filesystem probe.
Prose also glues onto the left (`문서는README.md`), and the span swallowed
it. Trim a leading non-ASCII run, but only when the path resumes in ASCII:
`참고파일.md` and `한글폴더/파일.md` are plausible real names and must stay
whole. A file genuinely named `파일은README.md` is the accepted cost.
Applied in all five implementations - renderer links, bare detection,
mobile tap, its injected WebView mirror, and mobile markdown - with the
new cases in the shared conformance list, so each asserts twice.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01QDkPfyEHWNUW1JQZRe6tKb
---
mobile/src/components/MobileMarkdown.tsx | 6 +--
.../markdown-file-path-detection.ts | 8 ++--
.../terminal/terminal-path-tap-injected.ts | 16 ++++++-
mobile/src/terminal/terminal-path-tap.ts | 8 ++--
.../lib/terminal-bare-file-link-detection.ts | 13 ++++--
src/renderer/src/lib/terminal-links.test.ts | 42 +++++++++++++++++++
src/renderer/src/lib/terminal-links.ts | 6 +--
.../non-ascii-terminal-text-boundary.ts | 28 +++++++++++++
src/shared/terminal-file-link-conformance.ts | 20 +++++++++
9 files changed, 126 insertions(+), 21 deletions(-)
diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx
index 4928b0734fee..63e2a7dc2093 100644
--- a/mobile/src/components/MobileMarkdown.tsx
+++ b/mobile/src/components/MobileMarkdown.tsx
@@ -7,7 +7,7 @@ import {
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
-import { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
@@ -133,9 +133,7 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
- onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiProse(code.trim())))
- }
+ onPress={() => onOpenFile(normalizeFilePath(trimFileLinkNonAsciiProse(code.trim())))}
>
{code}
diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts
index 4317d1015f3d..0513b4dec69b 100644
--- a/mobile/src/components/markdown-file-path-detection.ts
+++ b/mobile/src/components/markdown-file-path-detection.ts
@@ -4,7 +4,7 @@
// annoyance, but a false positive on prose or a version number is a broken tap.
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
-import { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
export type FilePathSegment =
| { type: 'text'; value: string }
@@ -129,7 +129,7 @@ export function splitFilePathLineSuffix(pathText: string): {
function isOpenablePath(pathText: string): boolean {
// A :line(:col) tail is part of the citation, not the file name. Strip CJK
// particles glued after an ASCII extension before extension lookup.
- const { path: candidate } = splitFilePathLineSuffix(trimFileLinkTrailingNonAsciiProse(pathText))
+ const { path: candidate } = splitFilePathLineSuffix(trimFileLinkNonAsciiProse(pathText))
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
@@ -182,7 +182,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
CANDIDATE_PATTERN.lastIndex = 0
while ((match = CANDIDATE_PATTERN.exec(text))) {
- const candidate = trimFileLinkTrailingNonAsciiProse(match[0])
+ const candidate = trimFileLinkNonAsciiProse(match[0])
// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
// a preceding slash (the leading slash of an absolute path is part of the
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
@@ -216,7 +216,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
* bare `file.ts` here even without a slash.
*/
export function isFilePathCodeSpan(code: string): boolean {
- const trimmed = trimFileLinkTrailingNonAsciiProse(code.trim())
+ const trimmed = trimFileLinkNonAsciiProse(code.trim())
if (!trimmed || /\s/.test(trimmed)) {
return false
}
diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts
index c6f2706a5924..fab495bebee7 100644
--- a/mobile/src/terminal/terminal-path-tap-injected.ts
+++ b/mobile/src/terminal/terminal-path-tap-injected.ts
@@ -19,6 +19,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
var ASCII_EXT_THEN_NON_ASCII = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(\P{ASCII}[\s\S]*)$/u;
+ // Leading prose only when the path resumes in ASCII, so a wholly non-Latin name survives.
+ var LEADING_NON_ASCII_THEN_ASCII = /^(\P{ASCII}+)([A-Za-z0-9_][\s\S]*)$/u;
function parsePathLineCol(value) {
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
@@ -46,6 +48,16 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
return { text: match[1], startIndex: range.startIndex, endIndex: range.startIndex + match[1].length };
}
+ function trimLeadingNonAsciiProse(range) {
+ var match = LEADING_NON_ASCII_THEN_ASCII.exec(range.text);
+ if (!match) return range;
+ return { text: match[2], startIndex: range.endIndex - match[2].length, endIndex: range.endIndex };
+ }
+
+ function trimNonAsciiProse(range) {
+ return trimTrailingNonAsciiProse(trimLeadingNonAsciiProse(range));
+ }
+
function hasSeparatorAfterWhitespace(text) {
var sawWhitespace = false;
for (var i = 0; i < text.length; i++) {
@@ -101,7 +113,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
var proseTrimmed = trimSpacedPathTrailingProse(trimmed, col);
if (!proseTrimmed) continue;
- var candidate = trimTrailingNonAsciiProse(proseTrimmed);
+ var candidate = trimNonAsciiProse(proseTrimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
@@ -119,7 +131,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
if (!trimmed) continue;
- var candidate = trimTrailingNonAsciiProse(trimmed);
+ var candidate = trimNonAsciiProse(trimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts
index fea62cb2b6f5..c226389ab438 100644
--- a/mobile/src/terminal/terminal-path-tap.ts
+++ b/mobile/src/terminal/terminal-path-tap.ts
@@ -7,7 +7,7 @@ import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
-import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkRangeNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
export type TappedFilePath = ParsedFileLinkLocation
@@ -17,7 +17,7 @@ export type TappedFilePath = ParsedFileLinkLocation
// existence-check reject non-files — agents often print a bare filename, so
// requiring a slash would miss the common case.
// Why \p{L}\p{M}\p{N}: ASCII-only classes truncated CJK path segments (#13396).
-// Pair with trimFileLinkRangeTrailingNonAsciiProse so glued particles after
+// Pair with trimFileLinkRangeNonAsciiProse so glued particles after
// an extension are not part of the tap target.
const LOCAL_PATH_REGEX =
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu
@@ -157,7 +157,7 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP
if (!proseTrimmed) {
continue
}
- const candidate = trimFileLinkRangeTrailingNonAsciiProse(proseTrimmed)
+ const candidate = trimFileLinkRangeNonAsciiProse(proseTrimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
@@ -187,7 +187,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
if (!trimmed) {
continue
}
- const candidate = trimFileLinkRangeTrailingNonAsciiProse(trimmed)
+ const candidate = trimFileLinkRangeNonAsciiProse(trimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
diff --git a/src/renderer/src/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
index 7afc1e131b7a..f800f480897a 100644
--- a/src/renderer/src/lib/terminal-bare-file-link-detection.ts
+++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts
@@ -1,4 +1,4 @@
-import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkRangeNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
import {
detectTerminalFileLinkRanges,
terminalFileLinkRangesOverlap,
@@ -24,7 +24,12 @@ const EXTENSIONLESS_FILENAMES = new Set([
'CONTRIBUTING'
])
-const BARE_FILENAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/
+// Why \p{L}\p{M}\p{N}: an ASCII-only name never matched `文書.md`, so a bare
+// non-Latin filename was not a link at all (#13396).
+const BARE_FILENAME_PATTERN = /^[\p{L}\p{M}\p{N}_][\p{L}\p{M}\p{N}._+-]*$/u
+// Why an ASCII extension: with the wider name class, every sentence ending in `.`
+// would otherwise reach the filesystem probe.
+const BARE_FILENAME_EXTENSION = /\.[A-Za-z0-9_+-]+$/
const MAX_BARE_FILENAME_TOKEN_LENGTH = 120
function looksLikeFilename(token: string): boolean {
@@ -38,7 +43,7 @@ function looksLikeFilename(token: string): boolean {
return false
}
if (token.includes('.')) {
- return !/^\.+$/.test(token)
+ return BARE_FILENAME_EXTENSION.test(token)
}
return EXTENSIONLESS_FILENAMES.has(token)
}
@@ -60,7 +65,7 @@ export function detectBareFilenameLinks(
continue
}
// Trim first: `README.mdへ` fails looksLikeFilename and never becomes a link.
- const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiProse(range))
+ const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range))
if (!link || !looksLikeFilename(link.pathText)) {
continue
}
diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts
index 85c4dd7344fc..6b2dfacc35a2 100644
--- a/src/renderer/src/lib/terminal-links.test.ts
+++ b/src/renderer/src/lib/terminal-links.test.ts
@@ -113,6 +113,48 @@ describe('terminal path helpers', () => {
expect(links.map((link) => link.displayText)).toEqual(['package.json'])
})
+ // Prose glues onto the left as readily as the right; the span must exclude it,
+ // and startIndex must move with it.
+ it.each([
+ ['文書はREADME.mdだ', 'README.md'],
+ ['参照docs/文書.md', 'docs/文書.md'],
+ ['経路はdocs/設計.mdへ', 'docs/設計.md']
+ ])('trims non-Latin prose glued before a path: %s', (line, expected) => {
+ const links = extractTerminalFileLinks(line)
+ expect(links.map((link) => link.pathText)).toContain(expected)
+ const link = links.find((entry) => entry.pathText === expected)!
+ expect(line.slice(link.startIndex, link.endIndex)).toBe(expected)
+ })
+
+ // A leading non-Latin run is only prose when the path resumes in ASCII —
+ // otherwise `参照文書.md` and `日本語フォルダ/x.md` would lose their real names.
+ it.each(['参照文書.md', '日本語フォルダ/文書.md'])(
+ 'keeps a wholly non-Latin name intact: %s',
+ (line) => {
+ expect(extractTerminalFileLinks(line).map((l) => l.pathText)).toEqual([line])
+ }
+ )
+
+ // A non-Latin *name* has to link too, not just a non-Latin tail (#13396).
+ it.each([
+ ['文書.md', '文書.md'],
+ ['文書.mdへ', '文書.md'],
+ ['設計.txtに', '設計.txt']
+ ])('detects bare filenames whose name is non-Latin: %s', (token, pathText) => {
+ const links = extractTerminalFileLinks(token)
+ expect(links).toHaveLength(1)
+ expect(links[0]).toMatchObject({ pathText })
+ })
+
+ // Widening the name class must not send every sentence ending in `.` to the
+ // filesystem probe, so an ASCII extension is still required.
+ it.each(['確定しました。', '確定しました.', 'よろしくお願いします.'])(
+ 'does not treat trailing-dot prose as a bare filename: %s',
+ (token) => {
+ expect(extractTerminalFileLinks(token)).toEqual([])
+ }
+ )
+
it.each([
['README.mdへ', 'README.md'],
['AGENTS.mdに', 'AGENTS.md'],
diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts
index 408e29d8d4f1..fcbc1a7688a5 100644
--- a/src/renderer/src/lib/terminal-links.ts
+++ b/src/renderer/src/lib/terminal-links.ts
@@ -1,4 +1,4 @@
-import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
+import { trimFileLinkRangeNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
import { normalizeAbsolutePath } from './terminal-path-normalization'
import { resolveExplicitFileLinkTarget } from './explicit-file-link-target'
import { detectBareFilenameLinks } from './terminal-bare-file-link-detection'
@@ -177,7 +177,7 @@ function detectLocalPathLinks(
if (!/[\\/]/.test(range.text)) {
continue
}
- const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiProse(range))
+ const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range))
if (link) {
links.push(link)
}
@@ -219,7 +219,7 @@ function detectSpacedLocalPathLinks(
const candidateLinks = candidateRanges
.map((candidateRange) =>
toParsedTerminalFileLink(
- trimFileLinkRangeTrailingNonAsciiProse(
+ trimFileLinkRangeNonAsciiProse(
trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))
)
)
diff --git a/src/shared/non-ascii-terminal-text-boundary.ts b/src/shared/non-ascii-terminal-text-boundary.ts
index 4ea78b7eb56d..8465e15de190 100644
--- a/src/shared/non-ascii-terminal-text-boundary.ts
+++ b/src/shared/non-ascii-terminal-text-boundary.ts
@@ -22,6 +22,34 @@ export function trimFileLinkTrailingNonAsciiProse(text: string): string {
return match?.groups?.path ?? text
}
+// Why only before an ASCII start: `참고파일.md` may genuinely be a file name, so a
+// leading non-ASCII run is prose only when the path resumes in ASCII (`참고docs/x.md`).
+const LEADING_NON_ASCII_THEN_ASCII = /^(?\P{ASCII}+)(?[A-Za-z0-9_][\s\S]*)$/u
+
+export function trimFileLinkLeadingNonAsciiProse(text: string): string {
+ return LEADING_NON_ASCII_THEN_ASCII.exec(text)?.groups?.path ?? text
+}
+
+export function trimFileLinkNonAsciiProse(text: string): string {
+ return trimFileLinkTrailingNonAsciiProse(trimFileLinkLeadingNonAsciiProse(text))
+}
+
+export function trimFileLinkRangeLeadingNonAsciiProse<
+ T extends { text: string; startIndex: number; endIndex: number }
+>(range: T): T {
+ const trimmed = trimFileLinkLeadingNonAsciiProse(range.text)
+ if (trimmed === range.text) {
+ return range
+ }
+ return { ...range, text: trimmed, startIndex: range.endIndex - trimmed.length }
+}
+
+export function trimFileLinkRangeNonAsciiProse<
+ T extends { text: string; startIndex: number; endIndex: number }
+>(range: T): T {
+ return trimFileLinkRangeTrailingNonAsciiProse(trimFileLinkRangeLeadingNonAsciiProse(range))
+}
+
export function trimFileLinkRangeTrailingNonAsciiProse<
T extends { text: string; startIndex: number; endIndex: number }
>(range: T): T {
diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts
index 38f0160a60e7..03056083b477 100644
--- a/src/shared/terminal-file-link-conformance.ts
+++ b/src/shared/terminal-file-link-conformance.ts
@@ -157,5 +157,25 @@ export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConfor
lineText: 'docs/日本語.md」を 参照',
tapText: '日本語',
expected: { pathText: 'docs/日本語.md', line: null, column: null }
+ },
+ // Prose glues onto the left as readily as the right.
+ {
+ name: 'prose glued before a bare filename',
+ lineText: '文書はREADME.mdだ',
+ tapText: 'README',
+ expected: { pathText: 'README.md', line: null, column: null }
+ },
+ {
+ name: 'prose glued before a separator path',
+ lineText: '参照docs/文書.md を見て',
+ tapText: 'docs/',
+ expected: { pathText: 'docs/文書.md', line: null, column: null }
+ },
+ // Only at a non-ASCII -> ASCII boundary: a wholly non-Latin name is a real name.
+ {
+ name: 'wholly non-latin name stays whole',
+ lineText: '参照文書.md を見て',
+ tapText: '参照',
+ expected: { pathText: '参照文書.md', line: null, column: null }
}
]
From ec7d46397a10ba95b4b9e76166417aa7528f3c9c Mon Sep 17 00:00:00 2001
From: lazybuttrying
Date: Tue, 1 Sep 2026 13:56:17 +0900
Subject: [PATCH 5/5] fix(file-links): stop two relative paths on one line from
merging
`docs/a.md see docs/b.md` linked neither file. The spaced-path scan walks
every extension on the line and extends the span when the added segment
contains a separator, guarded by countPathStarts > 1. That guard only
recognises `/`, `./`, `~/` and `C:\` starts, so two *relative* paths never
tripped it: the span ran to the last extension, produced a string that is
not a file, and both real paths went dead. Absolute pairs were unaffected,
which is why this stayed hidden.
Stop extending once the selection is already a finished path - a separator
plus an alphabetic extension. The alphabetic part matters: `/tmp/v1.2` ends
in a numeric `.2`, so a spaced directory name still extends correctly into
`/tmp/v1.2 reports/result.json`.
Early-exiting made several line-ending prefix candidates trim to the same
span, so dedupe emitted spans - `/var/log/app.log` was returned four times.
Mirrored into the mobile tap matcher and its injected WebView copy, with
both halves of `docs/a.md see docs/b.md` in the shared conformance list.
Pre-existing on main and not CJK-specific; found while testing this branch.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01QDkPfyEHWNUW1JQZRe6tKb
---
mobile/src/terminal/terminal-path-tap-injected.ts | 4 ++++
mobile/src/terminal/terminal-path-tap.ts | 7 +++++++
src/renderer/src/lib/terminal-links.ts | 11 +++++++++--
.../src/lib/terminal-spaced-path-trailing-prose.ts | 9 +++++++++
src/shared/terminal-file-link-conformance.ts | 14 ++++++++++++++
5 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts
index fab495bebee7..1194dbbd633b 100644
--- a/mobile/src/terminal/terminal-path-tap-injected.ts
+++ b/mobile/src/terminal/terminal-path-tap-injected.ts
@@ -79,6 +79,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
var end = match.index + match[0].length;
var text = range.text.slice(0, end);
if (countPathStarts(text) > 1) continue;
+ // A finished path plus another hop across whitespace is a second path.
+ if (selected !== null && COMPLETE_PATH.test(selected)) break;
if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
selected = text;
}
@@ -91,6 +93,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
}
+ var COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/;
+
function countPathStarts(text) {
var count = 0;
var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts
index c226389ab438..d3e0d4c47884 100644
--- a/mobile/src/terminal/terminal-path-tap.ts
+++ b/mobile/src/terminal/terminal-path-tap.ts
@@ -95,6 +95,9 @@ function trimSpacedPathTrailingProse(
if (countPathStarts(text) > 1) {
continue
}
+ if (selected !== null && COMPLETE_PATH.test(selected)) {
+ break
+ }
if (
end < range.text.length ||
selected === null ||
@@ -121,6 +124,10 @@ function trimSpacedPathTrailingProse(
}
}
+// Mirror of the renderer rule: a separator plus an alphabetic extension means the
+// span is already a finished path, so a further hop across whitespace is a second one.
+const COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/
+
function countPathStarts(text: string): number {
let count = 0
for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts
index fcbc1a7688a5..775ae07b54ca 100644
--- a/src/renderer/src/lib/terminal-links.ts
+++ b/src/renderer/src/lib/terminal-links.ts
@@ -225,9 +225,16 @@ function detectSpacedLocalPathLinks(
)
)
.filter((link): link is ParsedTerminalFileLink => link !== null)
- const link = candidateLinks[0]
+ // Prefix candidates can now trim to the same span, so emit each span once.
+ const seenSpans = new Set()
+ const uniqueLinks = candidateLinks.filter((candidateLink) =>
+ seenSpans.has(`${candidateLink.startIndex}:${candidateLink.endIndex}`)
+ ? false
+ : (seenSpans.add(`${candidateLink.startIndex}:${candidateLink.endIndex}`), true)
+ )
+ const link = uniqueLinks[0]
if (link) {
- for (const candidateLink of candidateLinks) {
+ for (const candidateLink of uniqueLinks) {
links.push(candidateLink)
}
insertTerminalFileLinkClaimedRange(claimedRanges, [link.startIndex, link.endIndex])
diff --git a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
index 0081e37faec8..028884266148 100644
--- a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
+++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
@@ -8,6 +8,10 @@ import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detecti
// Also stop at non-ASCII: a CJK bracket closes a citation as often as a particle.
const EXTENSION_PREFIX_PATTERN = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu
+// A separator plus an alphabetic extension means the span is already a finished
+// path; `v1.2` is not, so a numeric tail still extends into `v1.2 reports/x.json`.
+const COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/
+
function countPathStarts(text: string): number {
let count = 0
for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
@@ -29,6 +33,11 @@ export function trimSpacedPathTrailingProse(
if (countPathStarts(text) > 1) {
continue
}
+ // Why: countPathStarts only sees absolute starts, so two relative paths on one
+ // line used to merge into a span that resolves to nothing and killed both.
+ if (selected !== null && COMPLETE_PATH.test(selected)) {
+ break
+ }
if (
end < range.text.length ||
selected === null ||
diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts
index 03056083b477..3ccc1f28a16c 100644
--- a/src/shared/terminal-file-link-conformance.ts
+++ b/src/shared/terminal-file-link-conformance.ts
@@ -171,6 +171,20 @@ export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConfor
tapText: 'docs/',
expected: { pathText: 'docs/文書.md', line: null, column: null }
},
+ // Two relative paths on one line must stay separate; they used to merge into a
+ // span that resolved to nothing and killed both.
+ {
+ name: 'two relative paths, first',
+ lineText: 'docs/a.md see docs/b.md',
+ tapText: 'a.md',
+ expected: { pathText: 'docs/a.md', line: null, column: null }
+ },
+ {
+ name: 'two relative paths, second',
+ lineText: 'docs/a.md see docs/b.md',
+ tapText: 'b.md',
+ expected: { pathText: 'docs/b.md', line: null, column: null }
+ },
// Only at a non-ASCII -> ASCII boundary: a wholly non-Latin name is a real name.
{
name: 'wholly non-latin name stays whole',