dsh-paste-code-block 0.1.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Cherry Studio‑style text/code block pasting for DeepSeek Harness Web**
6
6
 
7
- Paste long code or text into the DSH Web composer → it lands as a **bordered, collapsible, language‑tagged card** instead of a wall of text. Names follow the DSH interface language (中文 / English). On send, the original content is restored as a fenced code block.
7
+ Paste long code or text into the DSH Web composer → it lands as a **bordered, collapsible, language‑tagged card** instead of a wall of text. Names follow the DSH interface language (中文 / English). On send, the original content is restored as a fenced code block — and in the conversation it **stays folded** into click‑to‑expand cards, so your bubbles never become text walls again.
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/dsh-paste-code-block?logo=npm)](https://www.npmjs.com/package/dsh-paste-code-block)
10
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
@@ -28,6 +28,8 @@ Copying a 200‑line stack trace, an API response, or a long log into a chat inp
28
28
  - 🈯 **Localized names** — chips are named in the current DSH language: `代码块 1` / `Code #1`, `文本块 N` / `Text #N`. A leading glyph and tint tells the two apart at a glance — `</>` blue for code, `Aa` gray for text. Switching **Settings → Language** live-retitles every chip.
29
29
  - × **One-click remove** — every chip carries its own `×` delete button (the detail card has one too); a removed number is recycled immediately.
30
30
  - 📤 **Fenced restore on send** — each card expands back into a ```` ```lang … ``` ```` block when you send; a failed send automatically refills the input.
31
+ - 📦 **Folded after send** — sent blocks keep folding in the transcript: every fenced block inside your own messages renders as a collapsed card (`python · 128 行` / `python · 128 lines`). Click the header to expand, again to collapse; the card's copy button grabs the original fenced text verbatim. Bare ```` ``` ```` fences fold as code (only an explicit ```` ```text ```` folds as a text block).
32
+ - 💾 **Mid-draft safe** — DSH persists a composer draft as plain text, so pasted blocks are automatically re-attached when the composer is rebuilt (workspace switch on the new-session page, page reload); hand-deleted blocks are never "resurrected".
31
33
  - 🔢 **Smart numbering** — code and text count apart; the smallest free number is always reused.
32
34
  - 📱 **Every surface** — pure client plugin; works on desktop & mobile web, follows light/dark themes.
33
35
 
@@ -68,6 +70,7 @@ dsh plugin --profile web add file:/path/to/dsh-paste-code-block # from a local
68
70
  2. Paste into the DSH input → it collapses into a chip such as `代码块 1` / `Code #1`.
69
71
  3. Click the chip to open the card: read, edit inline, copy, collapse. Click the chip's (or the card's) `×` to delete that block.
70
72
  4. Send — the full original text goes out as a proper fenced code block.
73
+ 5. In the conversation it stays folded: each block sits in the bubble as a collapsed card — click the header to expand and read, click again to collapse.
71
74
 
72
75
  Detection threshold: **contains a newline / ≥ 2 lines / length ≥ 96 / indented & brace-dense / fenced** — any one turns the paste into a block.
73
76
 
@@ -78,21 +81,24 @@ Detection threshold: **contains a newline / ≥ 2 lines / length ≥ 96 / indent
78
81
  - Removing a chip any other way (e.g. `Backspace`) also frees its number — plugin state is reconciled against the editor continuously.
79
82
  - Deleting one block never touches the others, and removal is an ordinary editor edit — `Ctrl+Z` brings it back.
80
83
  - Creating a block leaves **no stray space**: DSH appends a separating space after a freshly inserted chip, and the plugin removes exactly that one character, so text typed after a block does not start with a space and pasting several blocks never piles spaces into the message. Spaces you typed yourself are never touched by pasting or deleting.
84
+ - Post-send folding is **display-only**: it restyles your own message bubbles (and steering rows / the send echo) in the DOM. Stored messages are never modified, the bubble's own copy / edit / delete bar keeps working on the original text, and assistant messages are left alone.
85
+ - Draft-rebuild recovery keeps a small localStorage mirror (block payloads plus a draft fingerprint; written while you edit, cleared when you send, expired after two weeks). If the browser blocks local storage (private mode), blocks still survive a same-tab workspace switch from memory — just not a full page reload.
81
86
 
82
87
  ## Project layout
83
88
 
84
89
  ```
85
90
  dsh-paste-code-block/
86
91
  ├── src/
87
- │ ├── client.js # Web (browser) half — paste capture, chips, detail card, × delete
92
+ │ ├── client.js # Web (browser) half — paste capture, chips, detail card, × delete, sent-message fold, draft-rebuild recovery
88
93
  │ ├── index.js # Host (node) half — intentionally empty (pure UI plugin)
89
- │ ├── parse.js # Pure block-detection logic (canonical, unit-tested)
90
- │ └── i18n.js # Locale dictionaries + label parsing (canonical, unit-tested)
94
+ │ ├── parse.js # Pure block-detection + fence-splitting logic (canonical, unit-tested)
95
+ │ └── i18n.js # Locale dictionaries, label & fold-title builders (canonical, unit-tested)
91
96
  ├── tests/
92
- │ ├── parse.test.js # node:test unit tests for src/parse.js
93
- │ └── i18n.test.js # node:test unit tests for src/i18n.js
97
+ │ ├── parse.test.js # node:test unit tests for src/parse.js
98
+ │ ├── i18n.test.js # node:test unit tests for src/i18n.js
99
+ │ └── fold-smoke.test.js # end-to-end fold scan against a DOM shim (runs the real client bundle)
94
100
  ├── docs/
95
- │ ├── design.md # Design rationale (numbering, anchors, codec, i18n, chip ×)
101
+ │ ├── design.md # Design rationale (numbering, anchors, codec, i18n, chip ×, sent-message fold, draft-rebuild recovery)
96
102
  │ └── images/ # README mockups (PNG + editable SVG sources)
97
103
  ├── cordis.patch.yml # DSH bundle patch declaring the host plugin
98
104
  ├── package.json # Package + DSH plugin manifest
@@ -104,7 +110,7 @@ dsh-paste-code-block/
104
110
 
105
111
  ```sh
106
112
  npm run check # syntax-check the JS halves
107
- npm test # run unit tests (node:test, 38 tests)
113
+ npm test # run unit tests (node:test, 61 tests)
108
114
  ```
109
115
 
110
116
  ## License
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Cherry Studio 式的文本块/代码块粘贴效果(DeepSeek Harness Web)**
6
6
 
7
- 把复制的**长代码 / 长文本**粘到 DSH Web 输入框时,不再糊成一长串普通文字,而是收成一张**带边框、可折叠、带语言标签的卡片**;块名称自动跟随 DSH 界面语言(中文 / English);发送时自动还原成真正的 ```` ```lang … ``` ```` 围栏代码块。
7
+ 把复制的**长代码 / 长文本**粘到 DSH Web 输入框时,不再糊成一长串普通文字,而是收成一张**带边框、可折叠、带语言标签的卡片**;块名称自动跟随 DSH 界面语言(中文 / English);发送时自动还原成真正的 ```` ```lang … ``` ```` 围栏代码块——**发送后在对话里也保持折叠**,点击即可展开阅读。
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/dsh-paste-code-block?logo=npm)](https://www.npmjs.com/package/dsh-paste-code-block)
10
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
@@ -28,6 +28,8 @@
28
28
  - 🈯 **名称本地化**:芯片按当前 DSH 界面语言命名——`代码块 1` / `Code #1`、`文本块 N` / `Text #N`;前缀图标与配色一眼区分两类块(代码 `</>` 蓝色、文本 `Aa` 灰色);在「设置 → 语言」切换后所有芯片即时改名。
29
29
  - × **一键删除**:每个芯片自带 `×` 删除按钮(详情卡上也有);删掉的块编号立刻回收复用。
30
30
  - 📤 **发送还原**:发送时每个卡片展开还原为 ```` ```lang … ``` ```` 围栏代码块;发送失败自动回填输入框。
31
+ - 📦 **发送后仍折叠**:发出去的消息在对话里继续折叠成卡片(如 `python · 128 行`),气泡不再变成文字墙;点标题展开、再点收起,卡片上的复制按钮原样复制带围栏的完整代码块。裸 ```` ``` ```` 围栏按代码块折叠(只有显式 ```` ```text ```` 才折叠为文本块)。
32
+ - 💾 **草稿中途不丢**:DSH 只把输入框草稿按纯文本持久化,输入框被重建时(新会话页切换工作区、刷新页面)已粘贴的块会自动重新挂回草稿;手动删掉的块绝不会被“复活”。
31
33
  - 🔢 **智能编号**:代码与文本分开计数,永远分配最小可用号。
32
34
  - 📱 **全端适配**:纯客户端插件,PC 与手机 Web 通用,实时跟随深浅主题。
33
35
 
@@ -68,6 +70,7 @@ dsh plugin --profile web add file:/path/to/dsh-paste-code-block # 从本地目
68
70
  2. 在 DSH 输入框粘贴 → 折叠成 `代码块 1` 这样的芯片。
69
71
  3. 点芯片打开详情卡:阅读、就地编辑、复制、折叠;点芯片(或卡片)上的 `×` 删除该块。
70
72
  4. 发送——完整原文以围栏代码块形式发出。
73
+ 5. 发送后对话里仍保持折叠:每个块在气泡中显示为一张卡片,点标题展开阅读、再点收起。
71
74
 
72
75
  识别阈值:**含换行 / 行数 ≥ 2 / 长度 ≥ 96 / 缩进且符号密集 / 带围栏**,任一命中即成块。
73
76
 
@@ -78,21 +81,24 @@ dsh plugin --profile web add file:/path/to/dsh-paste-code-block # 从本地目
78
81
  - 用其他方式删除芯片(如 `Backspace`)同样会释放编号——插件状态与编辑器持续对账。
79
82
  - 删除单个块绝不影响其它块;删除是一次普通的编辑器编辑,可用 `Ctrl+Z` 撤销找回。
80
83
  - 创建块**不会残留空格**:DSH 会在新插入的芯片后自动补一个分隔空格,插件只把这一个字符删掉——因此块后面接着输入的文字不会顶着一个前导空格,连续粘贴多个块也不会在正文里堆出多余空格;你自己敲的空格在粘贴/删除时都不会被动到。
84
+ - 发送后折叠**只是显示层**的改动:它只重排你自己消息(含插话与发送回显)气泡的 DOM,**不改任何存储的消息数据**;气泡自带的复制/编辑/删除操作照常作用于原文;助手消息完全不受影响。
85
+ - 草稿恢复依赖一个小型 localStorage 镜像(只存块内容与草稿指纹;编辑时写入、发送即清、两周过期)。浏览器禁用本地存储(如隐身模式)时,同一页面内的工作区切换仍可从内存恢复,但整页刷新不能。
81
86
 
82
87
  ## 目录结构
83
88
 
84
89
  ```
85
90
  dsh-paste-code-block/
86
91
  ├── src/
87
- │ ├── client.js # Web(浏览器侧)—— 粘贴拦截、芯片、详情卡片、× 删除
92
+ │ ├── client.js # Web(浏览器侧)—— 粘贴拦截、芯片、详情卡片、× 删除、发送后折叠、草稿恢复
88
93
  │ ├── index.js # Host(Node 侧)—— 有意为空(纯 UI 插件)
89
- │ ├── parse.js # 纯块识别逻辑(规范版,含单元测试)
90
- │ └── i18n.js # 词典与块名称解析(规范版,含单元测试)
94
+ │ ├── parse.js # 纯块识别 + 围栏切分逻辑(规范版,含单元测试)
95
+ │ └── i18n.js # 词典、块名称与折叠卡标题(规范版,含单元测试)
91
96
  ├── tests/
92
- │ ├── parse.test.js # node:test 单元测试(src/parse.js)
93
- │ └── i18n.test.js # node:test 单元测试(src/i18n.js)
97
+ │ ├── parse.test.js # node:test 单元测试(src/parse.js)
98
+ │ ├── i18n.test.js # node:test 单元测试(src/i18n.js)
99
+ │ └── fold-smoke.test.js # 用 DOM 垫片端到端跑真实 client.js:折叠扫描 + 草稿恢复
94
100
  ├── docs/
95
- │ ├── design.md # 设计说明(编号复用、锚点稳定、codec、i18n、芯片 ×)
101
+ │ ├── design.md # 设计说明(编号复用、锚点稳定、codec、i18n、芯片 ×、发送后折叠)
96
102
  │ └── images/ # README 示意图(PNG + 可编辑 SVG 源)
97
103
  ├── cordis.patch.yml # DSH bundle patch,声明宿主插件
98
104
  ├── package.json # 包与 DSH 插件清单
@@ -104,7 +110,7 @@ dsh-paste-code-block/
104
110
 
105
111
  ```sh
106
112
  npm run check # 语法检查 JS 半端
107
- npm test # 运行单元测试(node:test,38 项)
113
+ npm test # 运行单元测试(node:test,61 项)
108
114
  ```
109
115
 
110
116
  ## License
package/docs/design.md CHANGED
@@ -202,4 +202,136 @@ for us) while the scoped edit verbs address detect coordinates (a chip collapses
202
202
  placeholder character). `detectOffsetOf()` converts with
203
203
  `detect = clipboard - Σ(len_i - 1)` over preceding occurrences, so blocks stay correctly
204
204
  addressed even when other plugins' chips (file references and the like) sit in the same draft.
205
- Both helpers are pure and unit-tested (8 cases in tests/projection.test.js).
205
+ Both helpers are pure and unit-tested (8 cases in tests/projection.test.js).
206
+
207
+ ## 12. Sent-message folding (0.2.0)
208
+
209
+ The composer fold was only half the story: on send the chips expand back into fenced text, and
210
+ DSH renders user bubbles as **plain pre-wrap text** (`projectUserText` → `span.plainRun` runs
211
+ plus `data-ref-chip` badges), so the sent message reappeared in the transcript as the very wall
212
+ of text the plugin exists to prevent. §12 folds *sent* blocks back down.
213
+
214
+ **Where the DOM hooks are.** Every conversation row carries `data-chat-flow-kind`
215
+ (`user` / `steering` / `agent` / …) and `data-chat-flow-key` on its wrapper; the right-aligned
216
+ user bubble lives under the same row and contains the `plainRun` spans. The send echo
217
+ (`data-submission-echo`) and pending steering (`data-pending-steering`) render the same bubble
218
+ markup one frame before the durable node exists, so scanning all four gives an instant
219
+ collapsed view from the moment of send. Assistant output is deliberately not touched: its
220
+ markdown renderer already styles code blocks its own way.
221
+
222
+ **Never fight React.** The bubble's children belong to React; removing them would make the
223
+ next reconciliation throw. So the fold *hides* a qualifying `plainRun` span (`display:none` —
224
+ the node stays a child, React keeps patching it harmlessly) and inserts a
225
+ `div.dsh-pcb-fold-host` **after** it. The host is ours: prose segments re-render as
226
+ `span.dsh-pcb-fold-prose`, each fence as a collapsed card (glyph `</>`/`Aa` + localized title
227
+ `python · 128 行` / `Text · 128 lines`, copy button copying the **raw fenced text** exactly as
228
+ sent, click-to-expand `<pre>`). Since hiding a child does not affect `textContent`, the
229
+ message action bar (React-side copy/edit/delete) is entirely unaffected.
230
+
231
+ **What folds.** `splitFencedSegments()` (canonical in `src/parse.js`, embedded copy in
232
+ `client.js`) walks the run line by line with GFM fence rules (``` or ~~~, opener indented ≤ 3
233
+ spaces, closer ≥ opener length alone on its line; backticks in a ``` info string kill that
234
+ opener; an unterminated opener stays prose). Any fence segment with a non-empty body folds;
235
+ everything else — prose, whitespace between blocks, empty fences — renders verbatim around
236
+ the cards. Fence-only (not "any long text") is deliberate: composer-created blocks always
237
+ travel as fences, while unfenced long messages may have been *typed*, and silently folding
238
+ typed prose would be a nasty surprise.
239
+
240
+ **Idempotence and refresh.** Each scanned run gets
241
+ `data-dsh-pcb-run = "1|<stamp>"` (folded) or `"0|<stamp>"` (checked, nothing to fold), where
242
+ the stamp folds `foldRev`, text length, and an FNV-1a hash. The shared chip-sync
243
+ MutationObserver coalesces to one rAF scan; a run whose stamp matches is skipped, so steady
244
+ state costs attribute reads. A locale switch bumps `foldRev`, which invalidates every stamp —
245
+ the next scan rebuilds each host and re-titles cards from the live translator. Rebuilds
246
+ preserve who was expanded via a stable `flowKey#runIndex:segIndex` open set, and an orphan
247
+ sweep drops any host whose predecessor is no longer its hidden, stamp-matching run (React can
248
+ remount the run underneath us). Disposal (HMR / plugin removal) removes all hosts and unhides
249
+ every run — the transcript hands back clean.
250
+
251
+ `tests/fold-smoke.test.js` exercises the real bundle against a dependency-free DOM shim:
252
+ fold → marker/card/prose structure, steering rows, non-fenced messages untouched, retitle
253
+ across locale switches with open-state kept, text edits re-fold and unfold, orphan sweep,
254
+ and dispose restoration.
255
+
256
+ **Bare fences are code (0.2.1).** Card type follows the composer round-trip convention: a
257
+ plain-text block always serializes as an explicit ` ```text ` fence while a code block whose
258
+ language was not detected serializes as a **bare** ` ``` ` fence — so only an explicit `text`
259
+ info string folds as a 文本块, and a bare fence folds as 代码块, re-guessing a language from
260
+ the body for the title (`python · 2 行`, else `代码块 · N 行`). Manually typed bare fences
261
+ follow the same rule; that is also the CommonMark reading. (0.2.0 shipped the inverse and a
262
+ sent 代码块 chip could reappear in the transcript as 文本块.)
263
+
264
+ ## 13. Draft rebuild recovery (0.2.1)
265
+
266
+ **Typing-window guard and retired hygiene (0.2.2).** The recovery rewrite is a whole-
267
+ document `setDraft`: the caret drops at the end and an in-flight IME composition is
268
+ destroyed. Run in the middle of typing, that ate the user's first character and bounced
269
+ the view on every pass (reported). Two keystroke-time paths existed: sweeping a stray
270
+ U+200B that belonged to no restorable block (external invisible copy from WeChat / web
271
+ pages — the signature is NOT exclusive to rebuilds after all), and restoring blocks while
272
+ the draft had already moved past the re-seeded text. The hygiene path is retired outright:
273
+ external residue is left exactly where the user put it. Both paths now share one gate —
274
+ `recover()` only acts when the draft is IDENTICAL to the last draft observed for that
275
+ session in this page view (or was never observed: fresh boot, or the composer only ever
276
+ seen hydrating empty). A draft that MOVED between passes is live typing, never a rebuild.
277
+ `attach()` records the shell's post-insertion draft as that fingerprint immediately, and
278
+ the reconcile prune drops the mirror synchronously, so a hand-deleted block cannot be
279
+ resurrected from stale mirror data through the empty-prior window.
280
+
281
+ **Paste-storm guard (0.2.3).** Even with the typing-window guard deployed (load log
282
+ `client loaded v0.2.2` verified live), the console showed the SAME clipboard text (571
283
+ chars) intercepted dozens of times in seconds — an external automation (a Tampermonkey
284
+ AutoClicker was running on the page) re-dispatching paste. Each accepted paste inserts a
285
+ chip and re-renders the dock, so a storm reproduces the exact bounce-and-eat-the-first-
286
+ character experience through a different door. Identical clipboard text arriving
287
+ faster than a human can act collapses into one paste (`pasteStorm.seen`, 1200 ms
288
+ sliding window refreshed on every matching event, so a held repeat stays
289
+ suppressed while it continues); suppressed events are still `preventDefault`ed so
290
+ raw text never falls through to the composer. The plugin cannot suppress the
291
+ automation itself — users hitting this should disable AutoClicker-style userscripts
292
+ on the DSH page.
293
+
294
+ **Burst-only collapse (0.2.5).** 0.2.4 tried a content-liveness rule: while a block
295
+ with the same text was still attached, re-pasting identical text was consumed as
296
+ "not a new intent." That over-suppressed — pasting the same snippet twice (a
297
+ completely normal action) yielded a single block and confused users, and in the
298
+ end the reported bounces were traced to a *different* plugin entirely, not to
299
+ paste storms. So 0.2.5 reverts to a pure burst window: identical text pasted after
300
+ the 1200 ms window is a fresh intent and gets its own block (`sourceText` is
301
+ recorded per block and carried through the recovery mirror); only machine-paced
302
+ repeats *within* the window collapse. The `intercept` load log still carries
303
+ `trusted=` — `false` names a page-script dispatch (userscript glue, or the
304
+ `dsh-vision-router` image-paste replay, which carries the original text and lands
305
+ seconds after the trusted paste) rather than a real Ctrl+V, so any future storm
306
+ report identifies its own source.
307
+
308
+ Two DSH paths strand pasted blocks mid-draft, and both share one signature. (a) A page
309
+ reload re-seeds the composer from DSH's persisted per-session draft
310
+ (`dsh.conversation.<sid>`), which is **plain text** — Lexical chips cannot round-trip
311
+ through it (§7). (b) A workspace switch on the new-session page *carries the draft text
312
+ over* to the target workspace's blank session (`inputHub`: `next.setDraft(from.snapshot
313
+ .draft)`, then `from.setDraft("")`) — prose follows the composer, chips and controller
314
+ state do not, so blocks were silently orphaned under the old session id (user-reported).
315
+ Typed prose comes back either way; the blocks did not.
316
+
317
+ The signature — as 0.2.1 read it: every chip contributes exactly one U+200B to the clipboard
318
+ projection and nothing scrubs it from persisted/carried text (DSH's placeholder-stripping
319
+ regex covers private-use chars and U+FFFC, not ZWSPs). Stray U+200Bs with **no** live
320
+ occurrence of ours can therefore only mean the draft was rebuilt from text; conversely
321
+ deleting a chip — ✕, Backspace, or prune — removes its ZWSP together with the node, so
322
+ deliberate deletion never looks like a rebuild and cannot be "resurrected".
323
+
324
+ `BlockController.recover()` runs in the dock's reconcile effect, ahead of the reconcile,
325
+ and resolves blocks in this order: own-session memory → the session's own localStorage
326
+ mirror → **carry-over donor**: the unmounted session whose last published draft equals the
327
+ re-seeded text character for character (the mirror stores that same fingerprint, so the
328
+ donor is even found after a reload wiped memory) → nothing. An ambiguous fingerprint aborts
329
+ the donor step — no guessing. On a hit the mirror moves to the new sid. (0.2.1 also stripped
330
+ strays when nothing could be restored; the typing-window guard above retired that — see the
331
+ 0.2.2 note.) The mirror holds just the block payloads plus that draft fingerprint, is written
332
+ synchronously on attach (and debounced on detail-card edits), drops on a send attempt, and
333
+ expires after two weeks. The recovery pass then *skips* its reconcile — the snapshot it
334
+ would reconcile against is stale; the draft mutation bumps `draftRev` and the next pass
335
+ reconciles real occurrences. Restored chips get re-derived labels and numbers, so a
336
+ restored draft is indistinguishable from one that never left. Mirror writes are best
337
+ effort: private mode / quota only lose reload durability, never the feature itself.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-paste-code-block",
3
- "version": "0.1.3",
4
- "description": "Cherry Studio-style paste for DSH Web: pasted text/code becomes a bordered, collapsible, language-tagged card with localized names (中文/English) and a one-click remove button; restored as a fenced code block on send.",
3
+ "version": "0.2.5",
4
+ "description": "Cherry Studio-style paste for DSH Web: pasted text/code becomes a bordered, collapsible, language-tagged card with localized names (中文/English) and a one-click remove button; restored as a fenced code block on send — and folded back into collapsed, expandable cards in the conversation.",
5
5
  "license": "MIT",
6
6
  "author": "DSH paste-code-block contributors",
7
7
  "repository": {
@@ -85,7 +85,7 @@
85
85
  "node": ">=20"
86
86
  },
87
87
  "scripts": {
88
- "check": "node --check src/index.js && node --check src/i18n.js && node --check src/chip-nodes.js && node --check src/projection.js && node --check src/client.js",
88
+ "check": "node --check src/index.js && node --check src/parse.js && node --check src/i18n.js && node --check src/chip-nodes.js && node --check src/projection.js && node --check src/client.js",
89
89
  "test": "node --test 'tests/**/*.test.js'",
90
90
  "render:images": "node docs/images/render.cjs"
91
91
  }
package/src/client.js CHANGED
@@ -109,6 +109,9 @@ window.__ModuleLoader__.load({
109
109
  'edit.aria': '编辑代码块内容',
110
110
  'error.stale': '代码块已失效,请重新粘贴',
111
111
  'error.remove': '无法删除该块,请重试',
112
+ 'fold.code': '代码块',
113
+ 'fold.text': '文本块',
114
+ 'fold.aria': '展开或折叠该块',
112
115
  },
113
116
  en: {
114
117
  'block.code': 'Code #{n}',
@@ -126,6 +129,9 @@ window.__ModuleLoader__.load({
126
129
  'edit.aria': 'Edit block content',
127
130
  'error.stale': 'This block is no longer valid — paste it again',
128
131
  'error.remove': 'Could not remove the block — please retry',
132
+ 'fold.code': 'Code',
133
+ 'fold.text': 'Text',
134
+ 'fold.aria': 'Expand or collapse this block',
129
135
  },
130
136
  }
131
137
 
@@ -138,6 +144,13 @@ window.__ModuleLoader__.load({
138
144
  }
139
145
  }
140
146
 
147
+ // Sent-message fold card title (embedded copy of src/i18n.js#foldTitle).
148
+ function foldTitle(t, type, lang, lines) {
149
+ const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
150
+ const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
151
+ return `${name} · ${meta}`
152
+ }
153
+
141
154
  function detectBrowserLocale() {
142
155
  try {
143
156
  const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
@@ -269,6 +282,134 @@ window.__ModuleLoader__.load({
269
282
  return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
270
283
  }
271
284
 
285
+ // ----- fence segmentation (embedded copy of src/parse.js) ----------------
286
+ // Split a message text into ordered prose/fence segments (GFM-style ``` or
287
+ // ~~~ fences, opener indented <=3 spaces, closer >= opener length alone on
288
+ // its line). Unterminated openers stay prose. Keep in sync with parse.js.
289
+ function splitFencedSegments(text) {
290
+ const src = String(text == null ? '' : text)
291
+ if (!src) return []
292
+ const lines = src.split(/\r?\n/)
293
+ const segments = []
294
+ const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
295
+ let proseStart = 0
296
+ let i = 0
297
+ const flushProse = (end) => {
298
+ if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
299
+ }
300
+ while (i < lines.length) {
301
+ const m = OPEN.exec(lines[i])
302
+ if (!m) { i += 1; continue }
303
+ const marker = m[1]
304
+ const ch = marker[0]
305
+ const info = m[2].trim()
306
+ if (ch === '`' && info.includes('`')) { i += 1; continue }
307
+ const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
308
+ let j = i + 1
309
+ let closed = false
310
+ while (j < lines.length) {
311
+ if (closeRe.test(lines[j])) { closed = true; break }
312
+ j += 1
313
+ }
314
+ if (!closed) { i += 1; continue }
315
+ flushProse(i)
316
+ const bodyLines = lines.slice(i + 1, j)
317
+ segments.push({
318
+ kind: 'fence',
319
+ lang: info ? info.split(/\s+/)[0] : '',
320
+ info,
321
+ body: bodyLines.join('\n'),
322
+ raw: lines.slice(i, j + 1).join('\n'),
323
+ })
324
+ i = j + 1
325
+ proseStart = i
326
+ }
327
+ flushProse(lines.length)
328
+ return segments
329
+ }
330
+
331
+ // ===== Recovery mirror (draft re-seed after workspace switch / reload) ===
332
+ // DSH persists a session's composer draft as PLAIN TEXT
333
+ // (`dsh.conversation.<sid>` in localStorage) and re-seeds the editor from
334
+ // it — but Lexical chip nodes cannot round-trip through text (design.md
335
+ // §7), so switching workspaces mid-draft left every pasted block behind:
336
+ // the seeded draft keeps only one invisible U+200B per lost chip. The
337
+ // plugin therefore mirrors its block CONTENT to localStorage itself and
338
+ // re-attaches the chips when it recognizes that stray-ZWSP signature
339
+ // (BlockDock mount effect → BlockController.recover). Intentional chip
340
+ // deletion removes the ZWSPs along with the nodes, so it never looks
341
+ // like a re-seed; the mirror clears itself on send and once no blocks
342
+ // remain, and stale entries expire after two weeks.
343
+ const RECOVERY_PREFIX = 'dsh-pcb-recovery.'
344
+ const RECOVERY_TTL_MS = 14 * 24 * 60 * 60 * 1000
345
+ const RECOVERY_MAX_CHARS = 3_000_000
346
+ const RECOVERY_MAX_DRAFT = 200_000
347
+
348
+ function recoverySave(sid, blocks, draftText) {
349
+ const key = RECOVERY_PREFIX + sid
350
+ try {
351
+ if (!blocks || blocks.length === 0) { localStorage.removeItem(key); return }
352
+ const draft = typeof draftText === 'string' && draftText.length <= RECOVERY_MAX_DRAFT ? draftText : ''
353
+ const json = JSON.stringify({ at: Date.now(), draft, blocks })
354
+ if (json.length > RECOVERY_MAX_CHARS) { console.warn('[dsh-pcb] draft too large to mirror; chips will not survive a reload'); return }
355
+ localStorage.setItem(key, json)
356
+ } catch (e) { /* quota / private mode: best effort */ }
357
+ }
358
+
359
+ function recoveryLoad(sid) {
360
+ const key = RECOVERY_PREFIX + sid
361
+ try {
362
+ const raw = localStorage.getItem(key)
363
+ if (!raw) return null
364
+ let data = null
365
+ try { data = JSON.parse(raw) } catch (e) { data = null }
366
+ if (!data || typeof data.at !== 'number' || Date.now() - data.at > RECOVERY_TTL_MS) { localStorage.removeItem(key); return null }
367
+ return data
368
+ } catch (e) { return null }
369
+ }
370
+
371
+ function recoveryClear(sid) {
372
+ try { localStorage.removeItem(RECOVERY_PREFIX + sid) } catch (e) { /* noop */ }
373
+ }
374
+
375
+ /**
376
+ * Mirror-side carry-over donor: a store whose mirrored draft matches the
377
+ * re-seeded one exactly and still holds blocks (after a page reload there
378
+ * is no live memory to match against). Returns { sid, blocks } on a
379
+ * UNIQUE match, null when absent or ambiguous.
380
+ */
381
+ function recoveryScanDonor(targetSid, draft) {
382
+ try {
383
+ let hit = null
384
+ for (let i = 0; i < localStorage.length; i += 1) {
385
+ const key = localStorage.key(i)
386
+ if (!key || !key.startsWith(RECOVERY_PREFIX)) continue
387
+ const sid = key.slice(RECOVERY_PREFIX.length)
388
+ if (sid === targetSid) continue
389
+ const data = recoveryLoad(sid)
390
+ if (!data || data.draft !== draft || !Array.isArray(data.blocks)) continue
391
+ if (!data.blocks.some((b) => b && typeof b.content === 'string' && b.content !== '')) continue
392
+ if (hit) return null // ambiguous — refuse to guess
393
+ hit = { sid, blocks: data.blocks }
394
+ }
395
+ return hit
396
+ } catch (e) { return null }
397
+ }
398
+
399
+ function recoveryPruneExpired() {
400
+ try {
401
+ const doomed = []
402
+ for (let i = 0; i < localStorage.length; i += 1) {
403
+ const k = localStorage.key(i)
404
+ if (!k || !k.startsWith(RECOVERY_PREFIX)) continue
405
+ let at = 0
406
+ try { at = (JSON.parse(localStorage.getItem(k) || '{}').at) || 0 } catch (e) { /* fall through */ }
407
+ if (!at || Date.now() - at > RECOVERY_TTL_MS) doomed.push(k)
408
+ }
409
+ for (const k of doomed) localStorage.removeItem(k)
410
+ } catch (e) { /* noop */ }
411
+ }
412
+
272
413
  // ===== Draft controller ==================================================
273
414
  class BlockController {
274
415
  /**
@@ -289,6 +430,8 @@ window.__ModuleLoader__.load({
289
430
  this.expiry = new Map()
290
431
  this.serializing = new Set()
291
432
  this.listeners = new Map()
433
+ this.recoverTimer = new Map() // sid -> debounce handle for the recovery mirror
434
+ this.lastInput = new Map() // sid -> draft text seen by the last recover() pass
292
435
  this.counter = 0
293
436
  }
294
437
 
@@ -445,6 +588,16 @@ window.__ModuleLoader__.load({
445
588
  this.refIndex.set(block.id, { sessionId: k, block })
446
589
  this.labelIndex.set(block.label, block.id)
447
590
  this.publish(sessionId)
591
+ // The shell just rewrote the draft (chip + placeholder char); until
592
+ // the next dock pass runs, this is the session's ground truth. Keep
593
+ // the typing-window fingerprint and the carry-over donor match fresh
594
+ // so the pass right after a paste cannot mistake the new draft for a
595
+ // rebuild.
596
+ try {
597
+ const after = this.scope(k).shell.snapshot
598
+ if (after && typeof after.draft === 'string') this.lastInput.set(k, after.draft)
599
+ } catch (e) { /* session vanished — the next pass re-observes */ }
600
+ this.mirrorNow(k) // attach is a key moment — persist synchronously
448
601
  return true
449
602
  }
450
603
 
@@ -570,6 +723,7 @@ window.__ModuleLoader__.load({
570
723
  for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
571
724
  this.freeLabel(sessionId, entry.type, entry.labelNumber)
572
725
  this.publish(sessionId)
726
+ this.mirror(k)
573
727
  }
574
728
 
575
729
  /** Update a block's content while editing it in the detail card. */
@@ -578,23 +732,31 @@ window.__ModuleLoader__.load({
578
732
  if (!record) return
579
733
  record.block.content = content
580
734
  record.block.lines = content.split(/\r?\n/)
735
+ this.mirror(record.sessionId)
581
736
  }
582
737
 
583
738
  clearInFlight(sessionId) {
584
739
  const k = String(sessionId)
585
740
  const expiry = this.expiry.get(k)
586
741
  if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
742
+ const had = this.inFlight.has(k)
587
743
  for (const b of this.inFlight.get(k) || []) {
588
744
  this.refIndex.delete(b.id)
589
745
  this.freeLabel(sessionId, b.type, b.labelNumber)
590
746
  for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
591
747
  }
592
748
  this.inFlight.delete(k)
749
+ if (had) this.mirrorNow(k)
593
750
  }
594
751
 
595
752
  markSerializing(ref) {
596
753
  const record = this.refIndex.get(ref)
597
- if (record && !this.serializing.has(record.sessionId)) this.serializing.add(record.sessionId)
754
+ if (record && !this.serializing.has(record.sessionId)) {
755
+ this.serializing.add(record.sessionId)
756
+ // A send is starting: the content leaves the draft, the mirror may go.
757
+ // (A failed send re-mirrors via restoreFailed.)
758
+ recoveryClear(record.sessionId)
759
+ }
598
760
  }
599
761
 
600
762
  async serialize(ref) {
@@ -640,6 +802,12 @@ window.__ModuleLoader__.load({
640
802
  if (next.length > 0) this.list.set(k, next)
641
803
  else this.list.delete(k)
642
804
  this.publish(sessionId)
805
+ // Hand-deletion is final. Persist the pruned state synchronously so a
806
+ // later reload/carry-over cannot resurrect a deleted chip from a
807
+ // mirror the draft has already outlived — and so the recovery
808
+ // restore-path can never fire off invisible external residue once the
809
+ // list for this session is empty.
810
+ this.mirrorNow(k)
643
811
  }
644
812
 
645
813
  restoreFailed(sessionId) {
@@ -661,10 +829,168 @@ window.__ModuleLoader__.load({
661
829
  }
662
830
  if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
663
831
  this.publish(sessionId)
832
+ if (survived.length) this.mirror(k)
833
+ }
834
+
835
+ // ----- recovery mirror -------------------------------------------------
836
+ /** Debounced persistence of the session's current block payloads. */
837
+ mirror(sessionId) {
838
+ const k = String(sessionId)
839
+ const prev = this.recoverTimer.get(k)
840
+ if (prev) clearTimeout(prev)
841
+ this.recoverTimer.set(k, setTimeout(() => {
842
+ this.recoverTimer.delete(k)
843
+ this.mirrorNow(k)
844
+ }, 250))
845
+ }
846
+
847
+ mirrorNow(sessionId) {
848
+ const k = String(sessionId)
849
+ const t = this.recoverTimer.get(k)
850
+ if (t) { clearTimeout(t); this.recoverTimer.delete(k) }
851
+ recoverySave(k, this.listFor(k).map((b) => ({ isCode: !!b.isCode, lang: b.lang || '', content: b.content, src: b.sourceText || '' })), this.lastInput.get(k))
852
+ }
853
+
854
+ /**
855
+ * Restore the chips a text-only draft rebuild dropped. Two DSH paths
856
+ * strand them: (a) the persisted per-session draft re-seeds the composer
857
+ * after a page reload, and (b) a workspace switch on the new-session
858
+ * page CARRIES the draft plain-text to the target workspace's blank
859
+ * session (inputHub: next.setDraft(from.snapshot.draft)) — the chips,
860
+ * which are Lexical nodes plus controller state, never move. Both leave
861
+ * the same signature: stray U+200Bs (every chip contributes exactly one,
862
+ * and removing a chip removes its ZWSP too) with no live occurrence of
863
+ * ours. Deliberate deletion therefore never looks like a rebuild; and a
864
+ * carry-over is matched to its donor by the exact draft the donor last
865
+ * published — unique match only, no guessing. Blocks come from memory
866
+ * first (same tab), then this session's mirror, then the donor's memory
867
+ * or mirror. A stray U+200B that belongs to NO restorable block is
868
+ * ordinary invisible copy from an external app (WeChat, web pages):
869
+ * never restorable, cosmetic — it is left exactly where the user put
870
+ * it. Sweeping it used to rewrite the whole draft mid-typing and is
871
+ * retired in 0.2.2 (see the typing-window guard below).
872
+ *
873
+ * @param sessionId - session whose composer just (re)mounted.
874
+ * @param snapshot - live input state ({draft, occurrences, phase}).
875
+ * @returns whether the draft was rewritten by a real restore.
876
+ */
877
+ recover(sessionId, snapshot) {
878
+ const k = String(sessionId)
879
+ if (!snapshot || typeof snapshot.draft !== 'string') return false
880
+ const prior = this.lastInput.get(k)
881
+ this.lastInput.set(k, snapshot.draft)
882
+ if (this.serializing.has(k)) return false
883
+ if (snapshot.phase && snapshot.phase !== 'plain') return false
884
+ if ((snapshot.occurrences || []).some((o) => o.source === SOURCE)) return false
885
+ const draft = snapshot.draft
886
+ const strays = (draft.match(/\u200B/g) || []).length
887
+ if (strays === 0) return false
888
+ // Typing-window guard. Every committed keystroke re-runs this pass, so
889
+ // a draft that MOVED since the pass before it is live typing, not a
890
+ // text-only re-seed — and a real restore rewrites the whole document
891
+ // via setDraft: caret to the end, chips re-appended, an in-flight IME
892
+ // composition destroyed (0.2.2 bug: the first character typed was
893
+ // eaten and the view bounced once, again and again). Only a draft
894
+ // IDENTICAL to the last one observed for this session — or one this
895
+ // page view has never seen, or only ever saw the composer hydrate
896
+ // through while empty (fresh-boot re-seed) — can carry the rebuild
897
+ // signature. The empty-prior escape is sealed from the other side by
898
+ // reconcile pruning synchronously dropping the mirror: with nothing
899
+ // restorable on record, a moved-from-empty pass restores nothing.
900
+ if (prior && prior !== draft) return false
901
+ let shell
902
+ try {
903
+ shell = this.scope(k).shell
904
+ } catch (e) { return false } // session not ready yet — a later pass retries
905
+ const usable = (arr) => Array.isArray(arr)
906
+ ? arr.filter((b) => b && typeof b.content === 'string' && b.content !== '')
907
+ : []
908
+ let sources = usable(this.listFor(k))
909
+ let origin = sources.length ? 'memory' : null
910
+ let donor = null
911
+ if (!sources.length) {
912
+ const saved = recoveryLoad(k)
913
+ sources = usable(saved && saved.blocks)
914
+ if (sources.length) origin = 'mirror'
915
+ }
916
+ if (!sources.length) {
917
+ donor = this.findCarryoverSource(k, draft)
918
+ if (donor) {
919
+ sources = usable(this.listFor(donor))
920
+ origin = 'carry-over (live)'
921
+ }
922
+ }
923
+ if (!sources.length) {
924
+ const scanned = recoveryScanDonor(k, draft)
925
+ if (scanned && scanned.blocks.length) {
926
+ donor = scanned.sid
927
+ sources = usable(scanned.blocks)
928
+ origin = 'carry-over (mirror)'
929
+ }
930
+ }
931
+ if (sources.length === 0) {
932
+ // Nothing to restore — invisible external residue (or the residue of
933
+ // a deliberately deleted chip whose node already carries none of our
934
+ // state). NEVER rewrite the draft for it: rewriting drops the caret,
935
+ // destroys an in-flight IME composition and re-seeds stale text over
936
+ // whatever the user typed in the meantime (0.2.2).
937
+ return false
938
+ }
939
+ try {
940
+ shell.setDraft(draft.replace(/\u200B/g, ''))
941
+ } catch (e) {
942
+ console.warn('[dsh-pcb] recovery: draft rewrite failed', e)
943
+ return false
944
+ }
945
+ // The draft — and with it the blocks — now belongs to this session:
946
+ // forget the (possibly moved) bookkeeping; attach() re-derives ids,
947
+ // labels and numbers, and re-mirrors under this sid.
948
+ this.forgetSession(k)
949
+ if (donor) {
950
+ this.forgetSession(donor)
951
+ recoveryClear(donor)
952
+ this.lastInput.delete(donor)
953
+ }
954
+ let restored = 0
955
+ for (const b of sources) {
956
+ const block = { lang: b.lang || '', content: b.content, lines: b.content.split(/\r?\n/), isCode: !!b.isCode, sourceText: b.sourceText || (typeof b.src === 'string' && b.src ? b.src : undefined) }
957
+ if (this.attach(k, block)) restored += 1
958
+ }
959
+ console.log('[dsh-pcb] recovery: re-attached', restored, 'of', sources.length, 'block(s) via', origin, donor ? `(donor ${donor})` : '')
960
+ return restored > 0
961
+ }
962
+
963
+ /** Unmounted session that still lists blocks and last published EXACTLY this draft. */
964
+ findCarryoverSource(targetSid, draft) {
965
+ let found = null
966
+ for (const [x, entries] of this.list) {
967
+ if (x === targetSid || entries.length === 0) continue
968
+ if (this.serializing.has(x)) continue
969
+ const listeners = this.listeners.get(x)
970
+ if (listeners && listeners.size > 0) continue // still mounted — draft did not move
971
+ if (this.lastInput.get(x) !== draft) continue
972
+ if (found) return null // ambiguous
973
+ found = x
974
+ }
975
+ return found
976
+ }
977
+
978
+ /** Drop a session's block bookkeeping (its chips are gone or moving). */
979
+ forgetSession(sessionId) {
980
+ const k = String(sessionId)
981
+ for (const b of this.listFor(k)) {
982
+ this.freeLabel(k, b.type, b.labelNumber)
983
+ this.refIndex.delete(b.id)
984
+ for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
985
+ }
986
+ this.list.delete(k)
987
+ this.selected.delete(k)
664
988
  }
665
989
 
666
990
  dispose() {
667
991
  for (const t of this.expiry.values()) clearTimeout(t)
992
+ for (const t of this.recoverTimer.values()) clearTimeout(t)
993
+ this.recoverTimer.clear()
668
994
  this.expiry.clear(); this.list.clear(); this.selected.clear()
669
995
  this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
670
996
  this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
@@ -727,6 +1053,12 @@ window.__ModuleLoader__.load({
727
1053
  useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
728
1054
 
729
1055
  React.useEffect(() => {
1056
+ // Recognize a text-only draft re-seed (workspace switch / reload)
1057
+ // FIRST: re-attach lost chips before reconciliation prunes them. A
1058
+ // successful recovery mutates the draft, so this pass must NOT
1059
+ // reconcile against its now-stale snapshot — the next draftRev pass
1060
+ // does that with fresh occurrences.
1061
+ if (props.controller.recover(props.sessionId, input)) return
730
1062
  props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
731
1063
  }, [props.controller, props.sessionId, input.draftRev, input.phase])
732
1064
 
@@ -834,6 +1166,162 @@ window.__ModuleLoader__.load({
834
1166
  )
835
1167
  }
836
1168
 
1169
+ // ===== Sent-message fold =================================================
1170
+ // A block that folded in the composer comes back *after send* as fenced
1171
+ // text inside the user bubble (DSH renders user messages as plain
1172
+ // pre-wrap runs), so the wall of text this plugin exists to prevent
1173
+ // reappears in the transcript. scanFolds() (see apply) walks sent
1174
+ // bubbles and folds every complete fenced block into a collapsed card:
1175
+ // [</>] python · 128 行 [⎘] ▾ (header click toggles)
1176
+ // React owns the bubble's own children, so nothing is removed or
1177
+ // reordered: the plainRun span being folded is hidden (display:none) and
1178
+ // a host element we fully own is inserted directly after it. A content
1179
+ // stamp on the run keeps the scan idempotent and self-refreshing — text
1180
+ // or locale revision changes rebuild the host, and a fold that becomes
1181
+ // unnecessary is undone. The copy button copies the RAW fenced text
1182
+ // (exactly what was sent); the bubble's own copy/edit/delete bar is
1183
+ // React-side and never sees this DOM.
1184
+ const FOLD_HOST_CLASS = 'dsh-pcb-fold-host'
1185
+ const FOLD_CARD_CLASS = 'dsh-pcb-fold'
1186
+ const FOLD_RUN_ATTR = 'data-dsh-pcb-run' // '1|<stamp>' folded · '0|<stamp>' checked, plain
1187
+ const FOLD_STAMP_ATTR = 'data-dsh-pcb-stamp' // on the host, matches the run's stamp
1188
+ const FOLD_BUBBLE_SEL = [
1189
+ '[data-chat-flow-kind="user"]',
1190
+ '[data-chat-flow-kind="steering"]',
1191
+ '[data-submission-echo]',
1192
+ '[data-pending-steering]',
1193
+ ].map((s) => `${s} [class*="bubble"]`).join(',')
1194
+ const foldOpenKeys = new Set() // user-toggled-open cards, stable across rebuilds
1195
+ let foldRev = 0 // bumped on locale switch so cards re-title
1196
+
1197
+ function hash32(str) {
1198
+ let h = 0x811c9dc5
1199
+ for (let i = 0; i < str.length; i += 1) {
1200
+ h ^= str.charCodeAt(i)
1201
+ h = Math.imul(h, 0x01000193)
1202
+ }
1203
+ return (h >>> 0).toString(36)
1204
+ }
1205
+
1206
+ function foldStamp(text) {
1207
+ return `${foldRev}:${text.length}:${hash32(text)}`
1208
+ }
1209
+
1210
+ // Cheap pre-filter so the per-frame scan stays trivial on unfenced runs.
1211
+ const FENCE_HINT = /(^|\n) {0,3}(```|~~~)/
1212
+
1213
+ function isFoldRun(el) {
1214
+ return el.tagName === 'SPAN' && (el.getAttribute('class') || '').includes('plainRun')
1215
+ }
1216
+
1217
+ /** Segments when the text holds at least one non-empty complete fence, else null. */
1218
+ function foldPlanFor(text) {
1219
+ if (!FENCE_HINT.test(text)) return null
1220
+ const segments = splitFencedSegments(text)
1221
+ let folds = 0
1222
+ for (const seg of segments) if (seg.kind === 'fence' && seg.body.trim() !== '') folds += 1
1223
+ return folds > 0 ? segments : null
1224
+ }
1225
+
1226
+ function buildFoldCard(seg, openKey, t) {
1227
+ const infoLang = (seg.lang || '').toLowerCase()
1228
+ // Only an EXPLICIT ```text fence is a text block. The composer never
1229
+ // emits a bare fence for prose (plain blocks always serialize as
1230
+ // ```text), so a bare ``` fence is code whose language the composer
1231
+ // could not name — fold it as a code card and re-guess the language
1232
+ // for display. (0.2.0 bug: bare fences folded as 文本块, mismatching
1233
+ // the 代码块 chip the message was drafted from.)
1234
+ const isText = infoLang === 'text'
1235
+ const lang = isText ? '' : (infoLang || detectLang(seg.body) || '')
1236
+ const type = isText ? 'text' : 'code'
1237
+ const lines = seg.body.split('\n').length
1238
+ const open = foldOpenKeys.has(openKey)
1239
+
1240
+ const card = document.createElement('div')
1241
+ card.className = FOLD_CARD_CLASS
1242
+ card.setAttribute('data-pcb-type', type)
1243
+ card.setAttribute('data-open', open ? '1' : '0')
1244
+
1245
+ const head = document.createElement('div')
1246
+ head.className = 'dsh-pcb-fold-head'
1247
+
1248
+ const toggle = document.createElement('button')
1249
+ toggle.type = 'button'
1250
+ toggle.className = 'dsh-pcb-fold-toggle'
1251
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false')
1252
+ toggle.setAttribute('aria-label', t('fold.aria'))
1253
+ toggle.title = t(open ? 'collapse' : 'expand')
1254
+ const glyph = document.createElement('span')
1255
+ glyph.className = 'dsh-pcb-fold-glyph'
1256
+ glyph.setAttribute('aria-hidden', 'true')
1257
+ glyph.textContent = type === 'code' ? '</>' : 'Aa'
1258
+ const title = document.createElement('span')
1259
+ title.className = 'dsh-pcb-fold-title'
1260
+ title.textContent = foldTitle(t, type, lang, lines)
1261
+ const chevron = document.createElement('span')
1262
+ chevron.className = 'dsh-pcb-fold-chevron'
1263
+ chevron.setAttribute('aria-hidden', 'true')
1264
+ chevron.textContent = '▾'
1265
+ toggle.append(glyph, title, chevron)
1266
+
1267
+ const copy = document.createElement('button')
1268
+ copy.type = 'button'
1269
+ copy.className = 'dsh-pcb-btn dsh-pcb-fold-copy'
1270
+ copy.title = t('copy')
1271
+ copy.setAttribute('aria-label', t('copy'))
1272
+ copy.textContent = '⎘'
1273
+ let copyTimer = 0
1274
+ copy.addEventListener('click', (ev) => {
1275
+ ev.preventDefault()
1276
+ ev.stopPropagation()
1277
+ try {
1278
+ const done = navigator.clipboard?.writeText(seg.raw)
1279
+ if (done && typeof done.then === 'function') {
1280
+ done.then(() => {
1281
+ copy.textContent = '✓'
1282
+ clearTimeout(copyTimer)
1283
+ copyTimer = setTimeout(() => { copy.textContent = '⎘' }, 1200)
1284
+ }, () => {})
1285
+ }
1286
+ } catch (e) { /* noop */ }
1287
+ })
1288
+
1289
+ head.append(toggle, copy)
1290
+ const pre = document.createElement('pre')
1291
+ pre.className = 'dsh-pcb-fold-body'
1292
+ pre.textContent = seg.body
1293
+ card.append(head, pre)
1294
+
1295
+ toggle.addEventListener('click', (ev) => {
1296
+ ev.preventDefault()
1297
+ ev.stopPropagation()
1298
+ const nowOpen = card.getAttribute('data-open') !== '1'
1299
+ card.setAttribute('data-open', nowOpen ? '1' : '0')
1300
+ toggle.setAttribute('aria-expanded', nowOpen ? 'true' : 'false')
1301
+ toggle.title = t(nowOpen ? 'collapse' : 'expand')
1302
+ if (nowOpen) foldOpenKeys.add(openKey)
1303
+ else foldOpenKeys.delete(openKey)
1304
+ })
1305
+ return card
1306
+ }
1307
+
1308
+ function buildFoldHost(segments, keyBase, t) {
1309
+ const host = document.createElement('div')
1310
+ host.className = FOLD_HOST_CLASS
1311
+ segments.forEach((seg, i) => {
1312
+ if (seg.kind === 'fence' && seg.body.trim() !== '') {
1313
+ host.appendChild(buildFoldCard(seg, `${keyBase}:${i}`, t))
1314
+ } else {
1315
+ const prose = document.createElement('span')
1316
+ prose.className = 'dsh-pcb-fold-prose'
1317
+ // An un-foldable fence (empty body) keeps its raw text, fences and all.
1318
+ prose.textContent = seg.kind === 'fence' ? seg.raw : seg.text
1319
+ host.appendChild(prose)
1320
+ }
1321
+ })
1322
+ return host
1323
+ }
1324
+
837
1325
  // ===== Styles ============================================================
838
1326
  const CSS = `
839
1327
  /* --- restyle DSH's inline reference chip into a boxed badge --- */
@@ -905,9 +1393,26 @@ window.__ModuleLoader__.load({
905
1393
  /* --- hide the composer's own placeholder while chips/cards are active --- */
906
1394
  [data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
907
1395
 
1396
+ /* --- sent-message fold cards (conversation bubbles) --- */
1397
+ .dsh-pcb-fold-host{box-sizing:border-box;display:flex;flex-direction:column;gap:8px;width:100%;min-width:0}
1398
+ .dsh-pcb-fold-prose{white-space:pre-wrap;word-break:break-word}
1399
+ .dsh-pcb-fold{box-sizing:border-box;width:100%;min-width:0;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1));box-shadow:var(--dsw-shadow-lv1);color:var(--dsw-alias-label-primary);overflow:hidden}
1400
+ .dsh-pcb-fold-head{box-sizing:border-box;min-height:34px;flex:none;display:flex;align-items:center;gap:4px;padding:0 6px 0 10px}
1401
+ .dsh-pcb-fold-toggle{flex:1;min-width:0;display:flex;align-items:center;gap:7px;padding:0;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
1402
+ .dsh-pcb-fold-glyph{flex:none;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;font-weight:700;line-height:16px;opacity:.9}
1403
+ .dsh-pcb-fold[data-pcb-type="code"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-state-business-primary,#4c6ef5)}
1404
+ .dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-label-tertiary)}
1405
+ .dsh-pcb-fold-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
1406
+ .dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-title{font-family:var(--dsw-font-family)}
1407
+ .dsh-pcb-fold-chevron{flex:none;width:14px;text-align:center;font-size:11px;line-height:1;color:var(--dsw-alias-label-tertiary);transition:transform .14s ease}
1408
+ .dsh-pcb-fold[data-open="1"] .dsh-pcb-fold-chevron{transform:rotate(180deg)}
1409
+ .dsh-pcb-fold[data-open="0"] .dsh-pcb-fold-body{display:none}
1410
+ .dsh-pcb-fold-body{box-sizing:border-box;margin:0;max-height:360px;overflow:auto;padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l2);font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:1.5;white-space:pre;word-break:break-all;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
1411
+
908
1412
  @media (max-width:640px){
909
1413
  .dsh-pcb-head{height:34px}
910
1414
  .dsh-pcb-lang{max-width:55%}
1415
+ .dsh-pcb-fold-body{max-height:240px}
911
1416
  }
912
1417
  @media (pointer:coarse){
913
1418
  .${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
@@ -918,7 +1423,9 @@ window.__ModuleLoader__.load({
918
1423
  const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
919
1424
 
920
1425
  function apply(ctx) {
921
- try { console.log('[dsh-paste-code-block] client loaded') } catch (e) { /* noop */ }
1426
+ // Bump alongside package.json on every release — the load log is the
1427
+ // only proof of WHICH bundle generation the browser actually loaded.
1428
+ try { console.log('[dsh-paste-code-block] client loaded v0.2.5') } catch (e) { /* noop */ }
922
1429
 
923
1430
  // Publish our dictionaries under the plugin namespace, then bind a
924
1431
  // translator that always reflects the ACTIVE DSH language (Settings →
@@ -932,7 +1439,10 @@ window.__ModuleLoader__.load({
932
1439
  : createT(detectBrowserLocale())
933
1440
 
934
1441
  const controller = new BlockController(ctx, t)
1442
+ // Test seam (tests/fold-smoke.test.js drives recover() directly).
1443
+ exports.__controllerForTests = controller
935
1444
  ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
1445
+ try { recoveryPruneExpired() } catch (e) { /* private mode: recovery simply off */ }
936
1446
 
937
1447
  // ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
938
1448
  function scanChips() {
@@ -983,10 +1493,73 @@ window.__ModuleLoader__.load({
983
1493
  }
984
1494
  }
985
1495
 
1496
+ // ----- sent-message fold: the scan ------------------------------------
1497
+ function foldRun(run, plan, keyBase, stamp) {
1498
+ run.setAttribute(FOLD_RUN_ATTR, `1|${stamp}`)
1499
+ run.style.display = 'none'
1500
+ const next = run.nextElementSibling
1501
+ if (next && next.classList.contains(FOLD_HOST_CLASS)) {
1502
+ if (next.getAttribute(FOLD_STAMP_ATTR) === stamp) return // already current
1503
+ next.remove()
1504
+ }
1505
+ const host = buildFoldHost(plan, keyBase, t)
1506
+ host.setAttribute(FOLD_STAMP_ATTR, stamp)
1507
+ run.insertAdjacentElement('afterend', host)
1508
+ }
1509
+
1510
+ /** Restore a previously folded run to DSH's own rendering. */
1511
+ function unfoldRun(run, stamp) {
1512
+ const next = run.nextElementSibling
1513
+ if (next && next.classList.contains(FOLD_HOST_CLASS)) next.remove()
1514
+ run.style.display = ''
1515
+ run.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
1516
+ }
1517
+
1518
+ function scanFolds() {
1519
+ if (typeof document === 'undefined') return
1520
+ let bubbles
1521
+ try { bubbles = document.querySelectorAll(FOLD_BUBBLE_SEL) } catch (e) { return }
1522
+ for (const bubble of bubbles) {
1523
+ if (!bubble.isConnected) continue
1524
+ const holder = bubble.closest('[data-chat-flow-key]')
1525
+ const flowKey = holder ? holder.getAttribute('data-chat-flow-key') || '' : ''
1526
+ const kids = [...bubble.children]
1527
+ for (let i = 0; i < kids.length; i += 1) {
1528
+ const el = kids[i]
1529
+ if (el.classList.contains(FOLD_HOST_CLASS)) continue
1530
+ if (!isFoldRun(el)) continue
1531
+ const text = el.textContent || ''
1532
+ const stamp = foldStamp(text)
1533
+ const marked = el.getAttribute(FOLD_RUN_ATTR) || ''
1534
+ if (marked === `1|${stamp}` || marked === `0|${stamp}`) continue // current
1535
+ const plan = foldPlanFor(text)
1536
+ if (!plan) {
1537
+ if (marked.startsWith('1|')) unfoldRun(el, stamp)
1538
+ else el.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
1539
+ continue
1540
+ }
1541
+ // Echo/pending rows have no flow key — fall back to a content hash so
1542
+ // two simultaneous unkeyed rows never share one open-state slot.
1543
+ const keyBase = `${flowKey || 'h' + hash32(text)}#${i}`
1544
+ foldRun(el, plan, keyBase, stamp)
1545
+ }
1546
+ // Drop orphan hosts (e.g. React remounted the run span underneath us).
1547
+ for (const el of [...bubble.children]) {
1548
+ if (!el.classList.contains(FOLD_HOST_CLASS)) continue
1549
+ const prev = el.previousElementSibling
1550
+ const mark = prev && prev.getAttribute ? prev.getAttribute(FOLD_RUN_ATTR) : null
1551
+ const keep = !!mark && mark.startsWith('1|') &&
1552
+ prev.style.display === 'none' &&
1553
+ el.getAttribute(FOLD_STAMP_ATTR) === mark.slice(2)
1554
+ if (!keep) el.remove()
1555
+ }
1556
+ }
1557
+ }
1558
+
986
1559
  let scanScheduled = 0
987
1560
  function scheduleScan() {
988
1561
  if (scanScheduled) return
989
- const run = () => { scanScheduled = 0; scanChips() }
1562
+ const run = () => { scanScheduled = 0; scanChips(); scanFolds() }
990
1563
  scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
991
1564
  }
992
1565
 
@@ -998,13 +1571,28 @@ window.__ModuleLoader__.load({
998
1571
  }, 'paste-code-block: chip sync observer')
999
1572
 
1000
1573
  // A locale switch (or a late dictionary registration bumps the revision
1001
- // too) re-derives labels: controller state first, chip DOM second.
1574
+ // too) re-derives labels: controller state first, chip DOM second. Sent
1575
+ // fold cards are rebuilt the same way — bumping foldRev invalidates
1576
+ // every run stamp, so the next scan re-titles the cards in the new
1577
+ // language while foldOpenKeys keeps who was expanded.
1002
1578
  ctx.effect(() => {
1003
1579
  const face = ctx.locale
1004
1580
  if (!face || typeof face.subscribe !== 'function') return undefined
1005
- return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
1581
+ return face.subscribe(() => { controller.retitleAll(); foldRev += 1; scheduleScan() })
1006
1582
  }, 'paste-code-block: locale sync')
1007
- scheduleScan() // late-mount safety for chips rendered before plugin boot
1583
+ scheduleScan() // late-mount safety for chips and sent bubbles rendered before plugin boot
1584
+
1585
+ // On dispose (plugin removed / HMR swap) hand the transcript back to DSH.
1586
+ ctx.effect(() => () => {
1587
+ try {
1588
+ for (const host of document.querySelectorAll(`.${FOLD_HOST_CLASS}`)) host.remove()
1589
+ for (const run of document.querySelectorAll(`[${FOLD_RUN_ATTR}]`)) {
1590
+ run.style.display = ''
1591
+ run.removeAttribute(FOLD_RUN_ATTR)
1592
+ }
1593
+ } catch (e) { /* noop */ }
1594
+ foldOpenKeys.clear()
1595
+ }, 'paste-code-block: fold restore')
1008
1596
 
1009
1597
  ctx.effect(() => ctx.inputTriggers.registerSource({
1010
1598
  trigger: '@',
@@ -1026,6 +1614,34 @@ window.__ModuleLoader__.load({
1026
1614
  return () => style.remove()
1027
1615
  }, 'paste-code-block: styles')
1028
1616
 
1617
+ // Paste-storm guard. External automation — a Tampermonkey AutoClicker,
1618
+ // a clipboard bridge, IME glue, or a held-down Ctrl+V — can re-dispatch
1619
+ // the SAME clipboard text dozens of times a second. Each accepted paste
1620
+ // mints a new block, so a storm stuffs the composer with duplicate
1621
+ // chips. Collapse runs of an identical clipboard text that arrive faster
1622
+ // than a human can act into a single paste (a 1200 ms sliding window,
1623
+ // refreshed on every matching event, so a held repeat stays suppressed
1624
+ // while it continues). 0.2.4 added a content-liveness dedup that ALSO
1625
+ // suppressed a re-paste of the same text while its block was still
1626
+ // attached — that made "paste the same snippet twice" impossible, which
1627
+ // users hit immediately. 0.2.5 reverts that: identical text pasted after
1628
+ // the burst window is a fresh intent and gets its own block. Only
1629
+ // machine-paced repeats within the window are collapsed.
1630
+ const STORM_WINDOW_MS = 1200
1631
+ const pasteStorm = {
1632
+ lastText: '',
1633
+ lastAt: 0,
1634
+ // True when this text is the tail of a storm; every call refreshes
1635
+ // the window, so a held repeat stays suppressed while it continues.
1636
+ seen(text, now) {
1637
+ const dup = text === this.lastText && now - this.lastAt < STORM_WINDOW_MS
1638
+ this.lastText = text
1639
+ this.lastAt = now
1640
+ return dup
1641
+ },
1642
+ }
1643
+ exports.__pasteStormForTests = pasteStorm
1644
+
1029
1645
  // Turn qualifying pastes into boxed inline chips.
1030
1646
  const onPaste = (ev) => {
1031
1647
  const clip = ev.clipboardData
@@ -1038,7 +1654,19 @@ window.__ModuleLoader__.load({
1038
1654
  if (!sid) return
1039
1655
  const block = parseBlock(text)
1040
1656
  if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
1041
- console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang)
1657
+ if (pasteStorm.seen(text, Date.now())) {
1658
+ // Consume the event (never let the raw text fall through to the
1659
+ // composer) but create nothing.
1660
+ ev.preventDefault()
1661
+ ev.stopPropagation()
1662
+ console.log('[dsh-paste-code-block] storm paste ignored (<' + STORM_WINDOW_MS + 'ms repeat)')
1663
+ return
1664
+ }
1665
+ block.sourceText = text
1666
+ // trusted=false means a page script dispatched this paste (userscript,
1667
+ // clipboard bridge, replay glue) rather than a real keystroke — the
1668
+ // load-log line that names the source when a storm report comes in.
1669
+ console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang, 'trusted=', ev.isTrusted)
1042
1670
  ev.preventDefault()
1043
1671
  ev.stopPropagation()
1044
1672
  try {
package/src/i18n.js CHANGED
@@ -34,6 +34,9 @@ export const zh = {
34
34
  'edit.aria': '编辑代码块内容',
35
35
  'error.stale': '代码块已失效,请重新粘贴',
36
36
  'error.remove': '无法删除该块,请重试',
37
+ 'fold.code': '代码块',
38
+ 'fold.text': '文本块',
39
+ 'fold.aria': '展开或折叠该块',
37
40
  }
38
41
 
39
42
  /** English dictionary, key-identical to the Chinese source of truth. */
@@ -53,6 +56,9 @@ export const en = {
53
56
  'edit.aria': 'Edit block content',
54
57
  'error.stale': 'This block is no longer valid — paste it again',
55
58
  'error.remove': 'Could not remove the block — please retry',
59
+ 'fold.code': 'Code',
60
+ 'fold.text': 'Text',
61
+ 'fold.aria': 'Expand or collapse this block',
56
62
  }
57
63
 
58
64
  /** Shipped dictionaries keyed by locale id (mirrors DSH's built-in zh/en set). */
@@ -97,6 +103,19 @@ export function blockLabel(t, type, n) {
97
103
  return t(type === 'code' ? 'block.code' : 'block.text', { n })
98
104
  }
99
105
 
106
+ /**
107
+ * Build the title of a SENT-message fold card: `python · 128 行` /
108
+ * `Code · 128 lines`. `lang` (the fence info string) names code cards when
109
+ * present; text cards and untagged code cards fall back to the localized
110
+ * type name. Line count always renders, so users can gauge size while the
111
+ * card is collapsed.
112
+ */
113
+ export function foldTitle(t, type, lang, lines) {
114
+ const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
115
+ const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
116
+ return `${name} · ${meta}`
117
+ }
118
+
100
119
  /**
101
120
  * Recognize a rendered block label in ANY shipped locale (or a pre-0.1.3
102
121
  * legacy locale) and recover its `{ type, n }` identity. This lets a chip
package/src/parse.js CHANGED
@@ -99,4 +99,68 @@ export function countMarkdownSignals(text) {
99
99
  export function serializeBlock(block) {
100
100
  const lang = block.isCode ? (block.lang || '') : 'text'
101
101
  return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
102
+ }
103
+
104
+ /**
105
+ * Split a message text into ordered `prose` / `fence` segments using GFM-style
106
+ * fenced code blocks (``` or ~~~, opening fence indented at most 3 spaces,
107
+ * closing fence of >= the opening length and alone on its line).
108
+ *
109
+ * Used by the sent-message fold scanner (see design.md §12): a message the
110
+ * plugin serialized on send comes back as fenced text inside the user
111
+ * bubble, and this function locates the fences so each can be rendered as a
112
+ * collapsed card while the surrounding prose stays visible.
113
+ *
114
+ * Returns [] for empty input. Segments:
115
+ * - { kind: 'prose', text } — verbatim lines between fences
116
+ * - { kind: 'fence', lang, info, body, raw } — complete, terminated fence
117
+ * lang = first word of the info string ('' when absent)
118
+ * info = trimmed info string
119
+ * body = content between the fence lines (no fences, verbatim)
120
+ * raw = the whole slice including both fence lines
121
+ * An unterminated opening fence (no matching close) is kept in the prose run
122
+ * — conservative: we never fold text we are not sure ended.
123
+ */
124
+ export function splitFencedSegments(text) {
125
+ const src = String(text == null ? '' : text)
126
+ if (!src) return []
127
+ const lines = src.split(/\r?\n/)
128
+ const segments = []
129
+ const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
130
+ let proseStart = 0
131
+ let i = 0
132
+ const flushProse = (end) => {
133
+ if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
134
+ }
135
+ while (i < lines.length) {
136
+ const m = OPEN.exec(lines[i])
137
+ if (!m) { i += 1; continue }
138
+ const marker = m[1]
139
+ const ch = marker[0]
140
+ const info = m[2].trim()
141
+ // A ``` fence's info string may not contain backticks (GFM); if it does,
142
+ // this line is ordinary prose, not an opener.
143
+ if (ch === '`' && info.includes('`')) { i += 1; continue }
144
+ const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
145
+ let j = i + 1
146
+ let closed = false
147
+ while (j < lines.length) {
148
+ if (closeRe.test(lines[j])) { closed = true; break }
149
+ j += 1
150
+ }
151
+ if (!closed) { i += 1; continue } // unterminated -> stays prose, keep scanning
152
+ flushProse(i)
153
+ const bodyLines = lines.slice(i + 1, j)
154
+ segments.push({
155
+ kind: 'fence',
156
+ lang: info ? info.split(/\s+/)[0] : '',
157
+ info,
158
+ body: bodyLines.join('\n'),
159
+ raw: lines.slice(i, j + 1).join('\n'),
160
+ })
161
+ i = j + 1
162
+ proseStart = i
163
+ }
164
+ flushProse(lines.length)
165
+ return segments
102
166
  }