From 4032ffb6d51bef727ba54f9414b44256bfd349a0 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:15:55 +0900 Subject: [PATCH] Never cut an emoji in half at the text cap The compact view cuts a long block at TEXT_CAP UTF-16 units. An emoji, or any character past U+FFFF, is two of them, so when its first unit was the last one inside the cap the view printed half of it: a lone surrogate, which a terminal shows as the replacement character and which makes the printed line malformed UTF-16. A block with no sentence end inside the cap (a post, a title, a list of tags) takes this plain cut. The plain cut now steps back one unit when it would land between the two halves. The line and sentence cuts already end on a newline or on punctuation, so they never split one. Co-Authored-By: Claude Opus 5.5 --- src/render.js | 5 ++++- tests/distill.test.js | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/render.js b/src/render.js index f24d905..6f36b03 100644 --- a/src/render.js +++ b/src/render.js @@ -250,7 +250,10 @@ const truncate = (s) => { // instead of falling through to the sentence rule below. const line = s.slice(0, TEXT_CAP).lastIndexOf('\n'); if (line >= TEXT_CAP * SENTENCE_FLOOR) return `${s.slice(0, line)} ... +${num(s.length - line)} chars`; - let cut = TEXT_CAP; + // A character past U+FFFF (an emoji, a rare CJK ideograph) is two UTF-16 + // units. A cut between them printed half of one, which a terminal shows as + // the replacement character, so the cut steps back to before it. + let cut = /[\uD800-\uDBFF]/.test(s[TEXT_CAP - 1]) ? TEXT_CAP - 1 : TEXT_CAP; for (const m of s.slice(0, TEXT_CAP).matchAll(SENTENCE_END)) { const end = (m.index ?? 0) + m[0].length; if (end >= TEXT_CAP * SENTENCE_FLOOR) cut = end; diff --git a/tests/distill.test.js b/tests/distill.test.js index b66ea2f..d365fda 100644 --- a/tests/distill.test.js +++ b/tests/distill.test.js @@ -592,6 +592,15 @@ test('a truncated block ends on a sentence, so what is shown can be trusted', () assert.ok(unbroken.length > TEXT_CAP, `an early sentence end must not shrink the view:\n${unbroken}`); }); +test('a cut at the cap never splits an emoji in half', () => { + // An emoji is two UTF-16 units. With its first unit the last one inside + // the cap, the view printed half of it, which a terminal shows as U+FFFD. + const text = `${'x'.repeat(TEXT_CAP - 1)}\u{1F600} and the rest of the post`; + const line = render({ url: '', title: '', blocks: [{ n: 1, type: 'text', text }] }, { budget: 60 }).text; + assert.ok(line.isWellFormed(), `a lone surrogate reached the view: ${JSON.stringify(line.slice(TEXT_CAP - 2, TEXT_CAP + 20))}`); + assert.match(line, new RegExp(`x{${TEXT_CAP - 1}} \\.\\.\\. \\+${text.length - TEXT_CAP + 1} chars`)); +}); + test('a highlighted command comes out runnable', () => { // Every token of this command is its own element on the page. Space-joining // them gave `aws s3 cp s3 : // bucket / -- recursive`, which is not a command