diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts new file mode 100644 index 000000000000..143d1b6fb4b9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts @@ -0,0 +1,71 @@ +/** + * Code-point boundary checks for terminal HTTP link scanning. + * Why extracted: keeps hit-testing under max-lines while hosting CJK-aware rules (#10571). + */ + +// Why (#10571 review): enumerate glyphs forever misses 〜/〖〗/NBSP; property classes scale. +const NON_ASCII_BODY_TERMINATOR_RE = /[\p{White_Space}\p{P}\p{Sm}]/u + +export function isHttpUrlBodyTerminator(code: number): boolean { + if (code <= 0x7f) { + return isAsciiUrlWhitespace(code) || isAsciiUrlBodyDelimiter(code) + } + return isNonAsciiUrlBodyTerminator(code) +} + +export function isHttpUrlTrailingPunctuation(code: number): boolean { + if (code <= 0x7f) { + return isAsciiUrlWhitespace(code) || isAsciiUrlTrailingPunctuation(code) + } + // Why: max-length cuts can leave non-ASCII punct at the candidate tail. + return isNonAsciiUrlBodyTerminator(code) +} + +/** + * Why (#10571 review): if a terminator is missed, CJK must not fold into the + * authority — WHATWG IDNA would silently retarget the link (spoof surface). + * Paths/queries still accept non-ASCII after `/` `?` `#`. + */ +export function isHttpUrlAuthorityCodeAllowed(code: number): boolean { + return code <= 0x7f +} + +function isNonAsciiUrlBodyTerminator(code: number): boolean { + return NON_ASCII_BODY_TERMINATOR_RE.test(String.fromCodePoint(code)) +} + +function isAsciiUrlWhitespace(code: number): boolean { + return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 +} + +function isAsciiUrlBodyDelimiter(code: number): boolean { + return ( + code === 0x22 || // " + code === 0x27 || // ' + code === 0x21 || // ! + code === 0x2a || // * + code === 0x28 || // ( + code === 0x29 || // ) + code === 0x7b || // { + code === 0x7d || // } + code === 0x7c || // | + code === 0x5c || // \ + code === 0x5e || // ^ + code === 0x3c || // < + code === 0x3e || // > + code === 0x60 // ` + ) +} + +function isAsciiUrlTrailingPunctuation(code: number): boolean { + return ( + isAsciiUrlBodyDelimiter(code) || + code === 0x3a || // : + code === 0x2c || // , + code === 0x2e || // . + code === 0x3f || // ? + code === 0x7e || // ~ + code === 0x5b || // [ + code === 0x5d // ] + ) +} 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 192c4db15e1b..afb19082ceec 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,9 @@ import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' +import { + isHttpUrlAuthorityCodeAllowed, + isHttpUrlBodyTerminator, + isHttpUrlTrailingPunctuation +} from './terminal-http-url-boundary' type ParsedTerminalHttpLink = { url: string @@ -76,14 +81,41 @@ function hasHttpUrlWordBoundary(lineText: string, startIndex: number): boolean { function findHttpUrlCandidateEnd(lineText: string, startIndex: number): number { const scanEnd = Math.min(lineText.length, startIndex + TERMINAL_HTTP_URL_MAX_LENGTH + 1) - for (let index = startIndex; index < scanEnd; index += 1) { - if (isHttpUrlBodyTerminator(lineText.charCodeAt(index))) { + // Why: authority ends at first / ? #; non-ASCII there must not reach IDNA. + let inAuthority = true + const authorityStart = httpUrlAuthorityStartIndex(lineText, startIndex) + + for (let index = startIndex; index < scanEnd; ) { + const code = lineText.codePointAt(index) ?? 0 + const width = code > 0xffff ? 2 : 1 + + if (inAuthority && index >= authorityStart) { + if (code === 0x2f || code === 0x3f || code === 0x23) { + inAuthority = false + } else if (!isHttpUrlAuthorityCodeAllowed(code)) { + return index + } + } + + if (isHttpUrlBodyTerminator(code)) { return index } + index += width } return scanEnd } +function httpUrlAuthorityStartIndex(lineText: string, startIndex: number): number { + if (lineText.startsWith('https://', startIndex)) { + return startIndex + 'https://'.length + } + if (lineText.startsWith('http://', startIndex)) { + return startIndex + 'http://'.length + } + // Why: scheme finder only yields http(s); treat body start as authority floor. + return startIndex +} + function trimHttpUrlTrailingPunctuation( lineText: string, startIndex: number, @@ -96,56 +128,6 @@ function trimHttpUrlTrailingPunctuation( return endIndex } -function isHttpUrlBodyTerminator(code: number): boolean { - return ( - isAsciiWhitespace(code) || - code === 0x22 || - code === 0x27 || - code === 0x21 || - code === 0x2a || - code === 0x28 || - code === 0x29 || - code === 0x7b || - code === 0x7d || - code === 0x7c || - code === 0x5c || - code === 0x5e || - code === 0x3c || - code === 0x3e || - code === 0x60 - ) -} - -function isHttpUrlTrailingPunctuation(code: number): boolean { - return ( - isAsciiWhitespace(code) || - code === 0x22 || - code === 0x27 || - code === 0x3a || - code === 0x2c || - code === 0x2e || - code === 0x21 || - code === 0x3f || - code === 0x7b || - code === 0x7d || - code === 0x7c || - code === 0x5c || - code === 0x5e || - code === 0x7e || - code === 0x5b || - code === 0x5d || - code === 0x28 || - code === 0x29 || - code === 0x3c || - code === 0x3e || - code === 0x60 - ) -} - -function isAsciiWhitespace(code: number): boolean { - return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 -} - function isAsciiWordCode(code: number): boolean { return ( (code >= 48 && code <= 57) || diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts index 87d03c748b75..5c4ce90adff0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts @@ -46,4 +46,104 @@ describe('extractTerminalHttpLinks', () => { ]) expect(matchAllSpy).not.toHaveBeenCalled() }) + + it('terminates URLs on fullwidth parentheses without swallowing CJK annotation text', () => { + // Why (#10571): Claude Code often prints Japanese annotations glued to PRs. + const line = 'PR: https://github.com/stablyai/orca/pull/12345(作成済み・マージ待ち)' + const url = 'https://github.com/stablyai/orca/pull/12345' + expect(extractTerminalHttpLinks(line)).toEqual([ + { + url, + startIndex: 'PR: '.length, + endIndex: 'PR: '.length + url.length + } + ]) + }) + + it('terminates URLs on CJK corner brackets and ideographic space', () => { + expect(extractTerminalHttpLinks('詳細は「https://example.com/docs」を参照')).toEqual([ + { + url: 'https://example.com/docs', + startIndex: '詳細は「'.length, + endIndex: '詳細は「https://example.com/docs'.length + } + ]) + + const ideographicSpaceLine = 'PR: https://example.com/pull/2 (全角スペース区切り)' + const url = 'https://example.com/pull/2' + expect(extractTerminalHttpLinks(ideographicSpaceLine)).toEqual([ + { + url, + startIndex: 'PR: '.length, + endIndex: 'PR: '.length + url.length + } + ]) + }) + + it('still terminates on half-width spaces and parentheses next to CJK text', () => { + const spaced = 'PR: https://example.com/pull/2 (作成済み)' + const paren = 'PR: https://example.com/pull/2 (作成済み)' + const url = 'https://example.com/pull/2' + expect(extractTerminalHttpLinks(spaced)[0]?.url).toBe(url) + expect(extractTerminalHttpLinks(paren)[0]?.url).toBe(url) + }) + + it('terminates on CJK punctuation so prose is not IDNA-folded into the host', () => { + // Why (#10571 review): trailing-only trim never saw 、。・ — body must stop first. + const cases: { line: string; end: string }[] = [ + { line: 'https://example.com、それから', end: 'https://example.com' }, + { line: 'https://example.com。次の行', end: 'https://example.com' }, + { line: 'https://example.com・次', end: 'https://example.com' }, + { line: 'https://example.com!注意', end: 'https://example.com' }, + { line: 'https://example.com?詳細', end: 'https://example.com' } + ] + for (const { line, end } of cases) { + expect(extractTerminalHttpLinks(line)).toEqual([ + { url: 'https://example.com/', startIndex: 0, endIndex: end.length } + ]) + } + }) + + it('guards authority so bare CJK after the host cannot retarget via IDNA', () => { + // Why: defense in depth if a terminator is ever missed — letters aren't \p{P}. + const line = 'https://example.comそれから' + expect(extractTerminalHttpLinks(line)).toEqual([ + { + url: 'https://example.com/', + startIndex: 0, + endIndex: 'https://example.com'.length + } + ]) + }) + + it('still allows non-ASCII in the path after leaving authority', () => { + const line = 'see https://example.com/ドキュメント' + const links = extractTerminalHttpLinks(line) + expect(links).toHaveLength(1) + expect(links[0]?.url).toBe(new URL('https://example.com/ドキュメント').toString()) + expect(links[0]?.endIndex).toBe(line.length) + }) + + it('terminates on Unicode whitespace beyond ideographic space', () => { + // Why (CodeRabbit): NBSP / EM SPACE / NARROW NO-BREAK SPACE are \p{White_Space}. + for (const ws of ['\u00a0', '\u2003', '\u202f']) { + const line = `https://example.com${ws}next` + expect(extractTerminalHttpLinks(line)).toEqual([ + { + url: 'https://example.com/', + startIndex: 0, + endIndex: 'https://example.com'.length + } + ]) + } + }) + + it('terminates on property-class punctuation not in the old glyph list', () => { + // Why: 〜 is Pd, ~ is Sm, 〖〗 were missing from the enumerated bracket set. + for (const punct of ['〜', '~', '〖', '〗']) { + const line = `https://example.com${punct}続き` + expect(extractTerminalHttpLinks(line)[0]?.url).toBe('https://example.com/') + expect(extractTerminalHttpLinks(line)[0]?.endIndex).toBe('https://example.com'.length) + } + }) })