diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx
index cc88c01e5644..63e2a7dc2093 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 { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
@@ -132,7 +133,7 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
onOpenFile(normalizeFilePath(code.trim()))}
+ onPress={() => onOpenFile(normalizeFilePath(trimFileLinkNonAsciiProse(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..bb6ec43c5eb7 100644
--- a/mobile/src/components/markdown-file-path-detection.test.ts
+++ b/mobile/src/components/markdown-file-path-detection.test.ts
@@ -210,6 +210,28 @@ 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 +278,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..0513b4dec69b 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 { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
export type FilePathSegment =
| { type: 'text'; value: string }
@@ -88,8 +89,10 @@ 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; the
+// extension stays ASCII so particles after `.md` fall outside the match.
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 +127,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(trimFileLinkNonAsciiProse(pathText))
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
@@ -178,12 +182,12 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
CANDIDATE_PATTERN.lastIndex = 0
while ((match = CANDIDATE_PATTERN.exec(text))) {
- const candidate = 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).
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 +216,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 = trimFileLinkNonAsciiProse(code.trim())
if (!trimmed || /\s/.test(trimmed)) {
return false
}
@@ -234,7 +238,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..1194dbbd633b 100644
--- a/mobile/src/terminal/terminal-path-tap-injected.ts
+++ b/mobile/src/terminal/terminal-path-tap-injected.ts
@@ -14,10 +14,13 @@
// 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 = /^(.*\.[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);
@@ -39,6 +42,22 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
}
+ 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 };
+ }
+
+ 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++) {
@@ -53,12 +72,15 @@ 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 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;
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;
}
@@ -71,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;
@@ -91,8 +115,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 = trimNonAsciiProse(proseTrimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
@@ -110,8 +135,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 = trimNonAsciiProse(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..9d648dadfd0e 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..d3e0d4c47884 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 { trimFileLinkRangeNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
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 trimFileLinkRangeNonAsciiProse 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,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
- const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
+ // 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
@@ -90,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 ||
@@ -116,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)) {
@@ -148,10 +160,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 = trimFileLinkRangeNonAsciiProse(proseTrimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
@@ -181,10 +194,11 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
if (!trimmed) {
continue
}
- if (col < trimmed.startIndex || col >= trimmed.endIndex) {
+ const candidate = trimFileLinkRangeNonAsciiProse(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/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 364ebaffd6cd..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,3 +1,4 @@
+import { trimFileLinkRangeNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
import {
detectTerminalFileLinkRanges,
terminalFileLinkRangesOverlap,
@@ -23,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 {
@@ -37,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)
}
@@ -58,7 +64,8 @@ export function detectBareFilenameLinks(
if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) {
continue
}
- const link = toParsedTerminalFileLink(range)
+ // Trim first: `README.mdへ` fails looksLikeFilename and never becomes a link.
+ const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(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..34fd7240f672 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..6b2dfacc35a2 100644
--- a/src/renderer/src/lib/terminal-links.test.ts
+++ b/src/renderer/src/lib/terminal-links.test.ts
@@ -112,6 +112,59 @@ describe('terminal path helpers', () => {
expect(performance.now() - startedAt).toBeLessThan(100)
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'],
+ ['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 +268,82 @@ 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([
+ ['Japanese', '/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; 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')}`
+ 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 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..775ae07b54ca 100644
--- a/src/renderer/src/lib/terminal-links.ts
+++ b/src/renderer/src/lib/terminal-links.ts
@@ -1,3 +1,4 @@
+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'
@@ -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,10 @@ 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 +177,7 @@ function detectLocalPathLinks(
if (!/[\\/]/.test(range.text)) {
continue
}
- const link = toParsedTerminalFileLink(range)
+ const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range))
if (link) {
links.push(link)
}
@@ -259,13 +219,22 @@ function detectSpacedLocalPathLinks(
const candidateLinks = candidateRanges
.map((candidateRange) =>
toParsedTerminalFileLink(
- trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))
+ trimFileLinkRangeNonAsciiProse(
+ trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))
+ )
)
)
.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
new file mode 100644
index 000000000000..028884266148
--- /dev/null
+++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts
@@ -0,0 +1,57 @@
+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 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)) {
+ 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
+ }
+ // 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 ||
+ /[\\/]/.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/non-ascii-terminal-text-boundary.test.ts b/src/shared/non-ascii-terminal-text-boundary.test.ts
new file mode 100644
index 000000000000..27ae107f7309
--- /dev/null
+++ b/src/shared/non-ascii-terminal-text-boundary.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import {
+ isNonAsciiProseBoundary,
+ trimFileLinkRangeTrailingNonAsciiProse,
+ trimFileLinkTrailingNonAsciiProse
+} from './non-ascii-terminal-text-boundary'
+
+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('trimFileLinkTrailingNonAsciiProse (file-link mirror of #15240)', () => {
+ 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'],
+ // 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(trimFileLinkTrailingNonAsciiProse(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(trimFileLinkTrailingNonAsciiProse(input)).toBe(input)
+ })
+
+ it('adjusts endIndex when trimming a range', () => {
+ const range = {
+ text: 'README.mdへ',
+ startIndex: 10,
+ endIndex: 10 + 'README.mdへ'.length
+ }
+ 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
new file mode 100644
index 000000000000..8465e15de190
--- /dev/null
+++ b/src/shared/non-ascii-terminal-text-boundary.ts
@@ -0,0 +1,65 @@
+/**
+ * Non-ASCII boundary rules for terminal autolink spans (#15240).
+ *
+ * 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.
+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))
+}
+
+// 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 trimFileLinkTrailingNonAsciiProse(text: string): string {
+ const match = ASCII_EXT_THEN_NON_ASCII.exec(text)
+ 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 {
+ const trimmed = trimFileLinkTrailingNonAsciiProse(range.text)
+ if (trimmed === range.text) {
+ return range
+ }
+ return {
+ ...range,
+ text: trimmed,
+ endIndex: range.startIndex + trimmed.length
+ }
+}
diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts
index d74af87eab26..3ccc1f28a16c 100644
--- a/src/shared/terminal-file-link-conformance.ts
+++ b/src/shared/terminal-file-link-conformance.ts
@@ -137,5 +137,59 @@ 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 }
+ },
+ // 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 }
+ },
+ // 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',
+ lineText: '参照文書.md を見て',
+ tapText: '参照',
+ expected: { pathText: '参照文書.md', line: null, column: null }
}
]