dsh-paste-code-block 0.2.5 → 0.2.7

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/docs/design.md CHANGED
@@ -1,337 +1,373 @@
1
- # Design notes — dsh-paste-code-block
2
-
3
- This document captures the non-obvious design decisions behind the plugin, so future
4
- contributors understand *why* the code is the way it is. It is not a usage guide — see
5
- [README.md](../README.md) for that.
6
-
7
- ## 1. One plugin, two halves
8
-
9
- A DSH plugin can ship a **host** (node) half and a **client** (browser) half. This plugin is a
10
- *pure UI* plugin: all behavior lives on the browser side.
11
-
12
- - `src/index.js` — host half. **Intentionally empty.** It exists only so the package resolves
13
- as a DSH plugin and so the `dsh.client` declaration in `package.json` is discovered.
14
- - `src/client.js` — browser half. A self-contained UMD bundle loaded via
15
- `window.__ModuleLoader__.load({ id, factory })`. It captures pastes, restyles DSH's inline
16
- reference chips into boxed badges, and renders an editing detail card above the composer.
17
-
18
- Because the client is loaded by DSH's module loader — which resolves *package names*, not
19
- relative ESM files — `client.js` **cannot** `import` a sibling module. Its runtime logic is
20
- therefore **embedded** in the bundle. To keep that logic unit-testable, the same functions are
21
- extracted into plain, dependency-free ESM modules which the test suite exercises:
22
-
23
- - `src/parse.js` — block detection / language detection / serialization.
24
- - `src/i18n.js` — locale dictionaries, label building, and label parsing.
25
-
26
- **Keep `src/parse.js` / `src/i18n.js` and their embedded copies in `src/client.js` in sync.**
27
-
28
- ## 2. How a paste becomes a card (the pipeline)
29
-
30
- ```
31
- paste event
32
- └─ parseBlock(text) → Block | null (pure logic, in src/parse.js)
33
- └─ is short plain prose? → null → paste as-is (don't intercept)
34
- └─ else → { lang, content, lines, isCode }
35
- └─ controller.attach(sid, block)
36
- ├─ nextLabel(sid, type) → smallest free number (code/text counted apart)
37
- ├─ insertReference(...) → DSH 'slash/input-insert-reference' with clipboardText '\u200B'
38
- └─ publish(sid) → re-render the dock
39
- ```
40
-
41
- ## 3. The hidden-reference codec
42
-
43
- The card is rendered by DSH's existing **hidden-reference (chip)** machinery — the same
44
- mechanism that renders file/upload references inline. The plugin registers a source under the
45
- `@` input trigger:
46
-
47
- - `trigger: '@'`, `name: 'paste-code-block'`
48
- - `codec.clipboardText` → `''` (the chip leaves no visible text on copy)
49
- - `codec.serialize(ref)` → the block's fenced ```` ```lang … ``` ```` text
50
-
51
- The chip label is the localized human-readable badge (`代码块 N` / `Code #N` — see §9);
52
- clicking it opens the detail card, and each chip carries a `×` button that deletes the block
53
- (see §10).
54
-
55
- ## 4. Why `clipboardText` is U+200B (zero-width space)
56
-
57
- `insertReference` passes `clipboardText: '\u200B'`. This is deliberate:
58
-
59
- - Each chip contributes exactly **one character** to the editor's detect text.
60
- - `String.prototype.trim()` does **not** strip U+200B, so the composer's empty check
61
- (`draft.trim() === ""`) sees a draft with only chips as *non-empty*, keeping the send button
62
- enabled even when the user pasted a block with no surrounding text.
63
- - Because `draft.length` then equals the detect length, anchoring insertions at `draft.length`
64
- keeps chips strictly ordered — fixing the earlier "every 3rd block shifts forward by 1" bug.
65
-
66
- ## 5. Numbering and reuse
67
-
68
- Numbers are tracked **per session, split by type** (`code` vs `text`):
69
-
70
- - `nextLabel` always allocates the **smallest free integer** (`while (used.has(n)) n++`).
71
- - Deleting a chip frees its number; the next paste reuses it.
72
-
73
- So `代码块 1` … `代码块 N` and `文本块 1` … `文本块 M` each count independently and
74
- stay dense after removals.
75
-
76
- ## 6. Markdown vs. code detection
77
-
78
- The trickiest heuristic is deciding whether pasted text is *code* or *markdown prose*. The
79
- signal counter `countMarkdownSignals` scores structural markdown (headings, blockquotes, lists,
80
- links, bold, tables, hr). A lone `#` comment in code scores 1 and stays code; a real markdown
81
- document scores ≥2 and is treated as text. Fenced snippets always win as code.
82
-
83
- ## 7. Anchor stability and reconcile
84
-
85
- Blocks are **appended at the end of the draft**, reusing DSH's hidden-reference semantics so
86
- cursor/undo stay stable. During a submit the chips may transiently vanish; `reconcile` defers
87
- pruning while `serializing` is setatch, and `restoreFailed` re-surfaces in-flight blocks if the
88
- send fails — so a failed send auto-refills the input.
89
-
90
- ## 8. Testing strategy
91
-
92
- `tests/parse.test.js` and `tests/i18n.test.js` exercise the two pure modules (`src/parse.js`,
93
- `src/i18n.js`) with the built-in `node:test` runner (no test framework dependency). The
94
- DOM/controller glue is not unit-tested because it depends on the live DSH client runtime; it is
95
- verified manually in the browser.
96
-
97
- ```sh
98
- npm test
99
- ```
100
-
101
- ## 9. Localization (i18n)
102
-
103
- Block names are produced through **DSH's own locale service**, the same one first-party plugins
104
- (schedule, session-log-export, …) use:
105
-
106
- - `apply()` registers the `paste-code-block` namespace via `ctx.locale.register(NS, {zh, en})`
107
- with *identical key sets* in both dictionaries (the built-in locale runtime assumes that).
108
- - All user-visible strings — chip labels (`block.code` / `block.text` with `{n}`), button
109
- tooltips, the `{count}` line counter (with a `lines.one` / `lines.other` plural split),
110
- aria labels, slot label, and the stale-block send error — go through the bound translator
111
- `t(key, params)`.
112
- - The dock slot entry declares `locale: NS`, so the renderer hands it a fresh `t` seat **and
113
- re-renders it on every locale revision**.
114
-
115
- **Naming and visual identity (0.1.3).** A chip is a *reference badge*, not an action button, so
116
- the pre-0.1.3 names carried a verb nobody invokes on it — `复制代码块N` / `Code block #N` read
117
- like a command and burned width inside the composer. Current names are noun + number
118
- (`代码块 1` / `Code #1`). The code/text distinction moved from wording to visuals: `scanChips`
119
- tags every chip host with `data-pcb-type`, and CSS renders a leading glyph — `</>` for code
120
- (mono, business-primary) and `Aa` for text (neutral gray) — plus a soft blue tint on code
121
- chips. Pre-0.1.3 labels stay parseable through the `legacyLabels` table, so chips inserted by
122
- an older bundle keep their click/✕/retitle identity until the next locale-synced retitle.
123
-
124
- **Chip retitling across switches.** The chip label is *insert-time* content: Lexical caches the
125
- label inside the decorator node, and React never re-renders those node bodies while the node
126
- state is unchanged. So on a locale switch the plugin: (1) recomputes `block.label` for every
127
- tracked block and rebuilds `labelIndex` (`retitleAll`), then (2) patches the visible DOM —
128
- `title` attribute and label span `.textContent` — inside `scanChips`. React's virtual DOM
129
- record still says the old label, so it never fights the patch; a full chip remount (the only
130
- revert vector) is caught by the same MutationObserver that manages the ✕ button.
131
-
132
- **Cross-language identity.** `parseBlockLabel` recognizes a rendered label in **any** shipped
133
- locale and recovers `{ type, n }`; lookups (chip click, ✕ delete, retitle matching) resolve the
134
- block by that identity within the chip's own session. A chip inserted under one language keeps
135
- working after switching to the other.
136
-
137
- ## 10. The per-chip ✕ delete button
138
-
139
- DSH renders chips from Lexical decorator nodes; third parties cannot add React children to
140
- them. Instead the plugin **appends a `<span class="dsh-pcb-chip-x">×</span>` into each chip's
141
- inner element** and keeps them present with a `MutationObserver` on `document.body`
142
- (rAF-coalesced; the scan exits immediately when no block is tracked). A capture-phase
143
- `pointerdown` handler checks `.dsh-pcb-chip-x` **before** the toggle logic and calls
144
- `controller.remove`, which removes **just that chip's Lexical node** and frees its number. If
145
- the chip is not backed by a tracked block (e.g. a restored draft after redraw), no ✕ is added —
146
- no dead affordances. Touch targets grow on coarse pointers (`@media (pointer:coarse)`).
147
-
148
- **Why node-level removal matters — the 0.1.1 incident.** The first `remove()` implementation
149
- spliced the chip's placeholder char out of the draft string and wrote the result through
150
- `shell.setDraft()`. But `setDraft` **clears the editor root and rebuilds plain-text
151
- paragraphs** — chips are decorator nodes that cannot round-trip through text, so deleting one
152
- block destroyed *every* chip in the draft. `removeChipNodes()` instead scans the editor's node
153
- map for `ReferenceChipNode`s whose public `getSource()`/`getReference()` match this plugin and
154
- the block's ref, and calls the node's own `remove()` inside one discrete update — a surgical,
155
- undoable (`Ctrl+Z`) deletion that leaves sibling chips untouched.
156
-
157
- **The 0.1.2 silent no-op.** The node map (`editor.getEditorState()._nodeMap`) is keyed by
158
- NodeKey and **its values are the nodes themselves**; the older `NodeState` wrapper with a
159
- `.node` field no longer exists. Reading `value.node` therefore matched *nothing* for every
160
- entry, `removeChipNodes()` removed zero chips, yet the call looked successful — the block's
161
- state was pruned behind a chip that was still in the draft, so clicking ✕ appeared to do
162
- nothing at all. `src/chip-nodes.js` now normalizes both shapes (`chipNodeOf`) and is unit
163
- tested for exactly this rule; `tests/lexical-chip-probe.mjs` reproduces the original defect
164
- against real `lexical@0.49.0`:
165
-
166
- ```
167
- _nodeMap value shape: valueIsNode=true, hasNodeProp=false
168
- OLD code (value.node): removed = 0 | chips after = r1,r2,r3 <- silent no-op
169
- NEW code (value is node): removed = 1 | chips after = r1,r3 <- only the target
170
- ```
171
-
172
- **Failure is never silent and never destructive.** `remove()` gates every state change on
173
- `chipPresent()` — the editor's node map first, the published occurrence projection as a
174
- cross-check — so a block is pruned only once its chip is verifiably gone. If the chip survives
175
- the attempt the block stays tracked and the composer surfaces `error.remove`; the old
176
- `setDraft` fallback is gone for good. State that diverges from the editor still self-heals via
177
- `reconcile`.
178
-
179
- ## 11. Insertion leaves no stray space
180
-
181
- `SessionInputShell.insertReference` inserts `[chip, ' ']` — a separating space — whenever the
182
- character at the pick-time span is not itself a space. Because a block is always appended at
183
- the **end** of the draft, that condition is always true, so every paste used to leave a space
184
- behind: text typed next started with a space (the visible "prefix space"), and pasting several
185
- blocks piled stray spaces into the message body.
186
-
187
- Two rules keep the draft clean without ever touching user whitespace:
188
-
189
- 1. **Insert, then drop exactly the appended character.** `dropSeparatorSpace()` asks
190
- `separatorSpaceSpan()` (src/projection.js) for the detect span of the character directly
191
- after *our* chip and deletes it with the same public `slash/input-insert-text` verb DSH uses
192
- for its own span edits. Since the chip is inserted at the end, that character can only have
193
- come from the insertion itself — a trailing space the user had already typed still sits
194
- before the chip, untouched. The span is only used when the projection shows a space right
195
- after this block's chip, and a failure to apply just logs (the draft keeps DSH's behaviour).
196
- 2. **Delete removes the chip node only.** `remove()` drops the chip's Lexical node
197
- (§10) and never rewrites text, so spaces around a deleted block survive exactly as they were.
198
-
199
- `src/projection.js` owns the coordinate conversion this needs: the published `draft` is the
200
- clipboard projection (a chip occupies `occurrence.length` characters, i.e. one zero-width space
201
- for us) while the scoped edit verbs address detect coordinates (a chip collapses to exactly one
202
- placeholder character). `detectOffsetOf()` converts with
203
- `detect = clipboard - Σ(len_i - 1)` over preceding occurrences, so blocks stay correctly
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).
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.
1
+ # Design notes — dsh-paste-code-block
2
+
3
+ This document captures the non-obvious design decisions behind the plugin, so future
4
+ contributors understand *why* the code is the way it is. It is not a usage guide — see
5
+ [README.md](../README.md) for that.
6
+
7
+ ## 1. One plugin, two halves
8
+
9
+ A DSH plugin can ship a **host** (node) half and a **client** (browser) half. This plugin is a
10
+ *pure UI* plugin: all behavior lives on the browser side.
11
+
12
+ - `src/index.js` — host half. **Intentionally empty.** It exists only so the package resolves
13
+ as a DSH plugin and so the `dsh.client` declaration in `package.json` is discovered.
14
+ - `src/client.js` — browser half. A self-contained UMD bundle loaded via
15
+ `window.__ModuleLoader__.load({ id, factory })`. It captures pastes, restyles DSH's inline
16
+ reference chips into boxed badges, and renders an editing detail card above the composer.
17
+
18
+ Because the client is loaded by DSH's module loader — which resolves *package names*, not
19
+ relative ESM files — `client.js` **cannot** `import` a sibling module. Its runtime logic is
20
+ therefore **embedded** in the bundle. To keep that logic unit-testable, the same functions are
21
+ extracted into plain, dependency-free ESM modules which the test suite exercises:
22
+
23
+ - `src/parse.js` — block detection / language detection / serialization.
24
+ - `src/i18n.js` — locale dictionaries, label building, and label parsing.
25
+
26
+ **Keep `src/parse.js` / `src/i18n.js` and their embedded copies in `src/client.js` in sync.**
27
+
28
+ ## 2. How a paste becomes a card (the pipeline)
29
+
30
+ ```
31
+ paste event
32
+ └─ parseBlock(text) → Block | null (pure logic, in src/parse.js)
33
+ └─ is short plain prose? → null → paste as-is (don't intercept)
34
+ └─ else → { lang, content, lines, isCode }
35
+ └─ controller.attach(sid, block)
36
+ ├─ nextLabel(sid, type) → smallest free number (code/text counted apart)
37
+ ├─ insertReference(...) → DSH 'slash/input-insert-reference' with clipboardText '\u200B'
38
+ └─ publish(sid) → re-render the dock
39
+ ```
40
+
41
+ ## 3. The hidden-reference codec
42
+
43
+ The card is rendered by DSH's existing **hidden-reference (chip)** machinery — the same
44
+ mechanism that renders file/upload references inline. The plugin registers a source under the
45
+ `@` input trigger:
46
+
47
+ - `trigger: '@'`, `name: 'paste-code-block'`
48
+ - `codec.clipboardText` → `''` (the chip leaves no visible text on copy)
49
+ - `codec.serialize(ref)` → the block's fenced ```` ```lang … ``` ```` text
50
+
51
+ The chip label is the localized human-readable badge (`代码块 N` / `Code #N` — see §9);
52
+ clicking it opens the detail card, and each chip carries a `×` button that deletes the block
53
+ (see §10).
54
+
55
+ ## 4. Why `clipboardText` is U+200B (zero-width space)
56
+
57
+ `insertReference` passes `clipboardText: '\u200B'`. This is deliberate:
58
+
59
+ - Each chip contributes exactly **one character** to the editor's detect text.
60
+ - `String.prototype.trim()` does **not** strip U+200B, so the composer's empty check
61
+ (`draft.trim() === ""`) sees a draft with only chips as *non-empty*, keeping the send button
62
+ enabled even when the user pasted a block with no surrounding text.
63
+ - Because `draft.length` then equals the detect length, anchoring insertions at `draft.length`
64
+ keeps chips strictly ordered — fixing the earlier "every 3rd block shifts forward by 1" bug.
65
+
66
+ ## 5. Numbering and reuse
67
+
68
+ Numbers are tracked **per session, split by type** (`code` vs `text`):
69
+
70
+ - `nextLabel` always allocates the **smallest free integer** (`while (used.has(n)) n++`).
71
+ - Deleting a chip frees its number; the next paste reuses it.
72
+
73
+ So `代码块 1` … `代码块 N` and `文本块 1` … `文本块 M` each count independently and
74
+ stay dense after removals.
75
+
76
+ ## 6. Markdown vs. code detection
77
+
78
+ The trickiest heuristic is deciding whether pasted text is *code* or *markdown prose*. The
79
+ signal counter `countMarkdownSignals` scores structural markdown (headings, blockquotes, lists,
80
+ links, bold, tables, hr). A lone `#` comment in code scores 1 and stays code; a real markdown
81
+ document scores ≥2 and is treated as text. Fenced snippets always win as code.
82
+
83
+ ## 7. Anchor stability and reconcile
84
+
85
+ Blocks are **appended at the end of the draft**, reusing DSH's hidden-reference semantics so
86
+ cursor/undo stay stable. During a submit the chips may transiently vanish; `reconcile` defers
87
+ pruning while `serializing` is setatch, and `restoreFailed` re-surfaces in-flight blocks if the
88
+ send fails — so a failed send auto-refills the input.
89
+
90
+ ## 8. Testing strategy
91
+
92
+ `tests/parse.test.js` and `tests/i18n.test.js` exercise the two pure modules (`src/parse.js`,
93
+ `src/i18n.js`) with the built-in `node:test` runner (no test framework dependency). The
94
+ DOM/controller glue is not unit-tested because it depends on the live DSH client runtime; it is
95
+ verified manually in the browser.
96
+
97
+ ```sh
98
+ npm test
99
+ ```
100
+
101
+ ## 9. Localization (i18n)
102
+
103
+ Block names are produced through **DSH's own locale service**, the same one first-party plugins
104
+ (schedule, session-log-export, …) use:
105
+
106
+ - `apply()` registers the `paste-code-block` namespace via `ctx.locale.register(NS, {zh, en})`
107
+ with *identical key sets* in both dictionaries (the built-in locale runtime assumes that).
108
+ - All user-visible strings — chip labels (`block.code` / `block.text` with `{n}`), button
109
+ tooltips, the `{count}` line counter (with a `lines.one` / `lines.other` plural split),
110
+ aria labels, slot label, and the stale-block send error — go through the bound translator
111
+ `t(key, params)`.
112
+ - The dock slot entry declares `locale: NS`, so the renderer hands it a fresh `t` seat **and
113
+ re-renders it on every locale revision**.
114
+
115
+ **Naming and visual identity (0.1.3).** A chip is a *reference badge*, not an action button, so
116
+ the pre-0.1.3 names carried a verb nobody invokes on it — `复制代码块N` / `Code block #N` read
117
+ like a command and burned width inside the composer. Current names are noun + number
118
+ (`代码块 1` / `Code #1`). The code/text distinction moved from wording to visuals: `scanChips`
119
+ tags every chip host with `data-pcb-type`, and CSS renders a leading glyph — `</>` for code
120
+ (mono, business-primary) and `Aa` for text (neutral gray) — plus a soft blue tint on code
121
+ chips. Pre-0.1.3 labels stay parseable through the `legacyLabels` table, so chips inserted by
122
+ an older bundle keep their click/✕/retitle identity until the next locale-synced retitle.
123
+
124
+ **Chip retitling across switches.** The chip label is *insert-time* content: Lexical caches the
125
+ label inside the decorator node, and React never re-renders those node bodies while the node
126
+ state is unchanged. So on a locale switch the plugin: (1) recomputes `block.label` for every
127
+ tracked block and rebuilds `labelIndex` (`retitleAll`), then (2) patches the visible DOM —
128
+ `title` attribute and label span `.textContent` — inside `scanChips`. React's virtual DOM
129
+ record still says the old label, so it never fights the patch; a full chip remount (the only
130
+ revert vector) is caught by the same MutationObserver that manages the ✕ button.
131
+
132
+ **Cross-language identity.** `parseBlockLabel` recognizes a rendered label in **any** shipped
133
+ locale and recovers `{ type, n }`; lookups (chip click, ✕ delete, retitle matching) resolve the
134
+ block by that identity within the chip's own session. A chip inserted under one language keeps
135
+ working after switching to the other.
136
+
137
+ ## 10. The per-chip ✕ delete button
138
+
139
+ DSH renders chips from Lexical decorator nodes; third parties cannot add React children to
140
+ them. Instead the plugin **appends a `<span class="dsh-pcb-chip-x">×</span>` into each chip's
141
+ inner element** and keeps them present with a `MutationObserver` on `document.body`
142
+ (rAF-coalesced; the scan exits immediately when no block is tracked). A capture-phase
143
+ `pointerdown` handler checks `.dsh-pcb-chip-x` **before** the toggle logic and calls
144
+ `controller.remove`, which removes **just that chip's Lexical node** and frees its number. If
145
+ the chip is not backed by a tracked block (e.g. a restored draft after redraw), no ✕ is added —
146
+ no dead affordances. Touch targets grow on coarse pointers (`@media (pointer:coarse)`).
147
+
148
+ **Why node-level removal matters — the 0.1.1 incident.** The first `remove()` implementation
149
+ spliced the chip's placeholder char out of the draft string and wrote the result through
150
+ `shell.setDraft()`. But `setDraft` **clears the editor root and rebuilds plain-text
151
+ paragraphs** — chips are decorator nodes that cannot round-trip through text, so deleting one
152
+ block destroyed *every* chip in the draft. `removeChipNodes()` instead scans the editor's node
153
+ map for `ReferenceChipNode`s whose public `getSource()`/`getReference()` match this plugin and
154
+ the block's ref, and calls the node's own `remove()` inside one discrete update — a surgical,
155
+ undoable (`Ctrl+Z`) deletion that leaves sibling chips untouched.
156
+
157
+ **The 0.1.2 silent no-op.** The node map (`editor.getEditorState()._nodeMap`) is keyed by
158
+ NodeKey and **its values are the nodes themselves**; the older `NodeState` wrapper with a
159
+ `.node` field no longer exists. Reading `value.node` therefore matched *nothing* for every
160
+ entry, `removeChipNodes()` removed zero chips, yet the call looked successful — the block's
161
+ state was pruned behind a chip that was still in the draft, so clicking ✕ appeared to do
162
+ nothing at all. `src/chip-nodes.js` now normalizes both shapes (`chipNodeOf`) and is unit
163
+ tested for exactly this rule; `tests/lexical-chip-probe.mjs` reproduces the original defect
164
+ against real `lexical@0.49.0`:
165
+
166
+ ```
167
+ _nodeMap value shape: valueIsNode=true, hasNodeProp=false
168
+ OLD code (value.node): removed = 0 | chips after = r1,r2,r3 <- silent no-op
169
+ NEW code (value is node): removed = 1 | chips after = r1,r3 <- only the target
170
+ ```
171
+
172
+ **Failure is never silent and never destructive.** `remove()` gates every state change on
173
+ `chipPresent()` — the editor's node map first, the published occurrence projection as a
174
+ cross-check — so a block is pruned only once its chip is verifiably gone. If the chip survives
175
+ the attempt the block stays tracked and the composer surfaces `error.remove`; the old
176
+ `setDraft` fallback is gone for good. State that diverges from the editor still self-heals via
177
+ `reconcile`.
178
+
179
+ ## 11. Insertion leaves no stray space
180
+
181
+ `SessionInputShell.insertReference` inserts `[chip, ' ']` — a separating space — whenever the
182
+ character at the pick-time span is not itself a space. Because a block is always appended at
183
+ the **end** of the draft, that condition is always true, so every paste used to leave a space
184
+ behind: text typed next started with a space (the visible "prefix space"), and pasting several
185
+ blocks piled stray spaces into the message body.
186
+
187
+ Two rules keep the draft clean without ever touching user whitespace:
188
+
189
+ 1. **Insert, then drop exactly the appended character.** `dropSeparatorSpace()` asks
190
+ `separatorSpaceSpan()` (src/projection.js) for the detect span of the character directly
191
+ after *our* chip and deletes it with the same public `slash/input-insert-text` verb DSH uses
192
+ for its own span edits. Since the chip is inserted at the end, that character can only have
193
+ come from the insertion itself — a trailing space the user had already typed still sits
194
+ before the chip, untouched. The span is only used when the projection shows a space right
195
+ after this block's chip, and a failure to apply just logs (the draft keeps DSH's behaviour).
196
+ 2. **Delete removes the chip node only.** `remove()` drops the chip's Lexical node
197
+ (§10) and never rewrites text, so spaces around a deleted block survive exactly as they were.
198
+
199
+ `src/projection.js` owns the coordinate conversion this needs: the published `draft` is the
200
+ clipboard projection (a chip occupies `occurrence.length` characters, i.e. one zero-width space
201
+ for us) while the scoped edit verbs address detect coordinates (a chip collapses to exactly one
202
+ placeholder character). `detectOffsetOf()` converts with
203
+ `detect = clipboard - Σ(len_i - 1)` over preceding occurrences, so blocks stay correctly
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).
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 only ever *hides* the runs it consumes (`display:none` —
224
+ the node stays a child, React keeps patching it harmlessly) and inserts a
225
+ `div.dsh-pcb-fold-host` **after** them: after the folded run on the per-run path, appended at
226
+ the end of the bubble on the bubble-wide path. The host is ours: prose segments re-render as
227
+ `span.dsh-pcb-fold-prose`, each fence as a collapsed card (glyph `</>`/`Aa` + localized title
228
+ `python · 128 行` / `Text · 128 lines`, copy button copying the **raw fenced text** exactly as
229
+ sent, click-to-expand `<pre>`). Since hiding a child does not affect `textContent`, the
230
+ message action bar (React-side copy/edit/delete) is entirely unaffected.
231
+
232
+ **What folds.** `splitFencedSegments()` (canonical in `src/parse.js`, embedded copy in
233
+ `client.js`) walks the run line by line with GFM fence rules (``` or ~~~, opener indented ≤ 3
234
+ spaces, closer ≥ opener length alone on its line; backticks in a ``` info string kill that
235
+ opener; an unterminated opener stays prose). Any fence segment with a non-empty body folds;
236
+ everything else — prose, whitespace between blocks, empty fences — renders verbatim around
237
+ the cards. Fence-only (not "any long text") is deliberate: composer-created blocks always
238
+ travel as fences, while unfenced long messages may have been *typed*, and silently folding
239
+ typed prose would be a nasty surprise.
240
+
241
+ **Idempotence and refresh.** The bubble carries
242
+ `data-dsh-pcb-sig = "<mode>|<stamp>"` (`single` = per-run fold, `bubble` = whole-message fold),
243
+ each hidden run carries `data-dsh-pcb-hide = "<stamp>"`, and each host carries
244
+ `data-dsh-pcb-stamp = "<stamp>"`, where the stamp folds `foldRev`, text length, and an FNV-1a
245
+ hash. A bubble whose signature matches and whose DOM still shows that many hidden runs and
246
+ hosts is skipped, so steady state costs attribute reads. A locale switch bumps `foldRev`, which
247
+ invalidates every stamp — the next scan rebuilds each host and re-titles cards from the live
248
+ translator. Rebuilds preserve who was expanded via a stable `flowKey#scope:segIndex` open set,
249
+ and a payload change (or a React remount of a run underneath us) clears the stale fold first —
250
+ hosts are removed and every stamped run unhidden — before the new plan is built. Disposal
251
+ (HMR / plugin removal) removes all hosts and unhides every run — the transcript hands back
252
+ clean. A bubble with no plan and no fold is never touched at all (no marker, no attribute).
253
+
254
+ `tests/fold-smoke.test.js` exercises the real bundle against a dependency-free DOM shim:
255
+ fold → marker/card/prose structure, steering rows, non-fenced messages untouched, a fence that
256
+ straddles the runs DSH split at its @mention chips folding at bubble scope (0.2.6), retitle
257
+ across locale switches with open-state kept, text edits re-fold and unfold, orphan sweep,
258
+ and dispose restoration.
259
+
260
+ **Bare fences are code (0.2.1).** Card type follows the composer round-trip convention: a
261
+ plain-text block always serializes as an explicit ` ```text ` fence while a code block whose
262
+ language was not detected serializes as a **bare** ` ``` ` fence — so only an explicit `text`
263
+ info string folds as a 文本块, and a bare fence folds as 代码块, re-guessing a language from
264
+ the body for the title (`python · 2 行`, else `代码块 · N 行`). Manually typed bare fences
265
+ follow the same rule; that is also the CommonMark reading. (0.2.0 shipped the inverse and a
266
+ sent 代码块 chip could reappear in the transcript as 文本块.)
267
+
268
+ ## 13. Draft rebuild recovery (0.2.1)
269
+
270
+ **Typing-window guard and retired hygiene (0.2.2).** The recovery rewrite is a whole-
271
+ document `setDraft`: the caret drops at the end and an in-flight IME composition is
272
+ destroyed. Run in the middle of typing, that ate the user's first character and bounced
273
+ the view on every pass (reported). Two keystroke-time paths existed: sweeping a stray
274
+ U+200B that belonged to no restorable block (external invisible copy from WeChat / web
275
+ pages — the signature is NOT exclusive to rebuilds after all), and restoring blocks while
276
+ the draft had already moved past the re-seeded text. The hygiene path is retired outright:
277
+ external residue is left exactly where the user put it. Both paths now share one gate —
278
+ `recover()` only acts when the draft is IDENTICAL to the last draft observed for that
279
+ session in this page view (or was never observed: fresh boot, or the composer only ever
280
+ seen hydrating empty). A draft that MOVED between passes is live typing, never a rebuild.
281
+ `attach()` records the shell's post-insertion draft as that fingerprint immediately, and
282
+ the reconcile prune drops the mirror synchronously, so a hand-deleted block cannot be
283
+ resurrected from stale mirror data through the empty-prior window.
284
+
285
+ **Paste-storm guard (0.2.3).** Even with the typing-window guard deployed (load log
286
+ `client loaded v0.2.2` verified live), the console showed the SAME clipboard text (571
287
+ chars) intercepted dozens of times in seconds — an external automation (a Tampermonkey
288
+ AutoClicker was running on the page) re-dispatching paste. Each accepted paste inserts a
289
+ chip and re-renders the dock, so a storm reproduces the exact bounce-and-eat-the-first-
290
+ character experience through a different door. Identical clipboard text arriving
291
+ faster than a human can act collapses into one paste (`pasteStorm.seen`, 1200 ms
292
+ sliding window refreshed on every matching event, so a held repeat stays
293
+ suppressed while it continues); suppressed events are still `preventDefault`ed so
294
+ raw text never falls through to the composer. The plugin cannot suppress the
295
+ automation itself — users hitting this should disable AutoClicker-style userscripts
296
+ on the DSH page.
297
+
298
+ **Burst-only collapse (0.2.5).** 0.2.4 tried a content-liveness rule: while a block
299
+ with the same text was still attached, re-pasting identical text was consumed as
300
+ "not a new intent." That over-suppressed — pasting the same snippet twice (a
301
+ completely normal action) yielded a single block and confused users, and in the
302
+ end the reported bounces were traced to a *different* plugin entirely, not to
303
+ paste storms. So 0.2.5 reverts to a pure burst window: identical text pasted after
304
+ the 1200 ms window is a fresh intent and gets its own block (`sourceText` is
305
+ recorded per block and carried through the recovery mirror); only machine-paced
306
+ repeats *within* the window collapse. The `intercept` load log still carries
307
+ `trusted=` — `false` names a page-script dispatch (userscript glue, or the
308
+ `dsh-vision-router` image-paste replay, which carries the original text and lands
309
+ seconds after the trusted paste) rather than a real Ctrl+V, so any future storm
310
+ report identifies its own source.
311
+
312
+ Two DSH paths strand pasted blocks mid-draft, and both share one signature. (a) A page
313
+ reload re-seeds the composer from DSH's persisted per-session draft
314
+ (`dsh.conversation.<sid>`), which is **plain text** — Lexical chips cannot round-trip
315
+ through it (§7). (b) A workspace switch on the new-session page *carries the draft text
316
+ over* to the target workspace's blank session (`inputHub`: `next.setDraft(from.snapshot
317
+ .draft)`, then `from.setDraft("")`) — prose follows the composer, chips and controller
318
+ state do not, so blocks were silently orphaned under the old session id (user-reported).
319
+ Typed prose comes back either way; the blocks did not.
320
+
321
+ The signature — as 0.2.1 read it: every chip contributes exactly one U+200B to the clipboard
322
+ projection and nothing scrubs it from persisted/carried text (DSH's placeholder-stripping
323
+ regex covers private-use chars and U+FFFC, not ZWSPs). Stray U+200Bs with **no** live
324
+ occurrence of ours can therefore only mean the draft was rebuilt from text; conversely
325
+ deleting a chip — ✕, Backspace, or prune — removes its ZWSP together with the node, so
326
+ deliberate deletion never looks like a rebuild and cannot be "resurrected".
327
+
328
+ `BlockController.recover()` runs in the dock's reconcile effect, ahead of the reconcile,
329
+ and resolves blocks in this order: own-session memory → the session's own localStorage
330
+ mirror → **carry-over donor**: the unmounted session whose last published draft equals the
331
+ re-seeded text character for character (the mirror stores that same fingerprint, so the
332
+ donor is even found after a reload wiped memory) → nothing. An ambiguous fingerprint aborts
333
+ the donor step — no guessing. On a hit the mirror moves to the new sid. (0.2.1 also stripped
334
+ strays when nothing could be restored; the typing-window guard above retired that — see the
335
+ 0.2.2 note.) The mirror holds just the block payloads plus that draft fingerprint, is written
336
+ synchronously on attach (and debounced on detail-card edits), drops on a send attempt, and
337
+ expires after two weeks. The recovery pass then *skips* its reconcile — the snapshot it
338
+ would reconcile against is stale; the draft mutation bumps `draftRev` and the next pass
339
+ reconciles real occurrences. Restored chips get re-derived labels and numbers, so a
340
+ restored draft is indistinguishable from one that never left. Mirror writes are best
341
+ effort: private mode / quota only lose reload durability, never the feature itself.
342
+
343
+ ## 14. Re-pasting your own message (0.2.6)
344
+
345
+ The 0.2.5 report: the user copied their *previous* message out of the transcript and pasted it
346
+ back to continue the conversation. The plugin got the round trip wrong twice over, and the
347
+ captured payload is kept as `tests/fixtures.js#B2` / `W` / `B` so the shapes cannot drift.
348
+
349
+ **A fence plus a tail is not one code block.** `parseBlock()` used to call anything that
350
+ *starts* with a fence a code block, so a message that was one ` ```text ` fence **plus** words
351
+ after it was typed as code; re-pasting then re-serialized the tail *inside* the fence. The fix
352
+ records the whole-fence span during detection (`wholeFence()` — the paste is a block only when
353
+ the fence is the entire paste, optionally surrounded by whitespace) and, when a paste starts
354
+ with a fence but carries a tail, takes the block type from the opening fence's own info string:
355
+ ` ```text ` is a 文本块, a bare/other fence is 代码块, and the tail stays prose outside it.
356
+
357
+ **Chosen fence markers must outgrow their body.** A block whose *content* already contains a
358
+ fence (re-pasting folded output does exactly this) was re-serialized inside a bare ` ``` `, so
359
+ the inner fence closed the outer one and the rest of the block spilled out as prose.
360
+ `serializeBlock()` now picks its outer marker with `fenceMarkerFor()`: the longest consecutive
361
+ backtick run in the content plus one (minimum three), so the wrapper always outgrows anything
362
+ inside it. Round-tripping fixed output is therefore stable.
363
+
364
+ **Fold scope is the bubble, not the run.** DSH splits a sent user message into several
365
+ `plainRun` spans at every `@mention` chip, and a fence can begin in one run and end in another.
366
+ Per-run scanning paired such an outer fence with an *inner* opener and folded a bogus one-line
367
+ card while the rest of the message stayed as raw text. `planFoldRuns(text, runTexts)` (embedded
368
+ copy of the same name in `src/parse.js`; `fenceRanges()` is the shared span finder) plans the
369
+ fences over the runs' text joined back together and reports either `single` (every fence sits
370
+ inside one run — keep the per-run path, which leaves mention chips clickable) or `bubble` (a
371
+ fence straddles runs — hide every run and render the whole message from the bubble-wide plan).
372
+ An isolated/debug black corner badge printed on every scan is gone unless
373
+ `window.__PCB_DEBUG__ === true`.