From f4d0d0cfe17a5e2b4cb79f9046991f5e60bdcb36 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 9 Aug 2026 00:35:53 +0800 Subject: [PATCH 1/7] fix(cli): stop bare-URL hyperlinks at full-width CJK punctuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-URL linkifier used https?://\S+ in both the React inline and ANSI table renderers; \S swallows full-width punctuation that Chinese prose glues onto URLs (https://x.com(2 …), turning the OSC 8 target into a 404. Share a BARE_URL_PATTERN that stops at CJK/full-width punctuation and extend trimTrailingUrlPunctuation to full-width trailing punctuation. Resolves #8750. --- .../ui/utils/InlineMarkdownRenderer.test.tsx | 16 +++++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 3 +- packages/cli/src/ui/utils/TableRenderer.tsx | 3 +- packages/cli/src/ui/utils/osc8.test.ts | 11 ++++ packages/cli/src/ui/utils/osc8.ts | 64 ++++++++++++++----- 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx index 02a88765488..882b4de80d7 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx @@ -248,6 +248,22 @@ math then literal: $x^2\$$`; expect(out).not.toContain(`\x1b]8;;${url}.\x07`); }); + it('stops the bare-URL hyperlink at glued-on full-width CJK punctuation', () => { + enableHyperlinks(); + const url = 'https://github.com/QwenLM/qwen-code/pull/8742'; + const { lastFrame } = renderWithProviders( + , + ); + + const out = lastFrame() ?? ''; + // OSC 8 target is exactly the URL — the (2 run is not swallowed into + // the link (https://github.com/QwenLM/qwen-code/issues/8750). + expect(out).toContain(`\x1b]8;;${url}\x07`); + expect(out).not.toContain(`\x1b]8;;${url}(`); + // The glued-on punctuation renders as plain text after the link. + expect(out.replace(/\s+/g, ' ')).toContain('(2 commits,等 CI)'); + }); + it('leaves bare URLs unwrapped when unsupported', () => { const url = 'https://example.com/plain'; const { lastFrame } = renderWithProviders( diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index d0984805cbc..d1764e1fad8 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -11,6 +11,7 @@ import stringWidth from 'string-width'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { renderInlineLatex } from './latexRenderer.js'; import { + BARE_URL_PATTERN, MD_LINK_CAPTURE, MD_LINK_PATTERN, isSafeOscScheme, @@ -38,7 +39,7 @@ const UNDERLINE_TAG_START_LENGTH = 3; // For "" const UNDERLINE_TAG_END_LENGTH = 4; // For "" const INLINE_MARKDOWN_REGEX = new RegExp( String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + - String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|.*?<\/u>|https?:\/\/\S+)`, + String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|.*?<\/u>|${BARE_URL_PATTERN})`, 'g', ); diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 6ab6c1f0818..8bb94bfe0d7 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -19,6 +19,7 @@ import { unescapeMarkdownDollars, } from './inline-math.js'; import { + BARE_URL_PATTERN, MD_LINK_CAPTURE, MD_LINK_PATTERN, isSafeOscScheme, @@ -50,7 +51,7 @@ const SAFETY_MARGIN = 4; const INLINE_MARKDOWN_REGEX = new RegExp( String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + - String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|.*?<\/u>|https?:\/\/\S+)`, + String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|.*?<\/u>|${BARE_URL_PATTERN})`, 'g', ); diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts index 42f38c5e0df..f54256c879a 100644 --- a/packages/cli/src/ui/utils/osc8.test.ts +++ b/packages/cli/src/ui/utils/osc8.test.ts @@ -266,10 +266,21 @@ describe('osc8 helpers', () => { ['https://example.com?q=1)', 'https://example.com?q=1'], ['https://example.com:::', 'https://example.com'], ['https://example.com).', 'https://example.com'], + // Full-width CJK punctuation glues onto URLs in Chinese prose. + ['https://example.com。', 'https://example.com'], + ['https://example.com,', 'https://example.com'], + ['https://example.com(', 'https://example.com'], + ['https://example.com)', 'https://example.com'], + ['https://example.com、', 'https://example.com'], ])('trims sentence punctuation: %s -> %s', (input, expected) => { expect(trimTrailingUrlPunctuation(input)).toBe(expected); }); + it('preserves a trailing full-width close-paren when balanced inside the URL', () => { + const url = 'https://example.com/wiki/Foo(bar)'; + expect(trimTrailingUrlPunctuation(url)).toBe(url); + }); + it('preserves a trailing close-paren when balanced inside the URL', () => { const url = 'https://en.wikipedia.org/wiki/Foo_(bar)'; expect(trimTrailingUrlPunctuation(url)).toBe(url); diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index 095df4ba427..2c403a164e5 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -97,14 +97,16 @@ export function isSafeOscScheme(url: string): boolean { * is unchanged. * * The set of trimmable trailing characters matches GitHub / GitLab linkifier - * behavior. We additionally rebalance a trailing `)` against opening `(` in + * behavior, extended with the full-width CJK equivalents (`。`、`,`;`:`!`?` + * etc.) because Chinese prose glues those onto URLs exactly like ASCII ones. + * We additionally rebalance a trailing `)`/`)` against opening `(`/`(` in * the URL so URLs that legitimately end with `)` (Wikipedia disambiguation, * MSDN) aren't truncated. */ export function trimTrailingUrlPunctuation(url: string): string { - // Count `( [ {` opens once up-front; we then decrement running `)`/`]`/`}` - // close counts as we trim, keeping the whole trim O(n) instead of O(n²) - // for adversarial inputs like `https://x.com))))…`. + // Count `( [ {` opens (ASCII and full-width) once up-front; we then + // decrement running `)`/`]`/`}` close counts as we trim, keeping the whole + // trim O(n) instead of O(n²) for adversarial inputs like `https://x.com))))…`. let openParen = 0; let openBracket = 0; let openBrace = 0; @@ -113,12 +115,12 @@ export function trimTrailingUrlPunctuation(url: string): string { let closeBrace = 0; for (let i = 0; i < url.length; i++) { const cc = url.charCodeAt(i); - if (cc === 0x28) openParen++; - else if (cc === 0x5b) openBracket++; - else if (cc === 0x7b) openBrace++; - else if (cc === 0x29) closeParen++; - else if (cc === 0x5d) closeBracket++; - else if (cc === 0x7d) closeBrace++; + if (cc === 0x28 || cc === 0xff08) openParen++; + else if (cc === 0x5b || cc === 0xff3b) openBracket++; + else if (cc === 0x7b || cc === 0xff5b) openBrace++; + else if (cc === 0x29 || cc === 0xff09) closeParen++; + else if (cc === 0x5d || cc === 0xff3d) closeBracket++; + else if (cc === 0x7d || cc === 0xff5d) closeBrace++; } let end = url.length; @@ -136,23 +138,42 @@ export function trimTrailingUrlPunctuation(url: string): string { c === 0x27 || c === 0x22 || c === 0x60 || - c === 0x3e + c === 0x3e || + // Full-width CJK punctuation: 。 、 , ; : ! ? ' " ` > … – — ― + // plus opening ( [ { (a URL never legitimately ends with one). + c === 0x3002 || + c === 0x3001 || + c === 0xff08 || + c === 0xff3b || + c === 0xff5b || + c === 0xff0c || + c === 0xff1b || + c === 0xff1a || + c === 0xff01 || + c === 0xff1f || + c === 0xff07 || + c === 0xff02 || + c === 0xff40 || + c === 0xff1e || + c === 0x2026 || + (c >= 0x2013 && c <= 0x2015) ) { end--; continue; } - // Trailing `)`/`]`/`}` only when unbalanced against opens in the prefix. - if (c === 0x29 && closeParen > openParen) { + // Trailing `)`/`]`/`}` (ASCII or full-width) only when unbalanced + // against opens in the prefix. + if ((c === 0x29 || c === 0xff09) && closeParen > openParen) { closeParen--; end--; continue; } - if (c === 0x5d && closeBracket > openBracket) { + if ((c === 0x5d || c === 0xff3d) && closeBracket > openBracket) { closeBracket--; end--; continue; } - if (c === 0x7d && closeBrace > openBrace) { + if ((c === 0x7d || c === 0xff5d) && closeBrace > openBrace) { closeBrace--; end--; continue; @@ -178,6 +199,19 @@ export const MD_LINK_PATTERN = String.raw`\[.*?\]\((?:[^()]|\([^()]*\))*\)`; */ export const MD_LINK_CAPTURE = /^\[(.*?)\]\(((?:[^()]|\([^()]*\))*)\)$/; +/** + * Bare-URL pattern shared between the React and ANSI renderers. Unlike a + * plain `\S+` run it stops at CJK / full-width punctuation: Chinese prose + * routinely glues `(…)`/`。` onto a URL with no space + * (`https://x.com(2 commits)`), and `\S` swallows the punctuation plus + * everything up to the next ASCII space, turning the OSC 8 target into a + * 404. Raw CJK ideographs stay in the match — un-percent-encoded IRI paths + * are plausible; full-width punctuation never is. ASCII trailing punctuation + * is still matched and left to `trimTrailingUrlPunctuation` so visible bytes + * stay unchanged on unsupported terminals. + */ +export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u2013-\u2015\u2018-\u201f\u2026\u3000-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; + /** * Should the markdown renderers wrap a `[label](url)` token in an OSC 8 * envelope? Returns true only when (a) the host terminal advertises OSC 8, From 8b01f1099dc18acdc05e40583e4a9dcc55366358 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 9 Aug 2026 13:38:18 +0800 Subject: [PATCH 2/7] fix(cli): preserve CJK URL word characters --- .../cli/src/ui/utils/TableRenderer.test.tsx | 10 ++++ packages/cli/src/ui/utils/osc8.test.ts | 22 +++---- packages/cli/src/ui/utils/osc8.ts | 59 +++++++------------ 3 files changed, 41 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index 61eecb3cfa6..9e071e9da62 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -649,6 +649,16 @@ describe('', () => { expectAllLinesToHaveSameVisibleWidth(output); }); + it('stops a bare URL before glued-on CJK punctuation', () => { + enableHyperlinks(); + const url = 'https://github.com/QwenLM/qwen-code/pull/8742'; + const suffix = '(2 commits,等 CI)'; + const output = renderTable(['PR'], [[`PR:${url}${suffix}`]], 100); + expect(output).toContain(`\x1b]8;;${url}\x07`); + expect(output).not.toContain(`\x1b]8;;${url}(`); + expect(stripAnsi(output)).toContain(suffix); + }); + it('falls back to legacy `label (url)` in cells on unsupported terminals', () => { // isTTY=false from the suite-wide beforeEach disables hyperlinks. const url = 'https://example.com/page'; diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts index f54256c879a..7797db67c04 100644 --- a/packages/cli/src/ui/utils/osc8.test.ts +++ b/packages/cli/src/ui/utils/osc8.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + BARE_URL_PATTERN, HYPERLINK_ENV_KEYS, isSafeOscScheme, labelMayDeceive, @@ -266,21 +267,10 @@ describe('osc8 helpers', () => { ['https://example.com?q=1)', 'https://example.com?q=1'], ['https://example.com:::', 'https://example.com'], ['https://example.com).', 'https://example.com'], - // Full-width CJK punctuation glues onto URLs in Chinese prose. - ['https://example.com。', 'https://example.com'], - ['https://example.com,', 'https://example.com'], - ['https://example.com(', 'https://example.com'], - ['https://example.com)', 'https://example.com'], - ['https://example.com、', 'https://example.com'], ])('trims sentence punctuation: %s -> %s', (input, expected) => { expect(trimTrailingUrlPunctuation(input)).toBe(expected); }); - it('preserves a trailing full-width close-paren when balanced inside the URL', () => { - const url = 'https://example.com/wiki/Foo(bar)'; - expect(trimTrailingUrlPunctuation(url)).toBe(url); - }); - it('preserves a trailing close-paren when balanced inside the URL', () => { const url = 'https://en.wikipedia.org/wiki/Foo_(bar)'; expect(trimTrailingUrlPunctuation(url)).toBe(url); @@ -305,6 +295,16 @@ describe('osc8 helpers', () => { }); }); + describe('BARE_URL_PATTERN', () => { + it.each([ + 'https://ja.wikipedia.org/wiki/人々', + 'https://example.com/二〇二六年報', + 'https://zh.wikipedia.org/wiki/北京', + ])('keeps word-forming CJK characters in %s', (url) => { + expect(new RegExp(BARE_URL_PATTERN).exec(url)?.[0]).toBe(url); + }); + }); + describe('supportsHyperlinks', () => { function setTTY(value: boolean) { Object.defineProperty(process.stdout, 'isTTY', { diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index 2c403a164e5..387793d0764 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -97,14 +97,12 @@ export function isSafeOscScheme(url: string): boolean { * is unchanged. * * The set of trimmable trailing characters matches GitHub / GitLab linkifier - * behavior, extended with the full-width CJK equivalents (`。`、`,`;`:`!`?` - * etc.) because Chinese prose glues those onto URLs exactly like ASCII ones. - * We additionally rebalance a trailing `)`/`)` against opening `(`/`(` in + * behavior. We additionally rebalance a trailing `)` against opening `(` in * the URL so URLs that legitimately end with `)` (Wikipedia disambiguation, * MSDN) aren't truncated. */ export function trimTrailingUrlPunctuation(url: string): string { - // Count `( [ {` opens (ASCII and full-width) once up-front; we then + // Count `( [ {` opens once up-front; we then // decrement running `)`/`]`/`}` close counts as we trim, keeping the whole // trim O(n) instead of O(n²) for adversarial inputs like `https://x.com))))…`. let openParen = 0; @@ -115,12 +113,12 @@ export function trimTrailingUrlPunctuation(url: string): string { let closeBrace = 0; for (let i = 0; i < url.length; i++) { const cc = url.charCodeAt(i); - if (cc === 0x28 || cc === 0xff08) openParen++; - else if (cc === 0x5b || cc === 0xff3b) openBracket++; - else if (cc === 0x7b || cc === 0xff5b) openBrace++; - else if (cc === 0x29 || cc === 0xff09) closeParen++; - else if (cc === 0x5d || cc === 0xff3d) closeBracket++; - else if (cc === 0x7d || cc === 0xff5d) closeBrace++; + if (cc === 0x28) openParen++; + else if (cc === 0x5b) openBracket++; + else if (cc === 0x7b) openBrace++; + else if (cc === 0x29) closeParen++; + else if (cc === 0x5d) closeBracket++; + else if (cc === 0x7d) closeBrace++; } let end = url.length; @@ -138,42 +136,23 @@ export function trimTrailingUrlPunctuation(url: string): string { c === 0x27 || c === 0x22 || c === 0x60 || - c === 0x3e || - // Full-width CJK punctuation: 。 、 , ; : ! ? ' " ` > … – — ― - // plus opening ( [ { (a URL never legitimately ends with one). - c === 0x3002 || - c === 0x3001 || - c === 0xff08 || - c === 0xff3b || - c === 0xff5b || - c === 0xff0c || - c === 0xff1b || - c === 0xff1a || - c === 0xff01 || - c === 0xff1f || - c === 0xff07 || - c === 0xff02 || - c === 0xff40 || - c === 0xff1e || - c === 0x2026 || - (c >= 0x2013 && c <= 0x2015) + c === 0x3e ) { end--; continue; } - // Trailing `)`/`]`/`}` (ASCII or full-width) only when unbalanced - // against opens in the prefix. - if ((c === 0x29 || c === 0xff09) && closeParen > openParen) { + // Trailing `)`/`]`/`}` only when unbalanced against opens in the prefix. + if (c === 0x29 && closeParen > openParen) { closeParen--; end--; continue; } - if ((c === 0x5d || c === 0xff3d) && closeBracket > openBracket) { + if (c === 0x5d && closeBracket > openBracket) { closeBracket--; end--; continue; } - if ((c === 0x7d || c === 0xff5d) && closeBrace > openBrace) { + if (c === 0x7d && closeBrace > openBrace) { closeBrace--; end--; continue; @@ -205,12 +184,14 @@ export const MD_LINK_CAPTURE = /^\[(.*?)\]\(((?:[^()]|\([^()]*\))*)\)$/; * routinely glues `(…)`/`。` onto a URL with no space * (`https://x.com(2 commits)`), and `\S` swallows the punctuation plus * everything up to the next ASCII space, turning the OSC 8 target into a - * 404. Raw CJK ideographs stay in the match — un-percent-encoded IRI paths - * are plausible; full-width punctuation never is. ASCII trailing punctuation - * is still matched and left to `trimTrailingUrlPunctuation` so visible bytes - * stay unchanged on unsupported terminals. + * 404. Raw CJK ideographs, U+3005 々, and U+3007 〇 stay in the match because + * they are word-forming IRI characters. Typographic dashes and curly quotes + * are excluded to terminate URLs in spaceless prose, so raw IRIs containing + * those characters are an accepted trade-off. ASCII trailing punctuation is + * still matched and left to `trimTrailingUrlPunctuation` so visible bytes stay + * unchanged on unsupported terminals. */ -export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u2013-\u2015\u2018-\u201f\u2026\u3000-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; +export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u2013-\u2015\u2018-\u201f\u2026\u3000-\u3004\u3006\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; /** * Should the markdown renderers wrap a `[label](url)` token in an OSC 8 From ae76eefd7916b3ec0190225c88f1a8b6df8dceb9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 9 Aug 2026 16:17:38 +0800 Subject: [PATCH 3/7] fix(cli): preserve ideographic closing mark in URLs --- packages/cli/src/ui/utils/osc8.test.ts | 9 +++++++++ packages/cli/src/ui/utils/osc8.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts index 7797db67c04..bece36c8d95 100644 --- a/packages/cli/src/ui/utils/osc8.test.ts +++ b/packages/cli/src/ui/utils/osc8.test.ts @@ -298,11 +298,20 @@ describe('osc8 helpers', () => { describe('BARE_URL_PATTERN', () => { it.each([ 'https://ja.wikipedia.org/wiki/人々', + 'https://ja.wikipedia.org/wiki/〆切', 'https://example.com/二〇二六年報', 'https://zh.wikipedia.org/wiki/北京', ])('keeps word-forming CJK characters in %s', (url) => { expect(new RegExp(BARE_URL_PATTERN).exec(url)?.[0]).toBe(url); }); + + it('stops before common CJK punctuation', () => { + expect( + new RegExp(BARE_URL_PATTERN).exec( + 'https://example.com/page。下一步', + )?.[0], + ).toBe('https://example.com/page'); + }); }); describe('supportsHyperlinks', () => { diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index 387793d0764..ad07e8f211e 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -191,7 +191,7 @@ export const MD_LINK_CAPTURE = /^\[(.*?)\]\(((?:[^()]|\([^()]*\))*)\)$/; * still matched and left to `trimTrailingUrlPunctuation` so visible bytes stay * unchanged on unsupported terminals. */ -export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u2013-\u2015\u2018-\u201f\u2026\u3000-\u3004\u3006\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; +export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u2013-\u2015\u2018-\u201f\u2026\u3000-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; /** * Should the markdown renderers wrap a `[label](url)` token in an OSC 8 From 01a37f346386899742dac3174d3364ab384ee196 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 10 Aug 2026 20:34:07 +0800 Subject: [PATCH 4/7] fix(cli): preserve typographic IRI punctuation --- packages/cli/src/ui/utils/osc8.test.ts | 8 ++++++++ packages/cli/src/ui/utils/osc8.ts | 10 ++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts index bece36c8d95..e0e8f47158d 100644 --- a/packages/cli/src/ui/utils/osc8.test.ts +++ b/packages/cli/src/ui/utils/osc8.test.ts @@ -305,6 +305,14 @@ describe('osc8 helpers', () => { expect(new RegExp(BARE_URL_PATTERN).exec(url)?.[0]).toBe(url); }); + it.each([ + 'https://en.wikipedia.org/wiki/Mexico–United_States_border', + 'https://example.com/it’s', + 'https://example.com/thing…', + ])('keeps typographic punctuation inside %s', (url) => { + expect(new RegExp(BARE_URL_PATTERN).exec(url)?.[0]).toBe(url); + }); + it('stops before common CJK punctuation', () => { expect( new RegExp(BARE_URL_PATTERN).exec( diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index ad07e8f211e..58b015ee784 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -185,13 +185,11 @@ export const MD_LINK_CAPTURE = /^\[(.*?)\]\(((?:[^()]|\([^()]*\))*)\)$/; * (`https://x.com(2 commits)`), and `\S` swallows the punctuation plus * everything up to the next ASCII space, turning the OSC 8 target into a * 404. Raw CJK ideographs, U+3005 々, and U+3007 〇 stay in the match because - * they are word-forming IRI characters. Typographic dashes and curly quotes - * are excluded to terminate URLs in spaceless prose, so raw IRIs containing - * those characters are an accepted trade-off. ASCII trailing punctuation is - * still matched and left to `trimTrailingUrlPunctuation` so visible bytes stay - * unchanged on unsupported terminals. + * they are word-forming IRI characters. ASCII and typographic trailing + * punctuation stays matched and is left to `trimTrailingUrlPunctuation` so + * valid raw IRIs and visible bytes remain unchanged. */ -export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u2013-\u2015\u2018-\u201f\u2026\u3000-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; +export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u3000-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; /** * Should the markdown renderers wrap a `[label](url)` token in an OSC 8 From 0aa4fdc8f759a4b31141eb914095fc9f4f0cb725 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 10 Aug 2026 21:17:20 +0800 Subject: [PATCH 5/7] fix(cli): remove redundant NBSP exclusion, add CJK compat/vertical form exclusions Co-Authored-By: wenshao --- packages/cli/src/ui/utils/osc8.test.ts | 22 ++++++++++++++++------ packages/cli/src/ui/utils/osc8.ts | 11 ++++++----- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts index e0e8f47158d..4ce3a39e90b 100644 --- a/packages/cli/src/ui/utils/osc8.test.ts +++ b/packages/cli/src/ui/utils/osc8.test.ts @@ -313,12 +313,22 @@ describe('osc8 helpers', () => { expect(new RegExp(BARE_URL_PATTERN).exec(url)?.[0]).toBe(url); }); - it('stops before common CJK punctuation', () => { - expect( - new RegExp(BARE_URL_PATTERN).exec( - 'https://example.com/page。下一步', - )?.[0], - ).toBe('https://example.com/page'); + it.each([ + ['https://example.com/page。下一步', 'https://example.com/page'], + ['https://example.com/page、続き', 'https://example.com/page'], + ['https://example.com/page:説明', 'https://example.com/page'], + ['https://example.com/page?質問', 'https://example.com/page'], + ['https://example.com/page!注意', 'https://example.com/page'], + ])('stops before CJK punctuation in %s', (input, expected) => { + expect(new RegExp(BARE_URL_PATTERN).exec(input)?.[0]).toBe(expected); + }); + + it.each([ + ['https://example.com/x﹙備註﹚', 'https://example.com/x'], + ['https://example.com/a︒後続', 'https://example.com/a'], + ['https://example.com/p﹖説明', 'https://example.com/p'], + ])('stops before CJK compat/vertical forms in %s', (input, expected) => { + expect(new RegExp(BARE_URL_PATTERN).exec(input)?.[0]).toBe(expected); }); }); diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index 58b015ee784..5c91746b792 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -184,12 +184,13 @@ export const MD_LINK_CAPTURE = /^\[(.*?)\]\(((?:[^()]|\([^()]*\))*)\)$/; * routinely glues `(…)`/`。` onto a URL with no space * (`https://x.com(2 commits)`), and `\S` swallows the punctuation plus * everything up to the next ASCII space, turning the OSC 8 target into a - * 404. Raw CJK ideographs, U+3005 々, and U+3007 〇 stay in the match because - * they are word-forming IRI characters. ASCII and typographic trailing - * punctuation stays matched and is left to `trimTrailingUrlPunctuation` so - * valid raw IRIs and visible bytes remain unchanged. + * 404. Raw CJK ideographs, U+3005 々, U+3006 〆, and U+3007 〇 stay in the + * match because they are word-forming IRI characters. The exclusion also + * covers CJK Compatibility Forms (U+FE30–U+FE6F) and Vertical Forms + * (U+FE10–U+FE1F). ASCII and other typographic punctuation stays matched and + * is left to `trimTrailingUrlPunctuation`. */ -export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u00a0\u3000-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65]+`; +export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u3001-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\ufe10-\ufe1f\ufe30-\ufe6f]+`; /** * Should the markdown renderers wrap a `[label](url)` token in an OSC 8 From c871945afda2b839cbbaef191fbd4feb7a56d792 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 00:48:09 +0800 Subject: [PATCH 6/7] fix(cli): preserve cjk url suffix underscores --- .../src/ui/utils/InlineMarkdownRenderer.test.tsx | 16 ++++++++++++++++ .../cli/src/ui/utils/InlineMarkdownRenderer.tsx | 7 +++++-- packages/cli/src/ui/utils/TableRenderer.test.tsx | 16 ++++++++++++++++ packages/cli/src/ui/utils/TableRenderer.tsx | 7 +++++-- packages/cli/src/ui/utils/osc8.ts | 13 ++++++++++++- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx index 882b4de80d7..ca4562c2d3b 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx @@ -5,6 +5,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import stripAnsi from 'strip-ansi'; import { renderWithProviders } from '../../test-utils/render.js'; import { getPlainTextLength, RenderInline } from './InlineMarkdownRenderer.js'; import { HYPERLINK_ENV_KEYS } from './osc8.js'; @@ -264,6 +265,21 @@ math then literal: $x^2\$$`; expect(out.replace(/\s+/g, ' ')).toContain('(2 commits,等 CI)'); }); + it('does not treat underscores around a later URL as emphasis', () => { + enableHyperlinks(); + const firstUrl = 'https://a.com/x'; + const secondUrl = 'https://b.com/y'; + const { lastFrame } = renderWithProviders( + , + ); + + const out = lastFrame() ?? ''; + expect(out).toContain(`\x1b]8;;${firstUrl}\x07`); + expect(out).toContain(`\x1b]8;;${secondUrl}\x07`); + expect(out.replace(/\s+/g, ' ')).toContain('(说明_1)和'); + expect(stripAnsi(out)).toContain(`${secondUrl}_`); + }); + it('leaves bare URLs unwrapped when unsupported', () => { const url = 'https://example.com/plain'; const { lastFrame } = renderWithProviders( diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index d1764e1fad8..55a9ddb64c6 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -38,7 +38,7 @@ const INLINE_CODE_MARKER_LENGTH = 1; // For "`" const UNDERLINE_TAG_START_LENGTH = 3; // For "" const UNDERLINE_TAG_END_LENGTH = 4; // For "" const INLINE_MARKDOWN_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + + String.raw`(\*\*.*?\*\*|\*.*?\*|(?.*?<\/u>|${BARE_URL_PATTERN})`, 'g', ); @@ -250,7 +250,10 @@ const RenderInlineInternal: React.FC = ({ // alternative is anchored on `https?://`, so `isSafeOscScheme` is // redundant but kept as a cheap defense-in-depth assertion. const trimmedUrl = canHyperlink - ? trimTrailingUrlPunctuation(fullMatch) + ? trimTrailingUrlPunctuation( + fullMatch, + text[index + fullMatch.length], + ) : fullMatch; const wrapOsc8 = canHyperlink && isSafeOscScheme(trimmedUrl); renderedNode = ( diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index 9e071e9da62..d9f95302c0c 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -659,6 +659,22 @@ describe('', () => { expect(stripAnsi(output)).toContain(suffix); }); + it('does not treat underscores around a later URL as emphasis', () => { + enableHyperlinks(); + const firstUrl = 'https://a.com/x'; + const secondUrl = 'https://b.com/y'; + const output = renderTable( + ['PR'], + [[`见 ${firstUrl}(说明_1)和 ${secondUrl}_。`]], + 100, + ); + + expect(output).toContain(`\x1b]8;;${firstUrl}\x07`); + expect(output).toContain(`\x1b]8;;${secondUrl}\x07`); + expect(stripAnsi(output)).toContain('(说明_1)和'); + expect(stripAnsi(output)).toContain(`${secondUrl}_。`); + }); + it('falls back to legacy `label (url)` in cells on unsupported terminals', () => { // isTTY=false from the suite-wide beforeEach disables hyperlinks. const url = 'https://example.com/page'; diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 8bb94bfe0d7..18f3607a32f 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -50,7 +50,7 @@ const ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH = 24; const SAFETY_MARGIN = 4; const INLINE_MARKDOWN_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + + String.raw`(\*\*.*?\*\*|\*.*?\*|(?.*?<\/u>|${BARE_URL_PATTERN})`, 'g', ); @@ -372,7 +372,10 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { } else if (/^https?:\/\//.test(fullMatch)) { const visible = applyColor(fullMatch, theme.text.link); if (canHyperlink) { - const trimmedUrl = trimTrailingUrlPunctuation(fullMatch); + const trimmedUrl = trimTrailingUrlPunctuation( + fullMatch, + text[index + fullMatch.length], + ); rendered = isSafeOscScheme(trimmedUrl) ? `${osc8Open(trimmedUrl)}${visible}${osc8Close()}` : visible; diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index 5c91746b792..1fade67c42c 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -101,7 +101,10 @@ export function isSafeOscScheme(url: string): boolean { * the URL so URLs that legitimately end with `)` (Wikipedia disambiguation, * MSDN) aren't truncated. */ -export function trimTrailingUrlPunctuation(url: string): string { +export function trimTrailingUrlPunctuation( + url: string, + nextCharacter = '', +): string { // Count `( [ {` opens once up-front; we then // decrement running `)`/`]`/`}` close counts as we trim, keeping the whole // trim O(n) instead of O(n²) for adversarial inputs like `https://x.com))))…`. @@ -122,6 +125,14 @@ export function trimTrailingUrlPunctuation(url: string): string { } let end = url.length; + if ( + url.charCodeAt(end - 1) === 0x5f && + /[\u3001-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\ufe10-\ufe1f\ufe30-\ufe6f]/.test( + nextCharacter, + ) + ) { + end--; + } while (end > 0) { const c = url.charCodeAt(end - 1); // .,;:!?'"`> — `>` covers CommonMark autolinks (``) From 3202a6388a1e573018cd2413bd8725e10c884f07 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 10:57:47 +0800 Subject: [PATCH 7/7] fix(cli): preserve CJK URLs and dunder text --- .../ui/utils/InlineMarkdownRenderer.test.tsx | 8 ++++++++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 2 +- .../cli/src/ui/utils/TableRenderer.test.tsx | 6 ++++++ packages/cli/src/ui/utils/TableRenderer.tsx | 2 +- packages/cli/src/ui/utils/osc8.test.ts | 3 +++ packages/cli/src/ui/utils/osc8.ts | 18 +++++++++++------- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx index ca4562c2d3b..93a21979714 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx @@ -280,6 +280,14 @@ math then literal: $x^2\$$`; expect(stripAnsi(out)).toContain(`${secondUrl}_`); }); + it('preserves dunder identifiers as visible text', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(stripAnsi(lastFrame() ?? '')).toContain('__init__'); + }); + it('leaves bare URLs unwrapped when unsupported', () => { const url = 'https://example.com/plain'; const { lastFrame } = renderWithProviders( diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 55a9ddb64c6..4e7f7ec5580 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -38,7 +38,7 @@ const INLINE_CODE_MARKER_LENGTH = 1; // For "`" const UNDERLINE_TAG_START_LENGTH = 3; // For "" const UNDERLINE_TAG_END_LENGTH = 4; // For "" const INLINE_MARKDOWN_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|(?.*?<\/u>|${BARE_URL_PATTERN})`, 'g', ); diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index d9f95302c0c..df9ac6c9fe1 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -675,6 +675,12 @@ describe('', () => { expect(stripAnsi(output)).toContain(`${secondUrl}_。`); }); + it('preserves dunder identifiers as visible text', () => { + const output = renderTable(['Value'], [['Python 的 __init__ 方法']], 60); + + expect(stripAnsi(output)).toContain('__init__'); + }); + it('falls back to legacy `label (url)` in cells on unsupported terminals', () => { // isTTY=false from the suite-wide beforeEach disables hyperlinks. const url = 'https://example.com/page'; diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 18f3607a32f..b2b4cdd8fed 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -50,7 +50,7 @@ const ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH = 24; const SAFETY_MARGIN = 4; const INLINE_MARKDOWN_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|(?.*?<\/u>|${BARE_URL_PATTERN})`, 'g', ); diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts index 4ce3a39e90b..aa9510794b8 100644 --- a/packages/cli/src/ui/utils/osc8.test.ts +++ b/packages/cli/src/ui/utils/osc8.test.ts @@ -300,6 +300,9 @@ describe('osc8 helpers', () => { 'https://ja.wikipedia.org/wiki/人々', 'https://ja.wikipedia.org/wiki/〆切', 'https://example.com/二〇二六年報', + 'https://ja.example.com/〼道と〻次', + 'https://example.com/〱〢', + 'https://example.com/a︳b︴c', 'https://zh.wikipedia.org/wiki/北京', ])('keeps word-forming CJK characters in %s', (url) => { expect(new RegExp(BARE_URL_PATTERN).exec(url)?.[0]).toBe(url); diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index 1fade67c42c..382bab3cdbb 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -88,6 +88,11 @@ export function isSafeOscScheme(url: string): boolean { return SAFE_OSC8_SCHEMES.has(match[1]!.toLowerCase()); } +const BARE_URL_BREAK_CHARACTERS = String.raw`\u3001-\u3004\u3008-\u3020\u302e-\u3030\u3036-\u3037\u303d-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\ufe10-\ufe1f\ufe30-\ufe32\ufe35-\ufe6f`; +// The escaped CJK ranges do not contain literal combining characters. +// eslint-disable-next-line no-misleading-character-class +const BARE_URL_BREAK_PATTERN = new RegExp(`[${BARE_URL_BREAK_CHARACTERS}]`); + /** * Trim trailing sentence punctuation off a bare URL run before it becomes * an OSC 8 target. Models routinely produce `see https://example.com.` and @@ -127,9 +132,7 @@ export function trimTrailingUrlPunctuation( let end = url.length; if ( url.charCodeAt(end - 1) === 0x5f && - /[\u3001-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\ufe10-\ufe1f\ufe30-\ufe6f]/.test( - nextCharacter, - ) + BARE_URL_BREAK_PATTERN.test(nextCharacter) ) { end--; } @@ -197,11 +200,12 @@ export const MD_LINK_CAPTURE = /^\[(.*?)\]\(((?:[^()]|\([^()]*\))*)\)$/; * everything up to the next ASCII space, turning the OSC 8 target into a * 404. Raw CJK ideographs, U+3005 々, U+3006 〆, and U+3007 〇 stay in the * match because they are word-forming IRI characters. The exclusion also - * covers CJK Compatibility Forms (U+FE30–U+FE6F) and Vertical Forms - * (U+FE10–U+FE1F). ASCII and other typographic punctuation stays matched and - * is left to `trimTrailingUrlPunctuation`. + * covers punctuation in CJK Compatibility Forms and Vertical Forms while + * preserving their word-forming repeat marks and vertical low lines. ASCII + * and other typographic punctuation stays matched and is left to + * `trimTrailingUrlPunctuation`. */ -export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s\u3001-\u3004\u3008-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\ufe10-\ufe1f\ufe30-\ufe6f]+`; +export const BARE_URL_PATTERN = String.raw`https?:\/\/[^\s${BARE_URL_BREAK_CHARACTERS}]+`; /** * Should the markdown renderers wrap a `[label](url)` token in an OSC 8