From 973678e50fdc8fd7656674b67205666527052453 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:02:23 +0800 Subject: [PATCH 1/2] test(chat): cover a folded preview that is a fence, a rule or half an emoji The folded thinking row prints one line of its reasoning (#1624), but that line is whatever comes first: a fence (```js) or a horizontal rule can stand in for the thought, a clip at 160 UTF-16 units can cut a surrogate pair in half, and a clipped line never says it was clipped. Cover those three shapes and the all-markup fallback, so #1642 cannot land as a cosmetic change. --- core/test/thinking-row.test.js | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/core/test/thinking-row.test.js b/core/test/thinking-row.test.js index ef3971cc4..c8bdec65d 100644 --- a/core/test/thinking-row.test.js +++ b/core/test/thinking-row.test.js @@ -136,3 +136,43 @@ test('#1636: an opened row hides its preview, so its first line is not printed t /\.bt-think\[open\] \.bt-think-peek\{display:none\}/, ) }) + +test('#1642: a fence or a rule is not the peek line; the first prose line is', () => { + const fenced = [think('```js\nconst x = 1\n```')] + const peek = classed(thinkingRow(render(step(1, fenced), [step(1, fenced)])), 'bt-think-peek') + assert.ok(peek, 'the closed row carries a line of the reasoning') + assert.equal(text(peek), 'const x = 1', 'markup is not what the Bot was thinking') + assert.doesNotMatch(text(peek), /```/) + + const ruled = [think('---\n***\nLet me read it\n___')] + const second = classed(thinkingRow(render(step(1, ruled), [step(1, ruled)])), 'bt-think-peek') + assert.ok(second) + assert.equal(text(second), 'Let me read it', 'a horizontal rule is markup too') +}) + +test('#1642: a cut preview keeps whole characters and says it was cut', () => { + const long = [think(`${'a'.repeat(150)}😀${'b'.repeat(100)}`)] + const peek = classed(thinkingRow(render(step(1, long), [step(1, long)])), 'bt-think-peek') + assert.ok(peek) + const shown = text(peek) + assert.equal(shown.length, 160, 'the cap still holds') + assert.ok(shown.includes('😀'), 'an emoji inside the cap survives whole') + assert.ok(shown.endsWith('…'), 'a clipped line says it was clipped') + assert.doesNotMatch(shown, /[\uD800-\uDFFF]/u, 'no half a surrogate pair') + + // The reported input: a pair cut exactly at the cap used to render as U+FFFD. + const edge = [think(`${'a'.repeat(159)}😀 tail`)] + const cut = text(classed(thinkingRow(render(step(1, edge), [step(1, edge)])), 'bt-think-peek')) + assert.equal(cut.length, 160) + assert.ok(cut.endsWith('…')) + assert.doesNotMatch(cut, /[\uD800-\uDFFF]/u, 'no half a surrogate pair at the cap either') +}) + +test('#1642: reasoning that is only markup gets a neutral summary, never a bare fence or an empty row', () => { + const onlyMarkup = [think('```js\n```')] + const details = thinkingRow(render(step(1, onlyMarkup), [step(1, onlyMarkup)])) + assert.ok(details, 'the row stays') + const peek = classed(details, 'bt-think-peek') + assert.ok(peek, 'the folded row never reads as an empty frame') + assert.equal(text(peek), '2 lines of reasoning') +}) From 0835e88201fba7cecbe4249a4a9c5a32b2efdd23 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:02:39 +0800 Subject: [PATCH 2/2] fix(chat): the folded thinking row previews prose, cut on whole characters The folded row prints one line of its reasoning (#1624), and that line was whatever came first: a fence or a horizontal rule could stand in for the thought, a clip at 160 UTF-16 units could halve an emoji into a lone surrogate (U+FFFD on screen), and a clipped line gave no sign it had been clipped. The preview now skips markup and takes the first line with prose, cuts on grapheme boundaries with an ellipsis inside the same 160-unit cap, and falls back to the line count when the reasoning is only markup, so the row never shows a fence fragment and never an empty frame. core/client.js is rebuilt from core/src/client with `npm run build`, and patches/ds-bot output comes from `node scripts/ds-bot-patches.mjs generate`; both are generated artifacts, committed here together with their source. Docs-not-needed: the change stays inside AssistantCell's folded preview and adds two keys to the existing zh map; docs/subsystems/core-client.md describes the client's structure and the plugin copy that map carries, not the thinking row's preview line, which the page did not describe before #1624 either. --- .../1642-folded-thinking-preview.fixed.md | 1 + core/client.js | 21 +++- core/src/client/cells.js | 28 ++++- core/src/client/i18n.js | 2 + patches/ds-bot/0001-core-fixes.patch | 4 +- patches/ds-bot/0003-flybot-api-client.patch | 30 ++++- patches/ds-bot/0008-client-bundle.patch | 105 ++++++++++-------- .../ds-bot/files/test/thinking-row.test.js | 40 +++++++ 8 files changed, 179 insertions(+), 52 deletions(-) create mode 100644 changelog.d/1642-folded-thinking-preview.fixed.md diff --git a/changelog.d/1642-folded-thinking-preview.fixed.md b/changelog.d/1642-folded-thinking-preview.fixed.md new file mode 100644 index 000000000..af5609109 --- /dev/null +++ b/changelog.d/1642-folded-thinking-preview.fixed.md @@ -0,0 +1 @@ +The folded thinking row's one-line preview now skips fences and horizontal rules, cuts on a character boundary instead of halving an emoji, and ends a clipped line with an ellipsis; reasoning that is nothing but markup says how many lines it has instead of showing a fence fragment ([#1642](https://github.com/ThinkFlowLab/FlyBot/issues/1642)). diff --git a/core/client.js b/core/client.js index 3414e1f00..e3295b9ac 100644 --- a/core/client.js +++ b/core/client.js @@ -838,6 +838,8 @@ var ZH = { "Message actions": "消息操作", Download: "下载", "Thought it through": "思考过程", + "{n} line of reasoning": "{n} 行思考内容", + "{n} lines of reasoning": "{n} 行思考内容", "Seen — I’ll get to this right after the current step.": "看到了,这步做完就接着处理你这条。", "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", @@ -6171,9 +6173,24 @@ function MessageTools({ text, name, sessionId, react, actions }) { ); } var PEEK_MAX = 160; +var CUT = "…"; +var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; +var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); +var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; +var clip = (line2) => { + if (line2.length <= PEEK_MAX) return line2; + let kept = ""; + for (const part of graphemes(line2)) { + if (kept.length + part.length > PEEK_MAX - CUT.length) break; + kept += part; + } + return kept + CUT; +}; +var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); var peekLine = (text) => { - const line2 = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).find((part) => part !== ""); - return line2 === void 0 ? "" : line2.slice(0, PEEK_MAX); + const lines = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); + const prose = lines.find((line2) => !isMarkup(line2)); + return prose === void 0 ? onlyMarkup(lines) : clip(prose); }; function ThinkingRow({ text }) { if (typeof text !== "string" || text.trim() === "") return null; diff --git a/core/src/client/cells.js b/core/src/client/cells.js index d8b5c316c..cdf9a73bc 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -130,9 +130,33 @@ function MessageTools({ text, name, sessionId, react, actions }) { // The first line with something on it, inner runs of whitespace squeezed: a folded row shows it, so // the row never reads as an empty frame while the text it names sits behind the caret (#1624). const PEEK_MAX = 160 +const CUT = '…' +// A fence (```js, ~~~, a closing fence) and a horizontal rule are markup, not something thought, so +// the preview skips them and takes the first line that has prose (#1642). +const MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/] +const isMarkup = (line) => MARKUP.some(pattern => pattern.test(line)) +// Graphemes where the platform segments them, code points otherwise: either way the cut never lands +// inside a surrogate pair, so a clipped emoji cannot show up as U+FFFD (#1642). +const graphemes = (line) => typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? [...new Intl.Segmenter().segment(line)].map(part => part.segment) + : [...line] +const clip = (line) => { + if (line.length <= PEEK_MAX) return line + let kept = '' + for (const part of graphemes(line)) { + if (kept.length + part.length > PEEK_MAX - CUT.length) break + kept += part + } + return kept + CUT +} +// Nothing but markup: a line count still says something, where a fence fragment says nothing (#1642). +const onlyMarkup = (lines) => (lines.length === 1 + ? t('{n} line of reasoning', { n: lines.length }) + : t('{n} lines of reasoning', { n: lines.length })) const peekLine = (text) => { - const line = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).find((part) => part !== '') - return line === undefined ? '' : line.slice(0, PEEK_MAX) + const lines = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).filter((part) => part !== '') + const prose = lines.find((line) => !isMarkup(line)) + return prose === undefined ? onlyMarkup(lines) : clip(prose) } function ThinkingRow({ text }) { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 72d01720e..91e0a1644 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -785,6 +785,8 @@ const ZH = { 'Message actions': '消息操作', Download: '下载', 'Thought it through': '思考过程', + '{n} line of reasoning': '{n} 行思考内容', + '{n} lines of reasoning': '{n} 行思考内容', 'Seen — I’ll get to this right after the current step.': '看到了,这步做完就接着处理你这条。', 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 3bf37caac..8f10c751e 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1152,7 +1152,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Command palette Messages: '消息', Commands: '命令', -@@ -390,10 +390,12 @@ const ZH = { +@@ -390,10 +390,14 @@ const ZH = { Reply: '回复', 'More message actions': '更多操作', More: '更多', @@ -1161,6 +1161,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Message actions': '消息操作', Download: '下载', 'Thought it through': '思考过程', ++ '{n} line of reasoning': '{n} 行思考内容', ++ '{n} lines of reasoning': '{n} 行思考内容', + 'Seen — I’ll get to this right after the current step.': '看到了,这步做完就接着处理你这条。', 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 3f1a67494..a83f748bf 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -529,16 +529,40 @@ diff --git a/src/client/cells.js b/src/client/cells.js // Closes a popover on a press outside the given elements or on Escape. function useDismiss(open, close, refs) { useEffect(() => { -@@ -100,9 +100,21 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -100,9 +100,45 @@ function MessageTools({ text, name, sessionId, react, actions }) { item(t('Copy'), CopyIcon, copy), item(t('Download'), DownloadIcon, download)), document.body) : null) } +// The first line with something on it, inner runs of whitespace squeezed: a folded row shows it, so +// the row never reads as an empty frame while the text it names sits behind the caret (#1624). +const PEEK_MAX = 160 ++const CUT = '…' ++// A fence (```js, ~~~, a closing fence) and a horizontal rule are markup, not something thought, so ++// the preview skips them and takes the first line that has prose (#1642). ++const MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/] ++const isMarkup = (line) => MARKUP.some(pattern => pattern.test(line)) ++// Graphemes where the platform segments them, code points otherwise: either way the cut never lands ++// inside a surrogate pair, so a clipped emoji cannot show up as U+FFFD (#1642). ++const graphemes = (line) => typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' ++ ? [...new Intl.Segmenter().segment(line)].map(part => part.segment) ++ : [...line] ++const clip = (line) => { ++ if (line.length <= PEEK_MAX) return line ++ let kept = '' ++ for (const part of graphemes(line)) { ++ if (kept.length + part.length > PEEK_MAX - CUT.length) break ++ kept += part ++ } ++ return kept + CUT ++} ++// Nothing but markup: a line count still says something, where a fence fragment says nothing (#1642). ++const onlyMarkup = (lines) => (lines.length === 1 ++ ? t('{n} line of reasoning', { n: lines.length }) ++ : t('{n} lines of reasoning', { n: lines.length })) +const peekLine = (text) => { -+ const line = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).find((part) => part !== '') -+ return line === undefined ? '' : line.slice(0, PEEK_MAX) ++ const lines = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).filter((part) => part !== '') ++ const prose = lines.find((line) => !isMarkup(line)) ++ return prose === undefined ? onlyMarkup(lines) : clip(prose) +} + function ThinkingRow({ text }) { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ba448a312..6ef71528a 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -572,7 +572,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,10 +446,12 @@ var ZH = { +@@ -446,10 +446,14 @@ var ZH = { Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -581,6 +581,8 @@ diff --git a/client.js b/client.js "Message actions": "消息操作", Download: "下载", "Thought it through": "思考过程", ++ "{n} line of reasoning": "{n} 行思考内容", ++ "{n} lines of reasoning": "{n} 行思考内容", + "Seen — I’ll get to this right after the current step.": "看到了,这步做完就接着处理你这条。", "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", @@ -3668,7 +3670,7 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2805,1975 +2805,4080 @@ function sheetClassNames(sheet) { +@@ -2805,1975 +2805,4095 @@ function sheetClassNames(sheet) { visit(sheet.cssRules); } catch { } @@ -5172,19 +5174,12 @@ diff --git a/client.js b/client.js - ) : null, - foot + error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null - ); ++ ); + return (0, import_react8.createElement)( + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain -+ ); -+} -+ -+// src/client/turn-reasoning.js -+function turnReasoning(steps, current) { -+ const members = steps.length ? steps : [current]; -+ if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -+ return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); + ); } -function SettledQuestion({ question, answer }) { - const options = optionsOf(question); @@ -5203,6 +5198,13 @@ diff --git a/client.js b/client.js - ); - } - return (0, import_react4.createElement)( ++ ++// src/client/turn-reasoning.js ++function turnReasoning(steps, current) { ++ const members = steps.length ? steps : [current]; ++ if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; ++ return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); ++} +var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); @@ -5388,49 +5390,31 @@ diff --git a/client.js b/client.js - const loading = useSession?.(historyLoading) === true; - if (question === void 0 || question.callId && (drawn || loading)) return null; - return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+var PEEK_MAX = 160; -+var peekLine = (text) => { -+ const line2 = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).find((part) => part !== ""); -+ return line2 === void 0 ? "" : line2.slice(0, PEEK_MAX); -+}; -+function ThinkingRow({ text }) { -+ if (typeof text !== "string" || text.trim() === "") return null; -+ const peek = peekLine(text); -+ return (0, import_react9.createElement)( -+ "details", -+ { className: "bt-think" }, -+ (0, import_react9.createElement)( -+ "summary", -+ null, -+ (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), -+ t("Thought it through"), -+ peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null -+ ), -+ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) -+ ); - } +-} -function AnsweredCell({ node }) { - const data = node.data ?? {}; - const questions = Array.isArray(data.questions) ? data.questions : []; - if (questions.length === 0) { - return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } ++var PEEK_MAX = 160; ++var CUT = "…"; ++var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; ++var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); ++var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; ++var clip = (line2) => { ++ if (line2.length <= PEEK_MAX) return line2; ++ let kept = ""; ++ for (const part of graphemes(line2)) { ++ if (kept.length + part.length > PEEK_MAX - CUT.length) break; ++ kept += part; + } - const answers = Array.isArray(data.answers) ? data.answers : []; - return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { - key: question.id, - question, - answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } - }))); -+function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -+ const tail = useTurnData?.("turn-tail"); -+ const location2 = node.location; -+ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -+ const seq = node.data.finalNode?.seq; -+ return (0, import_react9.useMemo)(() => { -+ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -+ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -+ }, [fileMentions, openFile, seq, tail, turn]); - } +-} - -// src/client/secret-card.js -var import_react5 = require("react"); @@ -5460,7 +5444,8 @@ diff --git a/client.js b/client.js - if (scope === "all") return t("All Bots"); - const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); - return names.length === 0 ? t("No Bot") : names.join(t(" and ")); --}; ++ return kept + CUT; + }; -var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; -function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { - return (0, import_react5.createElement)("input", { @@ -5485,6 +5470,38 @@ diff --git a/client.js b/client.js - event.preventDefault(); - onEnter?.(); - } ++var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); ++var peekLine = (text) => { ++ const lines = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); ++ const prose = lines.find((line2) => !isMarkup(line2)); ++ return prose === void 0 ? onlyMarkup(lines) : clip(prose); ++}; ++function ThinkingRow({ text }) { ++ if (typeof text !== "string" || text.trim() === "") return null; ++ const peek = peekLine(text); ++ return (0, import_react9.createElement)( ++ "details", ++ { className: "bt-think" }, ++ (0, import_react9.createElement)( ++ "summary", ++ null, ++ (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), ++ t("Thought it through"), ++ peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null ++ ), ++ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) ++ ); ++} ++function useFileMentions({ node, openFile, fileMentions, useTurnData }) { ++ const tail = useTurnData?.("turn-tail"); ++ const location2 = node.location; ++ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; ++ const seq = node.data.finalNode?.seq; ++ return (0, import_react9.useMemo)(() => { ++ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; ++ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; ++ }, [fileMentions, openFile, seq, tail, turn]); ++} +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; diff --git a/patches/ds-bot/files/test/thinking-row.test.js b/patches/ds-bot/files/test/thinking-row.test.js index ef3971cc4..c8bdec65d 100644 --- a/patches/ds-bot/files/test/thinking-row.test.js +++ b/patches/ds-bot/files/test/thinking-row.test.js @@ -136,3 +136,43 @@ test('#1636: an opened row hides its preview, so its first line is not printed t /\.bt-think\[open\] \.bt-think-peek\{display:none\}/, ) }) + +test('#1642: a fence or a rule is not the peek line; the first prose line is', () => { + const fenced = [think('```js\nconst x = 1\n```')] + const peek = classed(thinkingRow(render(step(1, fenced), [step(1, fenced)])), 'bt-think-peek') + assert.ok(peek, 'the closed row carries a line of the reasoning') + assert.equal(text(peek), 'const x = 1', 'markup is not what the Bot was thinking') + assert.doesNotMatch(text(peek), /```/) + + const ruled = [think('---\n***\nLet me read it\n___')] + const second = classed(thinkingRow(render(step(1, ruled), [step(1, ruled)])), 'bt-think-peek') + assert.ok(second) + assert.equal(text(second), 'Let me read it', 'a horizontal rule is markup too') +}) + +test('#1642: a cut preview keeps whole characters and says it was cut', () => { + const long = [think(`${'a'.repeat(150)}😀${'b'.repeat(100)}`)] + const peek = classed(thinkingRow(render(step(1, long), [step(1, long)])), 'bt-think-peek') + assert.ok(peek) + const shown = text(peek) + assert.equal(shown.length, 160, 'the cap still holds') + assert.ok(shown.includes('😀'), 'an emoji inside the cap survives whole') + assert.ok(shown.endsWith('…'), 'a clipped line says it was clipped') + assert.doesNotMatch(shown, /[\uD800-\uDFFF]/u, 'no half a surrogate pair') + + // The reported input: a pair cut exactly at the cap used to render as U+FFFD. + const edge = [think(`${'a'.repeat(159)}😀 tail`)] + const cut = text(classed(thinkingRow(render(step(1, edge), [step(1, edge)])), 'bt-think-peek')) + assert.equal(cut.length, 160) + assert.ok(cut.endsWith('…')) + assert.doesNotMatch(cut, /[\uD800-\uDFFF]/u, 'no half a surrogate pair at the cap either') +}) + +test('#1642: reasoning that is only markup gets a neutral summary, never a bare fence or an empty row', () => { + const onlyMarkup = [think('```js\n```')] + const details = thinkingRow(render(step(1, onlyMarkup), [step(1, onlyMarkup)])) + assert.ok(details, 'the row stays') + const peek = classed(details, 'bt-think-peek') + assert.ok(peek, 'the folded row never reads as an empty frame') + assert.equal(text(peek), '2 lines of reasoning') +})