mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 06:05:36 +00:00
fix(pi-tui): stop GFM autolinks at CJK punctuation boundaries (#2917)
* fix(pi-tui): stop GFM autolinks at CJK punctuation boundaries marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK or full-width punctuation right after a bare URL was absorbed into the link text and href (`.../pull/232(本地` rendered as one anchor whose OSC 8 target contained raw CJK and opened a broken address). Register a CjkBoundaryUrlTokenizer (subclass of the upstream StrictStrikethroughTokenizer, which stays byte-identical for re-vendoring) that cuts the autolink match at the first CJK punctuation character before the ASCII backpedal. CJK ideographs inside the URL path itself are preserved. Guarded by new bare-URL CJK cases in test/markdown.test.ts and listed in pi-tui's local-divergence inventory. * fix(pi-tui): keep balanced full-width parens inside autolinked URLs Address review feedback on the CJK autolink boundary: cutting at the first full-width parenthesis anywhere in the match also truncated URLs that legitimately contain balanced full-width parens in their path (e.g. wiki disambiguation pages like .../wiki/中华人民共和国(1949年)). Full-width parens now follow GFM's ASCII-paren rule: a paren-depth scan keeps balanced pairs in the URL and only an unbalanced ( or ) terminates the match. Non-paren CJK punctuation still always terminates it. * fix(pi-tui): keep CJK punctuation inside balanced full-width parens Punctuation inside a balanced full-width parenthetical is deliberate URL content (e.g. .../wiki/中华人民共和国(北京,1949年)), so the non-paren CJK terminator now only applies at paren depth 0. Prose parentheticals contain spaces and never survive marked's match this far, and an unbalanced ( still cuts the match at the open paren.
This commit is contained in:
parent
7475c2e2e3
commit
6cf315b7bd
5 changed files with 196 additions and 1 deletions
5
.changeset/cli-autolink-cjk-boundary.md
Normal file
5
.changeset/cli-autolink-cjk-boundary.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix bare URLs in chat output absorbing the CJK characters that follow them, which made the link unclickable or open a broken address.
|
||||
5
.changeset/pi-tui-autolink-cjk-boundary.md
Normal file
5
.changeset/pi-tui-autolink-cjk-boundary.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/pi-tui": patch
|
||||
---
|
||||
|
||||
Stop GFM autolinks at CJK/full-width punctuation so a bare URL followed by CJK text no longer swallows the CJK characters into the link target.
|
||||
|
|
@ -11,6 +11,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the
|
|||
3. **`src/tui.ts` — truncate overwide lines instead of throwing**: `doRender` truncates overwide lines with `sliceByColumn` while building the per-line processed output (upstream had a "write crash log + throw" block in the differential render path — do not bring it back when syncing). Performance constraint: the width check must go through the `asciiVisibleWidth` fast path in `utils.ts` first (ANSI-aware ASCII scan with an early exit past the limit) and only fall back to `visibleWidth` for non-ASCII lines; `WIDTH_CACHE_SIZE` is 4096 to match. Since divergence 5 below, this check only runs for lines whose raw string reference changed since the previous frame, so the every-line-every-frame scan (and its width-cache FIFO thrash beyond 4096 distinct non-ASCII lines) no longer occurs. Guarding tests: "TUI overwide line handling" in `test/tui-render.test.ts` (exact viewport assertions) and "asciiVisibleWidth" in `test/truncate-to-width.test.ts`.
|
||||
4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — negative-width `repeat` guards**: the `repeat` counts for blank lines, horizontal rules, and the editor's top/bottom borders are clamped to ≥ 0 (two editor border sites; markdown's emptyLine and hr — the hr site is currently unreachable from the render entry and is purely defensive). Guarding tests: the "negative width safety" cases — Text's lives in `test/tui-render.test.ts` (Text has no dedicated test file), Markdown's and TruncatedText's live in their own test files; the editor's is "does not throw at zero or negative widths" inside the "Editor narrow width rendering" group in `test/editor.test.ts`.
|
||||
5. **`src/tui.ts` — per-frame processed-line reuse**: `doRender` keeps the previous frame's raw lines (`previousRawLines`), their processed output (`previousLines`), and per-line kitty image ids (`previousLineImageIds`). A line whose raw string is reference-identical to the previous frame's reuses its processed output (truncation + `normalizeTerminalOutput` + trailing `SEGMENT_RESET`) verbatim, so a steady-state frame costs O(total lines) pointer comparisons plus O(changed lines) real work instead of re-normalizing every line. Image-id consumers (`expandChangedRangeForKittyImages`, `deleteChangedKittyImages`, the frame-end id union) read the cached per-line ids rather than re-scanning line text; upstream has no such cache and re-processes every line every frame (upstream also has `applyLineResets`, which this divergence inlines into the processed-line build). Reuse is only valid when the terminal width is unchanged; width changes re-render everything at the new width so references never match. Guarding tests: "TUI steady-frame processed-line reuse" in `test/tui-render.test.ts`.
|
||||
6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`.
|
||||
|
||||
## Acceptance after syncing from upstream
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,98 @@ class StrictStrikethroughTokenizer extends Tokenizer {
|
|||
}
|
||||
}
|
||||
|
||||
// Local divergence (not upstream; keep StrictStrikethroughTokenizer untouched
|
||||
// for re-vendoring): marked's GFM autolink accepts any non-space characters
|
||||
// after the domain and its backpedal only strips ASCII trailing punctuation,
|
||||
// so CJK/full-width punctuation right after a bare URL is absorbed into the
|
||||
// link text and href (`.../pull/232(本地` becomes one anchor). Cut the match
|
||||
// at the first CJK punctuation character BEFORE the ASCII backpedal so trailing
|
||||
// ASCII punctuation left by the cut is still normalized away. Full-width
|
||||
// parentheses are handled like GFM handles ASCII ones: balanced pairs stay
|
||||
// part of the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside
|
||||
// them included), and only an unbalanced `(` / `)` terminates the match.
|
||||
// Guarded by the "CJK punctuation after bare URLs" tests in
|
||||
// test/markdown.test.ts.
|
||||
const FULLWIDTH_LEFT_PAREN = 0xff08; // (
|
||||
const FULLWIDTH_RIGHT_PAREN = 0xff09; // )
|
||||
const CJK_URL_TERMINATOR_REGEX =
|
||||
/[\u3000-\u303f\uff01-\uff07\uff0a-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\u2013\u2014\u2018\u2019\u201c\u201d\u2026]/;
|
||||
|
||||
/**
|
||||
* Index at which to cut an autolink match, or -1 to keep it whole. Non-paren
|
||||
* CJK punctuation terminates the URL outside of full-width parens; full-width
|
||||
* parens only terminate it when unbalanced. Punctuation inside a balanced
|
||||
* parenthetical (e.g. the ,in (北京,1949年)) stays part of the URL —
|
||||
* prose parentheticals contain spaces and never survive marked's match this
|
||||
* far, so a balanced group is almost always deliberate URL content.
|
||||
*/
|
||||
function findCjkUrlBoundary(match: string): number {
|
||||
let parenDepth = 0;
|
||||
let unmatchedOpen = -1;
|
||||
for (let i = 0; i < match.length; i++) {
|
||||
const code = match.charCodeAt(i);
|
||||
if (code === FULLWIDTH_LEFT_PAREN) {
|
||||
parenDepth++;
|
||||
if (unmatchedOpen === -1) {
|
||||
unmatchedOpen = i;
|
||||
}
|
||||
} else if (code === FULLWIDTH_RIGHT_PAREN) {
|
||||
if (parenDepth === 0) {
|
||||
return i;
|
||||
}
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
unmatchedOpen = -1;
|
||||
}
|
||||
} else if (parenDepth === 0 && CJK_URL_TERMINATOR_REGEX.test(match[i]!)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return parenDepth > 0 ? unmatchedOpen : -1;
|
||||
}
|
||||
|
||||
class CjkBoundaryUrlTokenizer extends StrictStrikethroughTokenizer {
|
||||
override url(src: string): Tokens.Link | undefined {
|
||||
const cap = this.rules.inline.url.exec(src);
|
||||
if (!cap) {
|
||||
return undefined;
|
||||
}
|
||||
// Autolinked emails (cap[2] === "@") skip the backpedal upstream; keep
|
||||
// that behavior exactly.
|
||||
if (cap[2] === "@") {
|
||||
const text = cap[0];
|
||||
return {
|
||||
type: "link",
|
||||
raw: text,
|
||||
text,
|
||||
href: `mailto:${text}`,
|
||||
tokens: [{ type: "text", raw: text, text }],
|
||||
};
|
||||
}
|
||||
const boundary = findCjkUrlBoundary(cap[0]);
|
||||
if (boundary !== -1) {
|
||||
cap[0] = cap[0].slice(0, boundary);
|
||||
}
|
||||
if (!cap[0]) {
|
||||
return undefined;
|
||||
}
|
||||
let previous: string;
|
||||
do {
|
||||
previous = cap[0];
|
||||
cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? "";
|
||||
} while (previous !== cap[0]);
|
||||
const text = cap[0];
|
||||
const href = cap[1] === "www." ? `http://${text}` : text;
|
||||
return {
|
||||
type: "link",
|
||||
raw: text,
|
||||
text,
|
||||
href,
|
||||
tokens: [{ type: "text", raw: text, text }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface LatexToken extends Tokens.Generic {
|
||||
type: "latex" | "latexBlock";
|
||||
text: string;
|
||||
|
|
@ -170,7 +262,7 @@ function trimPartialClosingFences(tokens: readonly Token[]): void {
|
|||
|
||||
const markdownParser = new Marked();
|
||||
markdownParser.setOptions({
|
||||
tokenizer: new StrictStrikethroughTokenizer(),
|
||||
tokenizer: new CjkBoundaryUrlTokenizer(),
|
||||
});
|
||||
markdownParser.use({ extensions: [...LATEX_MARKDOWN_EXTENSIONS] });
|
||||
|
||||
|
|
|
|||
|
|
@ -1603,6 +1603,98 @@ bar`,
|
|||
);
|
||||
assert.ok(!rawPlain.join("").includes("(https://example.com)"), "URL should not appear twice");
|
||||
});
|
||||
|
||||
it("should not absorb CJK punctuation after bare URLs into the link", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown(
|
||||
"PR 已开:https://example.com/app/pull/232(本地 main 已退回 origin/main 保持干净)。",
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
// The hyperlink target must stop at the CJK boundary…
|
||||
assert.ok(
|
||||
joined.includes("\x1b]8;;https://example.com/app/pull/232\x1b\\"),
|
||||
"OSC 8 target should end at the URL, before the full-width parenthesis",
|
||||
);
|
||||
// …and the CJK text must not be part of any hyperlink target.
|
||||
assert.ok(!joined.includes("%EF%BC%88"), "OSC 8 target should not contain encoded CJK");
|
||||
assert.ok(!/\x1b\]8;;[^\x1b]*(/.test(joined), "No hyperlink target should contain CJK characters");
|
||||
// The full source text still renders visibly.
|
||||
const rawPlain = lines.map((line) =>
|
||||
line.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""),
|
||||
);
|
||||
assert.ok(
|
||||
rawPlain.join("").includes("https://example.com/app/pull/232(本地 main 已退回"),
|
||||
"URL and following CJK text should both render",
|
||||
);
|
||||
});
|
||||
|
||||
it("should strip a trailing full-width parenthesis after a bare URL", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("看这个(https://example.com/page)就知道", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(
|
||||
joined.includes("\x1b]8;;https://example.com/page\x1b\\"),
|
||||
"OSC 8 target should exclude the wrapping full-width parenthesis",
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep CJK characters inside the URL path", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("见 https://example.com/wiki/测试页面 的说明", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(
|
||||
joined.includes("\x1b]8;;https://example.com/wiki/测试页面\x1b\\"),
|
||||
"CJK path characters remain part of the hyperlink target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep balanced full-width parentheses inside the URL path", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown(
|
||||
"见 https://example.com/wiki/中华人民共和国(1949年) 的说明",
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(
|
||||
joined.includes("\x1b]8;;https://example.com/wiki/中华人民共和国(1949年)\x1b\\"),
|
||||
"Balanced full-width parens remain part of the hyperlink target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep CJK punctuation inside balanced full-width parentheses", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown(
|
||||
"见 https://example.com/wiki/中华人民共和国(北京,1949年) 的说明",
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(
|
||||
joined.includes("\x1b]8;;https://example.com/wiki/中华人民共和国(北京,1949年)\x1b\\"),
|
||||
"Punctuation inside balanced full-width parens remains part of the hyperlink target",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML-like tags in text", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue