dsh-paste-code-block 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-paste-code-block contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ <div align="center">
2
+
3
+ # dsh-paste-code-block
4
+
5
+ **Cherry Studio‑style text/code block pasting for DeepSeek Harness Web**
6
+
7
+ Paste text/code into the DSH Web composer → it collapses into a **bordered, collapsible, language-tagged card** instead of a long flat string; on send it restores the original content as a fenced code block.
8
+
9
+ `type: module` · `runtime: host` · `client: web` · MIT
10
+
11
+ **[中文](./README.zh-CN.md) | English**
12
+
13
+ </div>
14
+
15
+ ---
16
+
17
+ ## Features
18
+
19
+ - 📋 **Smart detection** — pasted content with ``` fences, newlines, or long indented code becomes a card; ordinary short prose pastes as-is.
20
+ - 🏷️ **Language tags** — auto-detects Python / JS / JSON / YAML / SQL / bash / HTML·XML / C++ / Go / Ruby (incl. shebang). Plain text blocks are tagged `text`.
21
+ - 📂 **Collapsible card** — collapsed by default showing a one-line preview; click to expand full code (scrollable, up to 260px).
22
+ - ✏️ **In‑card editing** — edit the block content right in the detail card; the send path uses your edited text.
23
+ - 📤 **Restore on send** — each card is restored to a ```` ```lang … ``` ```` fenced block; a failed send auto-fills it back into the input.
24
+ - 🔢 **Smart numbering** — code and text are counted apart; deleting a block frees its number for reuse (smallest-free allocation).
25
+ - 📱 **All surfaces** — pure client plugin; works on PC & mobile Web, adapts to light/dark themes.
26
+
27
+ ## Install
28
+
29
+ Requires DSH `0.1.x` (current developer preview — APIs may change).
30
+
31
+ ```sh
32
+ # Local install from this directory
33
+ dsh plugin --profile web add file:/path/to/dsh-paste-code-block
34
+
35
+ # Or after publishing
36
+ # dsh plugin --profile web add dsh-paste-code-block
37
+ ```
38
+
39
+ **Restart `dsh web`** after installing.
40
+
41
+ ## Usage
42
+
43
+ 1. Copy a block of code or a multi-line block of text anywhere.
44
+ 2. Paste it into the DSH input → it becomes a code-block card.
45
+ 3. Expand / copy / edit / remove as you like; press Enter or send and it's restored as a fenced code block.
46
+
47
+ ## Notes & boundaries
48
+
49
+ - Blocks are **appended at the end of the draft** (reusing DSH's hidden-reference semantics), keeping cursor/undo stable; ordinary short text is not intercepted.
50
+ - Plain-text blocks are wrapped in ```` ```text ``` ```` on send so they don't smear into one line.
51
+ - Detection threshold: **contains newline / ≥2 lines / length ≥96 / indented or fenced** — any one triggers the card.
52
+
53
+ ## Project layout
54
+
55
+ ```
56
+ dsh-paste-code-block/
57
+ ├── src/
58
+ │ ├── client.js # Web (browser) half — paste capture, chip styling, detail card
59
+ │ ├── index.js # Host (node) half — intentionally empty (pure UI plugin)
60
+ │ └── parse.js # Pure block-parsing logic (canonical, unit-tested)
61
+ ├── tests/
62
+ │ └── parse.test.js # node:test unit tests for src/parse.js
63
+ ├── docs/
64
+ │ └── design.md # Design rationale (numbering, anchor stability, codec)
65
+ ├── cordis.patch.yml # DSH bundle patch declaring the host plugin
66
+ ├── package.json # Package + DSH plugin manifest
67
+ ├── README.md # English
68
+ └── README.zh-CN.md # 简体中文
69
+ ```
70
+
71
+ ## Development
72
+
73
+ ```sh
74
+ npm run check # syntax-check both JS halves
75
+ npm test # run the unit tests (node:test)
76
+ ```
77
+
78
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,82 @@
1
+ <div align="center">
2
+
3
+ # dsh-paste-code-block
4
+
5
+ **Cherry Studio 式的文本块/代码块粘贴效果(DeepSeek Harness Web)**
6
+
7
+ 把复制的**文本块 / 代码块**粘到 DSH Web 输入框时,不再是一长串普通文字,而是收成一张**带边框、可折叠、带语言标签的代码块卡片**;发送时自动还原成真正的 ```` ```lang … ``` ```` 围栏代码块发送。
8
+
9
+ 纯客户端插件(浏览器侧),PC 与手机 Web 通用,实时适配深浅主题。
10
+
11
+ `type: module` · `runtime: host` · `client: web` · MIT
12
+
13
+ **[English](./README.md) | 中文**
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## 功能
20
+
21
+ - 📋 **智能识别**:粘贴内容已带 ``` 围栏、含换行、或是较长的缩进代码 → 收成卡片;普通短文本照常粘贴。
22
+ - 🏷️ **语言标签**:自动识别 Python / JS / JSON / YAML / SQL / bash / HTML·XML / C++ / Go / Ruby 等常见语言(可识别 shebang)。纯文本块标为 `text`。
23
+ - 📂 **可折叠卡片**:默认收起显示首行预览,点击展开完整代码(最多 260px 高可滚动)。
24
+ - ✏️ **卡片内编辑**:在详情卡片里直接编辑代码块内容,发送时使用编辑后的文本。
25
+ - 📤 **发送还原**:发出时把每个卡片还原成 ```` ```lang ``` ```` 围栏代码块;发送失败自动回填回输入框。
26
+ - 🔢 **智能编号**:代码与文本分开计数,从最小可用号分配;删除某块即释放其编号供复用。
27
+ - 📱 **全端适配**:纯客户端插件,PC 与手机 Web 通用,实时适配深浅主题。
28
+
29
+ ## 安装
30
+
31
+ 需要 DSH `0.1.x`(当前为开发者预览版,接口可能变化)。
32
+
33
+ ```sh
34
+ # 从本目录本地安装
35
+ dsh plugin --profile web add file:/path/to/dsh-paste-code-block
36
+
37
+ # 或打包发布后
38
+ # dsh plugin --profile web add dsh-paste-code-block
39
+ ```
40
+
41
+ 安装后**重启 `dsh web`** 生效。
42
+
43
+ ## 使用
44
+
45
+ 1. 在任意地方复制一段代码 / 一段带换行的文本。
46
+ 2. 在 DSH 输入框粘贴 → 变成一张代码块卡片。
47
+ 3. 可展开 / 复制 / 编辑 / 移除;回车或发送即还原为围栏代码块发出。
48
+
49
+ ## 说明与边界
50
+
51
+ - 默认**追加在草稿末尾**(复用 DSH 隐藏引用按文件/上传的既有语义),保证光标/撤销稳定;短普通文本不拦截。
52
+ - 纯文本块发送时用 ```` ```text ``` ```` 围栏包裹,避免在对话里糊成一行。
53
+ - 识别阈值:**含换行 / 行数 ≥2 / 长度 ≥96 / 有缩进或围栏**其一即视为块。
54
+
55
+ ## 目录结构
56
+
57
+ ```
58
+ dsh-paste-code-block/
59
+ ├── src/
60
+ │ ├── client.js # Web(浏览器侧)半端 —— 粘贴拦截、徽章样式、详情卡片
61
+ │ ├── index.js # Host(Node 侧)半端 —— 有意为空(纯 UI 插件)
62
+ │ └── parse.js # 纯文本块解析逻辑(规范版,含单元测试)
63
+ ├── tests/
64
+ │ └── parse.test.js # node:test 单元测试(针对 src/parse.js)
65
+ ├── docs/
66
+ │ └── design.md # 设计说明(编号复用、锚点稳定、codec)
67
+ ├── cordis.patch.yml # DSH bundle patch,声明宿主插件
68
+ ├── package.json # 包与 DSH 插件清单
69
+ ├── README.md # English
70
+ └── README.zh-CN.md # 简体中文
71
+ ```
72
+
73
+ ## 开发
74
+
75
+ ```sh
76
+ npm run check # 语法检查两个 JS 半端
77
+ npm test # 运行单元测试(node:test)
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT — 见 [LICENSE](./LICENSE)。
@@ -0,0 +1,4 @@
1
+ # Install the Host half and expose the same package's dsh.client Web half.
2
+ - insert:
3
+ - id: dsh-paste-code-block
4
+ name: dsh-paste-code-block
package/docs/design.md ADDED
@@ -0,0 +1,93 @@
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 parsing logic is
20
+ therefore **embedded** in the bundle. To keep that logic unit-testable, the same functions are
21
+ extracted into `src/parse.js` (a plain, dependency-free ESM module) which the test suite
22
+ exercises. **Keep `src/parse.js` and the embedded copy in `src/client.js` in sync.**
23
+
24
+ ## 2. How a paste becomes a card (the pipeline)
25
+
26
+ ```
27
+ paste event
28
+ └─ parseBlock(text) → Block | null (pure logic, in src/parse.js)
29
+ └─ is short plain prose? → null → paste as-is (don't intercept)
30
+ └─ else → { lang, content, lines, isCode }
31
+ └─ controller.attach(sid, block)
32
+ ├─ nextLabel(sid, type) → smallest free number (code/text counted apart)
33
+ ├─ insertReference(...) → DSH 'slash/input-insert-reference' with clipboardText '\u200B'
34
+ └─ publish(sid) → re-render the dock
35
+ ```
36
+
37
+ ## 3. The hidden-reference codec
38
+
39
+ The card is rendered by DSH's existing **hidden-reference (chip)** machinery — the same
40
+ mechanism that renders file/upload references inline. The plugin registers a source under the
41
+ `@` input trigger:
42
+
43
+ - `trigger: '@'`, `name: 'paste-code-block'`
44
+ - `codec.clipboardText` → `''` (the chip leaves no visible text on copy)
45
+ - `codec.serialize(ref)` → the block's fenced ```` ```lang … ``` ```` text
46
+
47
+ The chip label is the human-readable badge (`复制代码块N` / `复制文本块N`); clicking it opens the
48
+ detail card.
49
+
50
+ ## 4. Why `clipboardText` is U+200B (zero-width space)
51
+
52
+ `insertReference` passes `clipboardText: '\u200B'`. This is deliberate:
53
+
54
+ - Each chip contributes exactly **one character** to the editor's detect text.
55
+ - `String.prototype.trim()` does **not** strip U+200B, so the composer's empty check
56
+ (`draft.trim() === ""`) sees a draft with only chips as *non-empty*, keeping the send button
57
+ enabled even when the user pasted a block with no surrounding text.
58
+ - Because `draft.length` then equals the detect length, anchoring insertions at `draft.length`
59
+ keeps chips strictly ordered — fixing the earlier "every 3rd block shifts forward by 1" bug.
60
+
61
+ ## 5. Numbering and reuse
62
+
63
+ Numbers are tracked **per session, split by type** (`code` vs `text`):
64
+
65
+ - `nextLabel` always allocates the **smallest free integer** (`while (used.has(n)) n++`).
66
+ - Deleting a chip frees its number; the next paste reuses it.
67
+
68
+ So `复制代码块1` … `复制代码块N` and `复制文本块1` … `复制文本块M` each count independently and
69
+ stay dense after removals.
70
+
71
+ ## 6. Markdown vs. code detection
72
+
73
+ The trickiest heuristic is deciding whether pasted text is *code* or *markdown prose*. The
74
+ signal counter `countMarkdownSignals` scores structural markdown (headings, blockquotes, lists,
75
+ links, bold, tables, hr). A lone `#` comment in code scores 1 and stays code; a real markdown
76
+ document scores ≥2 and is treated as text. Fenced snippets always win as code.
77
+
78
+ ## 7. Anchor stability and reconcile
79
+
80
+ Blocks are **appended at the end of the draft**, reusing DSH's hidden-reference semantics so
81
+ cursor/undo stay stable. During a submit the chips may transiently vanish; `reconcile` defers
82
+ pruning while `serializing` is setatch, and `restoreFailed` re-surfaces in-flight blocks if the
83
+ send fails — so a failed send auto-refills the input.
84
+
85
+ ## 8. Testing strategy
86
+
87
+ `tests/parse.test.js` exercises the pure parsing module (`src/parse.js`) with the built-in
88
+ `node:test` runner (no test framework dependency). The DOM/controller glue is not unit-tested
89
+ because it depends on the live DSH client runtime; it is verified manually in the browser.
90
+
91
+ ```sh
92
+ npm test
93
+ ```
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "dsh-paste-code-block",
3
+ "version": "0.1.0",
4
+ "description": "Cherry Studio-style paste: pasted text/code blocks into the DSH Web composer render as a bordered, collapsible, language-tagged card instead of a long flat string; the original text is restored as a fenced block on send.",
5
+ "license": "MIT",
6
+ "author": "DSH paste-code-block contributors",
7
+ "keywords": [
8
+ "dsh",
9
+ "deepseek-harness",
10
+ "plugin",
11
+ "paste",
12
+ "code-block",
13
+ "cherry-studio",
14
+ "ui"
15
+ ],
16
+ "type": "module",
17
+ "main": "./src/index.js",
18
+ "exports": {
19
+ ".": "./src/index.js",
20
+ "./client": "./src/client.js",
21
+ "./parse": "./src/parse.js",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "src/",
26
+ "docs/",
27
+ "cordis.patch.yml",
28
+ "README.md",
29
+ "README.zh-CN.md",
30
+ "LICENSE"
31
+ ],
32
+ "dsh": {
33
+ "runtime": "host",
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web",
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-conversation",
41
+ "@deepseek-ai/dsh-client-ui-input-trigger",
42
+ "@deepseek-ai/dsh-client-ui-slots"
43
+ ]
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
48
+ "@deepseek-ai/dsh-client-ui-conversation": "*",
49
+ "@deepseek-ai/dsh-client-ui-input-trigger": "*",
50
+ "@deepseek-ai/dsh-client-ui-slots": "*",
51
+ "react": "^18.2.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@deepseek-ai/dsh-client-ui-conversation": { "optional": true },
55
+ "@deepseek-ai/dsh-client-ui-input-trigger": { "optional": true },
56
+ "@deepseek-ai/dsh-client-ui-slots": { "optional": true },
57
+ "react": { "optional": true }
58
+ },
59
+ "engines": {
60
+ "node": ">=20"
61
+ },
62
+ "scripts": {
63
+ "check": "node --check src/index.js && node --check src/client.js",
64
+ "test": "node --test 'tests/**/*.test.js'"
65
+ }
66
+ }
package/src/client.js ADDED
@@ -0,0 +1,681 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-paste-code-block',
3
+ factory: (require) => {
4
+ const module = { exports: {} }
5
+ const exports = module.exports
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
7
+
8
+ const React = require('react')
9
+
10
+ // ===== Constants =========================================================
11
+ const SOURCE = 'paste-code-block'
12
+ // Inline chip: DSH renders each reference as a Lexical chip whose host span
13
+ // carries data-composer-chip="<source>" and, inside it, a decorator span with
14
+ // the label as title/text. We restyle that host and hide its "@" marker.
15
+ const MARK = 'data-dsh-pcb'
16
+ const MARK_SESSION = 'data-dsh-pcb-session'
17
+ const DETAIL_NS = 'dsh-pcb-detail'
18
+
19
+ // ===== Block parsing =====================================================
20
+ const SHEBANG_LANG = {
21
+ python: 'python', python3: 'python', node: 'javascript', nodejs: 'javascript',
22
+ ruby: 'ruby', bash: 'bash', sh: 'bash', zsh: 'bash', dash: 'bash',
23
+ perl: 'perl', php: 'php', go: 'go', rust: 'rust', deno: 'javascript',
24
+ }
25
+
26
+ function detectLang(text) {
27
+ const t = text.trim()
28
+ const shebang = /^\s*#!\s*(?:\/usr\/(?:bin|local\/bin|sbin)\/env\s+)?([\w.-]+)/.exec(text)
29
+ if (shebang) {
30
+ const base = shebang[1].split('.')[0]
31
+ if (SHEBANG_LANG[base]) return SHEBANG_LANG[base]
32
+ }
33
+ if (/^\s*\{(?:\s|")|\n?\s*"[\w-]+"\s*:[ \t]/.test(text) || /(?:{|\[)\s*$/.test(t)) return 'json'
34
+ if (/^\s*<\?xml|^\s*<!DOCTYPE/.test(text)) return 'html'
35
+ if (/^\s*</.test(t)) return 'xml'
36
+ if (/^\s*(let|const|var)\s+\w+\s*=|function\s*\w*\s*\(|=>|class\s+\w+\s*\{/.test(t)) return 'javascript'
37
+ if (/^\s*(import|from|export)\s+/.test(t) && /(?:;|=>|\(\))/.test(t)) return 'javascript'
38
+ if (/^\s*(def |class \w+:|import \w+$|from \w+ import|async def )/.test(t)) return 'python'
39
+ if (/^\s*(SELECT|INSERT INTO|CREATE TABLE|UPDATE |DELETE FROM|ALTER TABLE)\b/i.test(t)) return 'sql'
40
+ if (/^\s*---\s*$|^\s*[a-z0-9_-]+\s*:\s*/i.test(t) && text.indexOf('\n') >= 0 && t.indexOf('=') < 0) return 'yaml'
41
+ if (/^\s*(docker run|docker compose|FROM\s|RUN |npm run|pip install)/.test(t)) return 'bash'
42
+ if (/^\s*(#include|int main|std::)/.test(t)) return 'cpp'
43
+ if (/^\s*(package |import .*\n.*func )/.test(t)) return 'go'
44
+ if (/^\s*(BEGIN|DECLARE)/i.test(t)) return 'sql'
45
+ return ''
46
+ }
47
+
48
+ /**
49
+ * Parse pasted text into a block, or null when it is ordinary short prose
50
+ * that should paste plainly. `isCode` drives both the chip wording
51
+ * ("复制代码块N" vs "复制文本块N") and the fenced language on send.
52
+ */
53
+ function parseBlock(raw) {
54
+ const text = String(raw || '').replace(/^\uFEFF/, '')
55
+ if (!text) return null
56
+
57
+ const fm = /^\s*```([\w+-]*)[^\n]*\n([\s\S]*?)\n?\s*```\s*$/.exec(text)
58
+ if (fm) {
59
+ const lang = (fm[1] || '').trim()
60
+ const content = fm[2].replace(/\n+$/, '')
61
+ if (!content) return null
62
+ return { lang, content, lines: content.split(/\r?\n/), isCode: true }
63
+ }
64
+
65
+ const multi = /\r?\n/.test(text)
66
+ const long = text.length >= 96
67
+ const fenced = /(^|\n)\s*```/m.test(text)
68
+ if (!multi && !long && !fenced) return null
69
+
70
+ // Markdown documents (headings, blockquotes, lists, tables, links, bold,
71
+ // hr, task lists) read as *text*, not code. A lone "#" alone is treated as
72
+ // a comment/code line, so only a combination of markdown signals flips it.
73
+ const mdCount = countMarkdownSignals(text)
74
+
75
+ const indent = text.split(/\r?\n/).some((l) => /^\s{2,}/.test(l))
76
+ const codeKw = /^(def |function |class |const |let |var |import |from |if\b|elif\b|else\b|for\b|while\b|return\b|#include|#!|SELECT |INSERT |CREATE TABLE|UPDATE |DELETE FROM|void |public |private |package |type |enum |interface )/m.test(text)
77
+ const dense = (text.match(/[{}()[\];]/g) || []).length >= 5
78
+ const strongCode = fenced || codeKw || (indent && dense)
79
+
80
+ // Markdown prose wins unless it is an actual fenced code snippet.
81
+ const isMarkdownProse = !fenced && mdCount >= 2
82
+ const isCode = !isMarkdownProse && strongCode
83
+
84
+ const lang = isCode ? detectLang(text) : ''
85
+ const content = text.replace(/\n+$/, '')
86
+ return { lang, content, lines: content.split(/\r?\n/), isCode }
87
+ }
88
+
89
+ /**
90
+ * Count distinct markdown structural signals. A markdown document usually
91
+ * carries several (e.g. headings + lists + links); code with a lone "#"
92
+ * comment carries just one and is therefore kept as code.
93
+ */
94
+ function countMarkdownSignals(text) {
95
+ let n = 0
96
+ if (/^\s{0,3}#{1,6}\s+\S/m.test(text)) n += 1 // ATX heading
97
+ if (/^\s{0,3}>\s+/m.test(text)) n += 1 // blockquote
98
+ if (/^\s{0,3}([-*+]|\d{1,3}\.)\s+/m.test(text)) n += 1 // list / ordered list
99
+ if (/\[[^\]]+\]\([^)]*\)/.test(text)) n += 1 // link
100
+ if (/\*\*[^*\n]+\*\*|__[^_\n]+__/.test(text)) n += 1 // bold
101
+ if (/^\s*\|.*\|\s*$/m.test(text)) n += 1 // table row
102
+ if (/^\s*-{3,}\s*$/m.test(text)) n += 1 // horizontal rule
103
+ return n
104
+ }
105
+
106
+ function serializeBlock(block) {
107
+ const lang = block.isCode ? (block.lang || '') : 'text'
108
+ return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
109
+ }
110
+
111
+ // ===== Draft controller ==================================================
112
+ class BlockController {
113
+ constructor(ctx) {
114
+ this.ctx = ctx
115
+ this.list = new Map() // sessionId -> Block[]
116
+ this.selected = new Map() // sessionId -> ref
117
+ this.used = new Map() // sessionId -> {code:Set, text:Set}
118
+ this.refIndex = new Map() // ref -> {sessionId, block}
119
+ this.labelIndex = new Map()// label -> ref
120
+ this.inFlight = new Map()
121
+ this.expiry = new Map()
122
+ this.serializing = new Set()
123
+ this.listeners = new Map()
124
+ this.counter = 0
125
+ }
126
+
127
+ // Per-session sets of the label numbers currently in use, split by type
128
+ // (code vs text). Numbering always picks the smallest free integer, so
129
+ // deleting a block frees its number for reuse (text/code counted apart).
130
+ usedFor(sessionId) {
131
+ const k = String(sessionId)
132
+ let u = this.used.get(k)
133
+ if (!u) { u = { code: new Set(), text: new Set() }; this.used.set(k, u) }
134
+ return u
135
+ }
136
+
137
+ nextLabel(sessionId, type) {
138
+ const used = this.usedFor(sessionId)[type]
139
+ let n = 1
140
+ while (used.has(n)) n += 1
141
+ used.add(n)
142
+ return n
143
+ }
144
+
145
+ freeLabel(sessionId, type, n) {
146
+ const k = String(sessionId)
147
+ const used = this.used.get(k)
148
+ if (used) used[type]?.delete(n)
149
+ }
150
+
151
+ labelFor(block, n) {
152
+ return block.isCode ? `复制代码块${n}` : `复制文本块${n}`
153
+ }
154
+
155
+ listFor(sessionId) { return this.list.get(String(sessionId)) || [] }
156
+ selectedFor(sessionId) { return this.selected.get(String(sessionId)) }
157
+
158
+ subscribe(sessionId, fn) {
159
+ const k = String(sessionId)
160
+ let s = this.listeners.get(k)
161
+ if (!s) { s = new Set(); this.listeners.set(k, s) }
162
+ s.add(fn)
163
+ return () => { s.delete(fn); if (s.size === 0) this.listeners.delete(k) }
164
+ }
165
+
166
+ publish(sessionId) {
167
+ const s = this.listeners.get(String(sessionId))
168
+ if (!s) return
169
+ for (const fn of s) fn()
170
+ }
171
+
172
+ scope(sessionId) {
173
+ const actx = this.ctx.sessions.scope(sessionId)
174
+ if (!actx) throw new Error('paste-code-block: session not ready')
175
+ return { actx, shell: this.ctx.conversation.input.for(actx) }
176
+ }
177
+
178
+ insertReference(sessionId, block) {
179
+ const { actx, shell } = this.scope(sessionId)
180
+ const input = shell.snapshot
181
+ if (!input || input.phase !== 'plain') return false
182
+ // Each chip contributes exactly one char to the editor's detect text
183
+ // (the "" placeholder) and one char to the plain `draft` projection
184
+ // (clipboardText = zero-width space, chosen because String#trim() does
185
+ // NOT strip U+200B — so the composer's empty check `draft.trim() === ""`
186
+ // sees the draft as non-empty and the send button stays enabled even when
187
+ // only a block is pasted with no typed text). Hence draft.length already
188
+ // equals the detect length; anchoring there keeps blocks strictly ordered.
189
+ const end = input.draft ? input.draft.length : 0
190
+ return actx.bail(actx, 'slash/input-insert-reference', {
191
+ reference: {
192
+ source: SOURCE,
193
+ ref: block.id,
194
+ label: block.label,
195
+ clipboardText: '\u200B',
196
+ },
197
+ span: { start: end, end, draftRev: input.draftRev },
198
+ }) === true
199
+ }
200
+
201
+ attach(sessionId, block) {
202
+ const k = String(sessionId)
203
+ this.clearInFlight(k)
204
+ block.id = `${k}-${++this.counter}`
205
+ const type = block.isCode ? 'code' : 'text'
206
+ const n = this.nextLabel(sessionId, type)
207
+ block.labelNumber = n
208
+ block.type = type
209
+ block.label = this.labelFor(block, n)
210
+ if (!this.insertReference(sessionId, block)) {
211
+ // release the reserved label on failure
212
+ this.freeLabel(sessionId, type, n)
213
+ return false
214
+ }
215
+ const next = [...this.listFor(k), block]
216
+ this.list.set(k, next)
217
+ this.refIndex.set(block.id, { sessionId: k, block })
218
+ this.labelIndex.set(block.label, block.id)
219
+ this.publish(sessionId)
220
+ return true
221
+ }
222
+
223
+ blockForLabel(label) {
224
+ const ref = this.labelIndex.get(label)
225
+ return ref ? this.refIndex.get(ref)?.block : undefined
226
+ }
227
+
228
+ select(sessionId, ref) {
229
+ const k = String(sessionId)
230
+ const cur = this.selected.get(k)
231
+ if (cur === ref) this.selected.delete(k)
232
+ else this.selected.set(k, ref)
233
+ this.publish(sessionId)
234
+ }
235
+
236
+ remove(sessionId, ref) {
237
+ const k = String(sessionId)
238
+ const entries = this.listFor(k)
239
+ const entry = entries.find((b) => b.id === ref)
240
+ if (!entry) return
241
+ if (this.selected.get(k) === ref) this.selected.delete(k)
242
+ const { shell } = this.scope(sessionId)
243
+ const input = shell.snapshot
244
+ const occurrence = (input.occurrences || []).find((o) => o.source === SOURCE && o.ref === ref)
245
+ if (occurrence) {
246
+ shell.setDraft(input.draft.slice(0, occurrence.offset) + input.draft.slice(occurrence.offset + 1))
247
+ }
248
+ const next = entries.filter((b) => b.id !== ref)
249
+ if (next.length > 0) this.list.set(k, next)
250
+ else this.list.delete(k)
251
+ this.refIndex.delete(ref)
252
+ for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
253
+ this.freeLabel(sessionId, entry.type, entry.labelNumber)
254
+ this.publish(sessionId)
255
+ }
256
+
257
+ /** Update a block's content while editing it in the detail card. */
258
+ updateContent(sessionId, ref, content) {
259
+ const record = this.refIndex.get(ref)
260
+ if (!record) return
261
+ record.block.content = content
262
+ record.block.lines = content.split(/\r?\n/)
263
+ }
264
+
265
+ clearInFlight(sessionId) {
266
+ const k = String(sessionId)
267
+ const expiry = this.expiry.get(k)
268
+ if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
269
+ for (const b of this.inFlight.get(k) || []) {
270
+ this.refIndex.delete(b.id)
271
+ this.freeLabel(sessionId, b.type, b.labelNumber)
272
+ for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
273
+ }
274
+ this.inFlight.delete(k)
275
+ }
276
+
277
+ markSerializing(ref) {
278
+ const record = this.refIndex.get(ref)
279
+ if (record && !this.serializing.has(record.sessionId)) this.serializing.add(record.sessionId)
280
+ }
281
+
282
+ async serialize(ref) {
283
+ const record = this.refIndex.get(ref)
284
+ if (!record) throw new Error('代码块已失效,请重新粘贴')
285
+ this.markSerializing(ref)
286
+ return serializeBlock(record.block)
287
+ }
288
+
289
+ /**
290
+ * Keep only blocks whose inline chip is still present in the draft (the
291
+ * user may delete a chip with Backspace). During a submit the chips may
292
+ * transiently vanish, so prune is deferred there.
293
+ */
294
+ reconcile(sessionId, occurrences, phase) {
295
+ const k = String(sessionId)
296
+ const entries = this.listFor(k)
297
+ if (entries.length === 0) return
298
+ const refs = new Set((occurrences || []).filter((o) => o.source === SOURCE).map((o) => o.ref))
299
+ const missing = entries.filter((entry) => !refs.has(entry.id))
300
+ if (missing.length === 0) return
301
+
302
+ if (this.serializing.delete(k)) {
303
+ this.list.delete(k)
304
+ this.inFlight.set(k, entries)
305
+ const prev = this.expiry.get(k)
306
+ if (prev) clearTimeout(prev)
307
+ this.expiry.set(k, setTimeout(() => this.clearInFlight(k), 20_000))
308
+ this.publish(sessionId)
309
+ return
310
+ }
311
+
312
+ if (phase === 'claimed' || phase === 'settling') return
313
+
314
+ for (const entry of missing) {
315
+ this.refIndex.delete(entry.id)
316
+ for (const [label, r] of this.labelIndex) if (r === entry.id) this.labelIndex.delete(label)
317
+ if (this.selected.get(k) === entry.id) this.selected.delete(k)
318
+ this.freeLabel(sessionId, entry.type, entry.labelNumber)
319
+ }
320
+ const next = entries.filter((entry) => refs.has(entry.id))
321
+ if (next.length > 0) this.list.set(k, next)
322
+ else this.list.delete(k)
323
+ this.publish(sessionId)
324
+ }
325
+
326
+ restoreFailed(sessionId) {
327
+ // Best-effort: on a failed submit, simply surface in-flight blocks again.
328
+ // The draft chips are usually untouched on failure, so reconciliation
329
+ // keeps them; we only re-insert markers that were consumed.
330
+ const k = String(sessionId)
331
+ const entries = this.inFlight.get(k) || []
332
+ const expiry = this.expiry.get(k)
333
+ if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
334
+ this.inFlight.delete(k)
335
+ const survived = []
336
+ for (const entry of entries) {
337
+ this.refIndex.set(entry.id, { sessionId: k, block: entry })
338
+ this.labelIndex.set(entry.label, entry.id)
339
+ // A chip still present in the draft is enough; skip re-insert attempt
340
+ // if the shell can't resolve (rare). Reconcile will re-prune as needed.
341
+ survived.push(entry)
342
+ }
343
+ if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
344
+ this.publish(sessionId)
345
+ }
346
+
347
+ dispose() {
348
+ for (const t of this.expiry.values()) clearTimeout(t)
349
+ this.expiry.clear(); this.list.clear(); this.selected.clear()
350
+ this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
351
+ this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
352
+ }
353
+ }
354
+
355
+ // ===== React glue ========================================================
356
+ function useBlocks(controller, sessionId) {
357
+ const [, render] = React.useState(0)
358
+ React.useEffect(
359
+ () => controller.subscribe(sessionId, () => render((v) => v + 1)),
360
+ [controller, sessionId],
361
+ )
362
+ return controller.listFor(sessionId)
363
+ }
364
+
365
+ function useSelectedBlock(controller, sessionId) {
366
+ const [, render] = React.useState(0)
367
+ React.useEffect(
368
+ () => controller.subscribe(sessionId, () => render((v) => v + 1)),
369
+ [controller, sessionId],
370
+ )
371
+ const ref = controller.selectedFor(sessionId)
372
+ if (!ref) return null
373
+ const record = controller.refIndex.get(ref)
374
+ return record ? record.block : null
375
+ }
376
+
377
+ function useComposerMark(rootRef, sessionId, draftRev, active) {
378
+ React.useLayoutEffect(() => {
379
+ const root = rootRef.current
380
+ if (!root) return
381
+ let el = root.parentElement
382
+ while (el) {
383
+ if (el.nodeType === 1 && el.querySelector && el.querySelector('textarea,[contenteditable],[role="textbox"]')) break
384
+ el = el.parentElement
385
+ }
386
+ if (!el) return
387
+ el.setAttribute(MARK, '1')
388
+ el.setAttribute(MARK_SESSION, String(sessionId))
389
+ // Invisible block markers can leave the draft reading as "empty", so the
390
+ // composer's own placeholder would otherwise sit on top of the chips.
391
+ el.setAttribute('data-dsh-pcb-active', active ? '1' : '0')
392
+ }, [sessionId, draftRev, active])
393
+ }
394
+
395
+ // ===== Render: the small inline chips are DSH's own; we style them via CSS. =====
396
+ // This dock renders a **detail card only for the selected block** (clicked chip).
397
+ function BlockDock(props) {
398
+ const input = props.useInput((state) => state) || {}
399
+ const promptError = props.useSession((session) => session.promptError) || null
400
+ const blocks = useBlocks(props.controller, props.sessionId)
401
+ const selected = useSelectedBlock(props.controller, props.sessionId)
402
+ const railRef = React.useRef(null)
403
+
404
+ useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
405
+
406
+ React.useEffect(() => {
407
+ props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
408
+ }, [props.controller, props.sessionId, input.draftRev, input.phase])
409
+
410
+ React.useEffect(() => {
411
+ if (promptError && props.controller.inFlight.has(String(props.sessionId))) {
412
+ props.controller.restoreFailed(props.sessionId)
413
+ }
414
+ }, [props.controller, props.sessionId, promptError])
415
+
416
+ const empty = !selected
417
+ return React.createElement(
418
+ 'div',
419
+ {
420
+ ref: railRef,
421
+ className: `dsh-pcb-dock${empty ? ' dsh-pcb-dock--empty' : ''}`,
422
+ 'data-dsh-pcb-rail': '',
423
+ 'data-dsh-pcb-session': String(props.sessionId),
424
+ 'aria-label': '代码块详情',
425
+ },
426
+ selected
427
+ ? React.createElement(BlockDetail, {
428
+ block: selected,
429
+ controller: props.controller,
430
+ sessionId: props.sessionId,
431
+ })
432
+ : null,
433
+ )
434
+ }
435
+
436
+ function BlockDetail(props) {
437
+ const { block, controller, sessionId } = props
438
+ const [collapsed, setCollapsed] = React.useState(false)
439
+ const [copied, setCopied] = React.useState(false)
440
+ // Editable draft: lets the user edit the block's content in the detail
441
+ // card; kept in local state (so the caret isn't disturbed) and mirrored to
442
+ // the controller so the serialized send uses the edited text.
443
+ const [draft, setDraft] = React.useState(block.content)
444
+
445
+ React.useEffect(() => {
446
+ setDraft(block.content)
447
+ }, [block.id])
448
+
449
+ function onEdit(ev) {
450
+ const value = ev.target.value
451
+ setDraft(value)
452
+ controller.updateContent(sessionId, block.id, value)
453
+ }
454
+
455
+ function copy() {
456
+ navigator.clipboard?.writeText(block.content)?.then(() => {
457
+ setCopied(true)
458
+ setTimeout(() => setCopied(false), 1200)
459
+ }).catch(() => {})
460
+ }
461
+
462
+ function hide() {
463
+ controller.select(sessionId, block.id) // toggles off (same id)
464
+ }
465
+
466
+ return React.createElement(
467
+ 'div',
468
+ { className: `dsh-pcb-detail${collapsed ? ' dsh-pcb-detail--collapsed' : ''}` },
469
+ React.createElement(
470
+ 'div',
471
+ { className: 'dsh-pcb-head' },
472
+ React.createElement('span', { className: 'dsh-pcb-lang' }, block.lang || 'text'),
473
+ React.createElement('span', { className: 'dsh-pcb-lines' }, `${block.lines.length} 行`),
474
+ React.createElement('span', { className: 'dsh-pcb-spacer' }),
475
+ collapsed ? React.createElement('button', {
476
+ type: 'button', className: 'dsh-pcb-btn', title: '展开', 'aria-label': '展开',
477
+ onClick: () => setCollapsed(false),
478
+ }, '▾') : React.createElement('button', {
479
+ type: 'button', className: 'dsh-pcb-btn', title: '折叠', 'aria-label': '折叠',
480
+ onClick: () => setCollapsed(true),
481
+ }, '▸'),
482
+ React.createElement('button', {
483
+ type: 'button', className: 'dsh-pcb-btn', title: '复制', 'aria-label': '复制',
484
+ onClick: copy,
485
+ }, copied ? '✓' : '⎘'),
486
+ React.createElement('button', {
487
+ type: 'button', className: 'dsh-pcb-btn', title: '收起', 'aria-label': '收起',
488
+ onClick: hide,
489
+ }, '⟨'),
490
+ React.createElement('button', {
491
+ type: 'button', className: 'dsh-pcb-btn dsh-pcb-btn--danger', title: '移除', 'aria-label': '移除',
492
+ onClick: () => controller.remove(sessionId, block.id),
493
+ }, '×'),
494
+ ),
495
+ collapsed
496
+ ? React.createElement('div', { className: 'dsh-pcb-preview' }, block.lines.slice(0, 1).join('\n'))
497
+ : React.createElement('textarea', {
498
+ className: 'dsh-pcb-body dsh-pcb-body--edit',
499
+ value: draft,
500
+ onChange: onEdit,
501
+ spellCheck: false,
502
+ rows: Math.min(Math.max(block.lines.length, 2), 12),
503
+ 'aria-label': '编辑代码块内容',
504
+ }),
505
+ )
506
+ }
507
+
508
+ // ===== Styles ============================================================
509
+ const CSS = `
510
+ /* --- restyle DSH's inline reference chip into a boxed badge --- */
511
+ [data-composer-chip="${SOURCE}"] > span{
512
+ box-sizing:border-box!important;display:inline-flex!important;align-items:center;
513
+ gap:4px!important;vertical-align:middle;margin:0 2px;padding:1px 8px!important;
514
+ border:1px solid var(--dsw-alias-border-l3)!important;border-radius:7px!important;
515
+ background:var(--dsw-specific-input-major,var(--dsw-alias-interactive-bg-hover))!important;
516
+ color:var(--dsw-alias-label-primary)!important;
517
+ font-family:var(--dsw-font-family)!important;font-size:12px!important;line-height:18px!important;
518
+ cursor:pointer;box-shadow:var(--dsw-shadow-lv1);min-width:0;max-width:100%;
519
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap
520
+ }
521
+ [data-composer-chip="${SOURCE}"]:hover > span{
522
+ border-color:var(--dsw-alias-state-business-primary)!important;
523
+ background:var(--dsw-alias-interactive-bg-hover)!important
524
+ }
525
+ /* hide the "@" marker that DSH renders when the chip has no appearance glyph */
526
+ [data-composer-chip="${SOURCE}"] > span > [aria-hidden="true"]{display:none!important}
527
+ /* the label text */
528
+ [data-composer-chip="${SOURCE}"] > span > span:last-child{font-weight:500!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
529
+
530
+ /* --- dock / detail card above the input --- */
531
+ .dsh-pcb-dock{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width);margin:0 auto 6px;padding:0 var(--dsh-composer-side-clearance,16px);display:flex;flex-direction:column;gap:8px}
532
+ .dsh-pcb-dock--empty{display:none}
533
+ .dsh-pcb-detail{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);box-shadow:var(--dsw-shadow-lv1);color:var(--dsw-alias-label-primary);overflow:hidden}
534
+ .dsh-pcb-head{box-sizing:border-box;height:32px;flex:none;display:flex;align-items:center;gap:8px;padding:0 6px 0 10px;border-bottom:1px solid var(--dsw-alias-border-l2)}
535
+ .dsh-pcb-lang{flex:none;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-state-business-primary,#4c6ef5);background:var(--dsw-alias-interactive-bg-hover);border-radius:6px;padding:1px 7px}
536
+ .dsh-pcb-lines{flex:none;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
537
+ .dsh-pcb-spacer{flex:1;min-width:0}
538
+ .dsh-pcb-btn{box-sizing:border-box;width:26px;height:26px;flex:none;display:grid;place-items:center;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1;cursor:pointer}
539
+ .dsh-pcb-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
540
+ .dsh-pcb-btn--danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
541
+ .dsh-pcb-preview{box-sizing:border-box;padding:6px 10px;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-tertiary)}
542
+ .dsh-pcb-body{box-sizing:border-box;margin:0;max-height:260px;overflow:auto;padding:8px 12px;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)}
543
+ .dsh-pcb-body--edit{display:block;width:100%;min-height:56px;max-height:300px;resize:vertical;border:0;outline:none;border-radius:0;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);white-space:pre;overflow:auto;cursor:text}
544
+ .dsh-pcb-body--edit:focus{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:-1px}
545
+
546
+ /* --- hide the composer's own placeholder while chips/cards are active --- */
547
+ [data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
548
+
549
+ @media (max-width:640px){
550
+ .dsh-pcb-head{height:34px}
551
+ .dsh-pcb-lang{max-width:55%}
552
+ }
553
+ `
554
+
555
+ // ===== Client plugin body ===============================================
556
+ const inject = ['slots', 'sessions', 'conversation', 'inputTriggers']
557
+
558
+ function apply(ctx) {
559
+ try { console.log('[dsh-paste-code-block] client loaded') } catch (e) { /* noop */ }
560
+ const controller = new BlockController(ctx)
561
+ ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
562
+
563
+ ctx.effect(() => ctx.inputTriggers.registerSource({
564
+ trigger: '@',
565
+ name: SOURCE,
566
+ order: 20_000,
567
+ candidates: async () => [],
568
+ onPick: () => undefined,
569
+ codec: {
570
+ clipboardText: () => '',
571
+ serialize: async (ref) => controller.serialize(ref),
572
+ },
573
+ }), 'paste-code-block: hidden reference codec')
574
+
575
+ ctx.effect(() => {
576
+ const style = document.createElement('style')
577
+ style.dataset.plugin = 'dsh-paste-code-block'
578
+ style.textContent = CSS
579
+ document.head.appendChild(style)
580
+ return () => style.remove()
581
+ }, 'paste-code-block: styles')
582
+
583
+ // Resolve the active session for an event inside the composer seat.
584
+ function sessionFor(target) {
585
+ const seat = target.closest(`[${MARK}]`)
586
+ let sid = seat && seat.getAttribute(MARK_SESSION)
587
+ if (!sid && target.parentElement) {
588
+ let el = target.parentElement
589
+ while (el) {
590
+ const rail = el.querySelector && el.querySelector('[data-dsh-pcb-rail]')
591
+ if (rail) { sid = rail.getAttribute('data-dsh-pcb-session') || sid; break }
592
+ el = el.parentElement
593
+ }
594
+ }
595
+ return sid
596
+ }
597
+
598
+ // Turn qualifying pastes into boxed inline chips.
599
+ const onPaste = (ev) => {
600
+ const clip = ev.clipboardData
601
+ if (!clip) return
602
+ const text = clip.getData('text/plain')
603
+ if (!text || text.length === 0) return
604
+ const target = document.activeElement
605
+ if (!target || !(target instanceof Element)) return
606
+ const sid = sessionFor(target)
607
+ if (!sid) return
608
+ const block = parseBlock(text)
609
+ if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
610
+ console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang)
611
+ ev.preventDefault()
612
+ ev.stopPropagation()
613
+ try {
614
+ if (!controller.attach(sid, block)) {
615
+ console.log('[dsh-paste-code-block] attach failed; plain insert instead')
616
+ const { actx, shell } = controller.scope(sid)
617
+ const input = shell.snapshot
618
+ if (input && actx.bail(actx, 'slash/input-insert-text', {
619
+ text: text + (text.endsWith('\n') ? '' : '\n'),
620
+ span: {
621
+ start: input.draft ? input.draft.length : 0,
622
+ end: input.draft ? input.draft.length : 0,
623
+ draftRev: input.draftRev,
624
+ },
625
+ }) !== true) {
626
+ console.warn('[dsh-paste-code-block] could not insert block text')
627
+ }
628
+ }
629
+ } catch (err) {
630
+ console.error('[paste-code-block] attach failed:', err)
631
+ }
632
+ }
633
+ ctx.effect(() => {
634
+ document.addEventListener('paste', onPaste, true)
635
+ return () => document.removeEventListener('paste', onPaste, true)
636
+ }, 'paste-code-block: paste capture')
637
+
638
+ // Clicking an inline chip toggles its detail card.
639
+ const onChipPointer = (ev) => {
640
+ const target = ev.target
641
+ if (!(target instanceof Element)) return
642
+ // Clicking a chip toggles its detail card.
643
+ const host = target.closest(`[data-composer-chip="${SOURCE}"]`)
644
+ if (host) {
645
+ const labelEl = host.querySelector(':scope > span[title]') || host.querySelector('span[title]')
646
+ const label = labelEl && labelEl.getAttribute('title')
647
+ const sid = sessionFor(target)
648
+ if (!sid || !label) return
649
+ const block = controller.blockForLabel(label)
650
+ if (!block) return
651
+ ev.preventDefault()
652
+ ev.stopPropagation()
653
+ controller.select(sid, block.id)
654
+ return
655
+ }
656
+ // Clicking anywhere OUTSIDE the open detail card closes it.
657
+ const withinDetail = target.closest('.dsh-pcb-detail')
658
+ if (withinDetail) return
659
+ for (const [k, ref] of controller.selected) {
660
+ controller.selected.delete(k)
661
+ controller.publish(k)
662
+ }
663
+ }
664
+ ctx.effect(() => {
665
+ document.addEventListener('pointerdown', onChipPointer, true)
666
+ return () => document.removeEventListener('pointerdown', onChipPointer, true)
667
+ }, 'paste-code-block: chip pointer')
668
+
669
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
670
+ name: 'conversation.input.dock',
671
+ id: DETAIL_NS,
672
+ order: 90,
673
+ label: '粘贴代码块详情',
674
+ }, (props) => React.createElement(BlockDock, { ...props, controller })))
675
+ }
676
+
677
+ exports.apply = apply
678
+ exports.inject = inject
679
+ return module.exports
680
+ },
681
+ })
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * dsh-paste-code-block — host (node) half.
3
+ *
4
+ * Pure UI plugin: the host body is intentionally empty. The browser half ships
5
+ * via exports["./client"], discovered through the package.json `dsh.client`
6
+ * declaration and mounted into the Web composer by the host layer.
7
+ */
8
+ export const name = 'dsh-paste-code-block'
9
+
10
+ /** Host plugin body — no host-side behavior. */
11
+ export function apply() {}
package/src/parse.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * dsh-paste-code-block — pure block-parsing logic (no DOM, no DSH deps).
3
+ *
4
+ * Canonical source for how a pasted text/code snippet is turned into a block
5
+ * object. The browser half (`client.js`) embeds its own copy of this logic so
6
+ * it can run inside DSH's client module loader (which cannot `require` relative
7
+ * ESM files); this module is the single place where that logic is unit-tested.
8
+ *
9
+ * Keep it framework-free and side-effect-free so it is trivially testable.
10
+ */
11
+
12
+ const SHEBANG_LANG = {
13
+ python: 'python', python3: 'python', node: 'javascript', nodejs: 'javascript',
14
+ ruby: 'ruby', bash: 'bash', sh: 'bash', zsh: 'bash', dash: 'bash',
15
+ perl: 'perl', php: 'php', go: 'go', rust: 'rust', deno: 'javascript',
16
+ }
17
+
18
+ export function detectLang(text) {
19
+ const t = text.trim()
20
+ const shebang = /^\s*#!\s*(?:\/usr\/(?:bin|local\/bin|sbin)\/env\s+)?([\w.-]+)/.exec(text)
21
+ if (shebang) {
22
+ const base = shebang[1].split('.')[0]
23
+ if (SHEBANG_LANG[base]) return SHEBANG_LANG[base]
24
+ }
25
+ if (/^\s*\{(?:\s|")|\n?\s*"[\w-]+"\s*:[ \t]/.test(text) || /(?:{|\[)\s*$/.test(t)) return 'json'
26
+ if (/^\s*<\?xml|^\s*<!DOCTYPE/.test(text)) return 'html'
27
+ if (/^\s*</.test(t)) return 'xml'
28
+ if (/^\s*(let|const|var)\s+\w+\s*=|function\s*\w*\s*\(|=>|class\s+\w+\s*\{/.test(t)) return 'javascript'
29
+ if (/^\s*(import|from|export)\s+/.test(t) && /(?:;|=>|\(\))/.test(t)) return 'javascript'
30
+ if (/^\s*(def |class \w+:|import \w+$|from \w+ import|async def )/.test(t)) return 'python'
31
+ if (/^\s*(SELECT|INSERT INTO|CREATE TABLE|UPDATE |DELETE FROM|ALTER TABLE)\b/i.test(t)) return 'sql'
32
+ if (/^\s*---\s*$|^\s*[a-z0-9_-]+\s*:\s*/i.test(t) && text.indexOf('\n') >= 0 && t.indexOf('=') < 0) return 'yaml'
33
+ if (/^\s*(docker run|docker compose|FROM\s|RUN |npm run|pip install)/.test(t)) return 'bash'
34
+ if (/^\s*(#include|int main|std::)/.test(t)) return 'cpp'
35
+ if (/^\s*(package |import .*\n.*func )/.test(t)) return 'go'
36
+ if (/^\s*(BEGIN|DECLARE)/i.test(t)) return 'sql'
37
+ return ''
38
+ }
39
+
40
+ /**
41
+ * Parse pasted text into a block, or null when it is ordinary short prose
42
+ * that should paste plainly. `isCode` drives both the chip wording
43
+ * ("复制代码块N" vs "复制文本块N") and the fenced language on send.
44
+ */
45
+ export function parseBlock(raw) {
46
+ const text = String(raw || '').replace(/^\uFEFF/, '')
47
+ if (!text) return null
48
+
49
+ const fm = /^\s*```([\w+-]*)[^\n]*\n([\s\S]*?)\n?\s*```\s*$/.exec(text)
50
+ if (fm) {
51
+ const lang = (fm[1] || '').trim()
52
+ const content = fm[2].replace(/\n+$/, '')
53
+ if (!content) return null
54
+ return { lang, content, lines: content.split(/\r?\n/), isCode: true }
55
+ }
56
+
57
+ const multi = /\r?\n/.test(text)
58
+ const long = text.length >= 96
59
+ const fenced = /(^|\n)\s*```/m.test(text)
60
+ if (!multi && !long && !fenced) return null
61
+
62
+ // Markdown documents (headings, blockquotes, lists, tables, links, bold,
63
+ // hr, task lists) read as *text*, not code. A lone "#" alone is treated as
64
+ // a comment/code line, so only a combination of markdown signals flips it.
65
+ const mdCount = countMarkdownSignals(text)
66
+
67
+ const indent = text.split(/\r?\n/).some((l) => /^\s{2,}/.test(l))
68
+ const codeKw = /^(def |function |class |const |let |var |import |from |if\b|elif\b|else\b|for\b|while\b|return\b|#include|#!|SELECT |INSERT |CREATE TABLE|UPDATE |DELETE FROM|void |public |private |package |type |enum |interface )/m.test(text)
69
+ const dense = (text.match(/[{}()[\];]/g) || []).length >= 5
70
+ const strongCode = fenced || codeKw || (indent && dense)
71
+
72
+ // Markdown prose wins unless it is an actual fenced code snippet.
73
+ const isMarkdownProse = !fenced && mdCount >= 2
74
+ const isCode = !isMarkdownProse && strongCode
75
+
76
+ const lang = isCode ? detectLang(text) : ''
77
+ const content = text.replace(/\n+$/, '')
78
+ return { lang, content, lines: content.split(/\r?\n/), isCode }
79
+ }
80
+
81
+ /**
82
+ * Count distinct markdown structural signals. A markdown document usually
83
+ * carries several (e.g. headings + lists + links); code with a lone "#"
84
+ * comment carries just one and is therefore kept as code.
85
+ */
86
+ export function countMarkdownSignals(text) {
87
+ let n = 0
88
+ if (/^\s{0,3}#{1,6}\s+\S/m.test(text)) n += 1 // ATX heading
89
+ if (/^\s{0,3}>\s+/m.test(text)) n += 1 // blockquote
90
+ if (/^\s{0,3}([-*+]|\d{1,3}\.)\s+/m.test(text)) n += 1 // list / ordered list
91
+ if (/\[[^\]]+\]\([^)]*\)/.test(text)) n += 1 // link
92
+ if (/\*\*[^*\n]+\*\*|__[^_\n]+__/.test(text)) n += 1 // bold
93
+ if (/^\s*\|.*\|\s*$/m.test(text)) n += 1 // table row
94
+ if (/^\s*-{3,}\s*$/m.test(text)) n += 1 // horizontal rule
95
+ return n
96
+ }
97
+
98
+ export function serializeBlock(block) {
99
+ const lang = block.isCode ? (block.lang || '') : 'text'
100
+ return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
101
+ }