- Inicio
- Plugins
- Mejoras de UI
- dsh-paste-chip
dsh-paste-chip
zengxiaolou/dsh-paste-chip
Collapses long pasted text into an expandable chip in the composer: hover shows the body and where it came from, sending expands it back in place, and long text written by other plugins is taken over the same way.
Instalar
dsh plugin --profile web add github:zengxiaolou/dsh-paste-chipREADME
dsh-paste-chip

把超长粘贴折叠成一张可展开的引用胶囊,避免长草稿把输入框拖死。
- 粘贴阈值:≥400 字符 或 ≥12 行 折叠成一张胶囊
- 外部渠道(标注浏览器 / 划词注释 / 任何调用 API 的插件):不看长度,一律成胶囊
- 上限:20 万字符;超过直接拒绝并提示改用附件(超长正文进模型上下文也没有意义)
- 胶囊显示
粘贴「会议纪要:本周三…」· 1.2k 字(英文Pasted「…」· 1.2k chars)——带正文前缀,两个同字数的胶囊也能分辨;整块删除、整块撤销 - 发送时展开回完整正文;正文找不到就明确拦住发送,绝不把标签当正文发出去

为什么需要它
DSH 的输入框每敲一个键都在做全量工作:重算整篇投影、把整段草稿写进 localStorage。草稿一旦到几十万字,就会一卡一顿,极端情况直接把界面拖死。
这个插件把「长文本」从草稿里搬走:
| 没有它 | 有它 |
|---|---|
| 12k 字粘贴后草稿就 12k 字,每键写盘 12k+ | 草稿里只有一张 24px 高的胶囊,每键写盘回到标签大小 |
| 正文和你说的话混在一起,找不着边界 | 正文独立存放,发送时按胶囊所在位置展开 |
| 想确认粘了什么,得在几千字里翻 | 悬停或点击就能看正文前缀与上下文 |
为什么不改 core
DSH 的客户端是打包在 app.asar 里的,改 core 要连带改 ElectronAsarIntegrity 哈希并重签,且下一个版本会被覆盖。本插件只走三条已有公开通道:
| 能力 | 通道 |
|---|---|
| 插入胶囊 | 作用域事件 slash/input-insert-reference(内置 slash 插件插 chip 用的就是它) |
| 拿稳定锚点 | InputActions.captureInsertion() → 带 draftRev 的 TokenSpan |
| 发送时展开 | 注册 InputTriggerSource,实现 codec.serialize(ref) |
正文不放进 ref id(core 版把 base64 塞进 ref,中文要放大 4 倍),而是按短 id 存在插件自己的 localStorage(前缀 dsh-paste-chip.v1.,最多 12 条 / 2MB,超了淘汰最旧的)。草稿里只有一个短 id,每次按键的持久化体积回到标签大小。
工作方式
- 在
document的捕获阶段监听paste,只处理本插件所属 composer 的编辑器 - 短文本直接放行给 core,不干预
- 长文本:
preventDefault()→ 正文入库 →actx.bail(...)插入胶囊 - 插入失败:清掉刚写的正文,退回纯文本粘贴并提示——不吞内容
安装
以本地目录方式装进 profile(路径换成你自己的):
~/.dsh/profiles/<profile>/package.json→dependencies加"dsh-paste-chip": "link:/path/to/dsh-paste-chip"- 同一个
package.json→dsh.profile.bundles数组末尾加"dsh-paste-chip"← 真正的加载入口 ~/.dsh/profiles/<profile>/cordis.patch.yml→ 末尾加- id: dsh-paste-chip
然后在 profile 目录跑一次 pnpm install(需要 Node ≥22.13),再重启 DSH 客户端。
宿主启动时按 dsh.profile.bundles 的顺序叠 bundle 层,再叠 cordis.patch.yml;只加依赖和 patch 行不会加载插件。
客户端半的改动刷新页面就会重新加载;宿主半(
lib/index.js)改了才需要重启。
可配置项
全部阈值都能改,两种方式二选一:
A. cordis.patch.yml(profile 里那一行加上 config)
- id: dsh-paste-chip
config:
collapseChars: 600 # 粘贴折叠阈值(字符)
collapseLines: 15 # 粘贴折叠阈值(行)
previewChars: 500 # 悬停预览多少字
B. 设置页(装了 @deepseek-ai/dsh-settings 时自动出现「dsh-paste-chip」分区)
| 键 | 默认 | 范围 | 作用 |
|---|---|---|---|
collapseChars | 400 | 100–10000 | 粘贴折叠阈值(字符) |
collapseLines | 12 | 3–200 | 粘贴折叠阈值(行) |
maxChars | 200000 | 1000–2000000 | 单段正文上限,超过拒绝 |
externalMinChars | 8 | 1–200 | 外部渠道插入的兜底长度,低于它视为手打 |
warmupMs | 1200 | 0–10000 | 挂载后预热窗口,期间不接管(防恢复草稿误折叠) |
inputGraceMs | 500 | 0–3000 | 用户输入后的宽限期 |
previewChars | 320 | 80–2000 | 悬停浮层预览字数 |
contextChars | 40 | 0–200 | 悬停浮层上下文各取多少字(0 = 关闭) |
hoverDelayMs | 180 | 0–1000 | 悬停多久弹出预览 |
leaveGraceMs | 60 | 0–1000 | 移出后多久收起 |
storeMaxEntries | 12 | 1–100 | 正文最多存几条,超出淘汰最旧 |
storeMaxBytes | 2097152 | 64KB–8MB | 正文存储总字节上限 |
实现方式:宿主半把这些值汇总到 GET /api/dsh-paste-chip/config,客户端启动时读一次并覆盖默认值;读不到就用同一份默认值,越界一律夹到边界(客户端与宿主各夹一次)。默认值写在 lib/client.js 的 DEFAULT_SETTINGS 与 lib/index.js 的 DEFAULTS,两边必须保持一致。
给其他插件用的主动接法(可选)
默认是被动接法:任何插件用 setDraft / insertText 写进草稿的长文本,都会被差异观察器接住,对方不用改一行代码。
需要更精确控制时(原子插入、草稿里已有胶囊、不想晚一帧),生产者调一行服务:
// 在会话槽组件里:span 必须和当前 revision 一致
const span = props.inputActions.captureInsertion()
const result = ctx.pasteChip.insert(body, { sessionId, span, label: '注释 · 3 条' })
// result: { ok: true, ref, label } | { ok: false, reason }
失败原因(都不静默):empty、too-long(超 20 万字)、span-required(没给 span)、no-session(会话 actx 解析不到)、bail-refused(插入被拒,正文已清理)。
服务版本:ctx.pasteChip.version。
预览开关的事件(v0.1.22)
常驻预览是谁打开的、谁关心它被关掉,插件自己不知道。所以给一个订阅口:
const off = ctx.get('pasteChip').watchPreview((event) => {
// { phase: 'pin', ref }
// { phase: 'unpin', ref, reason: 'close' | 'dismiss' | 'switch' | 'removed' }
})
reason 是这套 API 的全部价值,别混用:
| reason | 什么动作 | 联动方一般该做什么 |
|---|---|---|
close | 点了卡片右上角的 ✕ | 这是唯一的「用户主动关闭」,可以据此取消对应动作 |
dismiss | Esc / 点浮层外部 | 收起预览而已,不要当成取消 |
switch | 点了另一枚胶囊,预览换过去 | 同上,旧的那条只是被替换 |
removed | 外部插件调了 closePreview(ref) | 胶囊已经没了,卡片顺手收掉 |
ctx.pasteChip.closePreview(ref):只收指定 ref 的常驻预览(不是当前预览就返回 false,不误伤别人),广播 reason: 'removed'。
服务必须用 ctx.get('pasteChip') 取。cordis 上下文里直接读没在 inject 里声明的服务会抛
cannot get property ... without inject,属性访问永远是死的。
非粘贴渠道
标注浏览器、划词询问这类插件是用 setDraft / insertText 写草稿的,不触发 paste,所以另有一条通道:比对前后两次草稿,认出「这一次新塞进来的那一段」。
这条通道没有字数/行数阈值:凡是外部渠道传进来的非空白文本,一律成胶囊。 粘贴仍保留 400 字阈值(短粘贴保持原文,避免粘个链接也变胶囊)。
怎么区分「外部传进来的」和「用户自己打的」:真实键入一定先触发 beforeinput,外部 setDraft 不会。所以 beforeinput 之后 500ms 内的草稿变化算用户输入,不接管;超出这个窗口且不是用户输入的插入,一律折叠。
三道保护,缺一个都会误伤:
| 保护 | 为什么 |
|---|---|
| 挂载后 1200ms 预热窗口内不接管 | 会话切换/页面加载会"恢复"草稿,那段文本和外部插入长得一模一样 |
| 用户输入事件后 500ms 内不接管 | beforeinput / input / keydown / compositionstart / compositionend 任一命中都算。只监听 beforeinput 不够:输入法组合提交有时不发它,实测漏了 7 次,产出「粘贴文本 · 1 字」这种胶囊 |
| 少于 8 个字的插入不接管 | 误判兜底,不是产品阈值。手打/输入法提交一次就是 1~3 字;真实外部渠道塞的是整段注释。日志里 7 次误折叠全是 1 字 |
| 空白插入不接管 | 纯换行/空格没有折叠价值 |
| 草稿里已有胶囊时跳过 | 有 chip 时 clipboard 偏移与 detect 偏移不再一致,硬插会错位替换 |
只处理纯插入(removed === 0) | 替换、删除的量算不准,不接管 |
事件:insertion.detected / insertion.collapsed / insertion.skip(带原因)/ insertion.warmup。
悬停预览
鼠标停在胶囊上,浮层显示「粘贴文本 · 12k 字 · 前 320 字」+ 正文开头,方便确认粘的是什么再发送。
- 定位:胶囊上方,放不下自动翻到下方,并夹在视口内
- 取正文:先用
props.useInput(s => s).occurrences按胶囊序号映射到ref; 拿不到输入状态时退回「标签 → ref」旁路索引(插入时记录) - 离开胶囊 / 滚动即隐藏;浮层
pointer-events: none,不挡点击 - 事件:
tooltip.show(带via= occurrences / label / none,以及hit)
样式
- 胶囊外圈:注入一段
[data-composer-chip="paste-chip"]的 CSS——最大宽度 240px、超出省略、圆角、淡蓝描边,悬停时描边加深。内部图标与文字是 core 渲染的,插件只能改外层容器,不做过度覆盖。 - 悬停浮层:渐变深色卡片 + 8px 毛玻璃、12px 圆角、标题行(竖条 + 标签 + 字数徽标)、等宽正文、底部「仅预览前 N 字 · 发送时展开全文」,120ms 位移淡入。
- 提示条(超限拒绝/插入失败):琥珀色左侧强调条,不再是裸内联样式。
- 常驻预览的 ✕:标题行最右侧 18px 描边圆角小按钮,收起卡片即
unpinPreview('close')。 Esc 与点外部走dismiss,两者刻意分开:只有 ✕ 会被联动方当成「主动关闭」。
三种打开方式
| 入口 | 状态 | 说明 |
|---|---|---|
| 悬停胶囊 | 已实现 | 停留 180ms 显示,离开 60ms 收起 |
| 点击胶囊 | 已实现 | 常驻预览:可点「复制全文」,右上角 ✕ / Esc / 点外部关闭;触屏唯一的入口 |
| 工具行「粘贴 N」 | 已实现 | 键盘 Tab 可达;点开列出每条粘贴(标签 + 失效标记),点条目打开常驻预览 |
点击预览走的是 InputTriggerSource.openReference:正文命中就接管(返回 true),正文已失效则返回 false 不抢点击。
托盘是查看器,不是草稿对象:不做删除、不做排序——插件没有按条删除胶囊的 API,给了按钮也是假的。
悬停预览里的「上下文」
浮层不只有正文前缀,还有一行上下文:
…上一句结尾 40 字 ▍此处 下一句开头 40 字…
数据来自 InputState.draft(clipboard 投影)与 occurrences[].offset/length,切片即得;取不到时不渲染这一行。
另外:core 在胶囊上挂了 title={label},会和浮层形成双 tooltip,插件在悬停时会把这个 title 清掉。
UX 规格
交互说明见 docs/UX-SPEC.md(页面层级、核心流程、组件三态、状态转移表、文案、字号间距、动效、19 条属性风险表)。可复测的样式对照:docs/chip-preview.html。
已按规格落地的部分:
| 项 | 改动 | 依据 |
|---|---|---|
| 胶囊外观 | 外层 display:inline-block,让 17px 行内盒包住 core 的 24px 内层,收起贴字形的描边;10% 品牌色填充 + 28% 外扩描边(悬停 46%);同时删掉在 inline 下无效、切 inline-block 后会改基线的 max-width/overflow/text-overflow/white-space | 实测基线不动、文字对比度 4.91:1,过 WCAG AA |
| 失效态 | 正文取不到时给胶囊打 data-paste-chip-lost,变成琥珀色 | 原来只有发送那一刻才炸 |
| 浮层 | z-index: 9999 → 60(低于侧栏 100、高于吸底 7/8);去掉 backdrop-filter;改用 token 颜色与 140ms cubic-bezier(.16,1,.3,1) | 长期渐变更融进界面,模糊拖慢首帧 |
| 悬停意图 | 停留 180ms 才显示,离开 60ms 宽限 | 日志里 2.2s 内触发过 7 次,属重复触发 |
| 提示条 | 改为 absolute; bottom:100% 浮在卡片上沿,不再压草稿首行;回退(内容没丢)用中性色,只有超限用警告色 | 回退是成功降级,不该报警 |
仍待人工验证:display:inline-block 在真实 Lexical 里的光标与 IME 行为(盒模型/基线已在无头 Chrome 实测)。若出现光标跳动,回滚只需删掉 display:inline-block 这一行——其余样式在 inline 下仍然生效。
日志
三条路径,各有各的用途:
| 路径 | 谁看 | 内容 |
|---|---|---|
| 浏览器控制台 | 你 | 每条事件一行,带 [dsh-paste-chip] 前缀 |
window.__dshPasteChip | 你在 devtools 里查 | 版本、拦截次数、最后一次结果、最近事件数 |
| 落盘 JSONL | 我直接读,不用你转述 | $DSH_HOME/logs/dsh-paste-chip.log(默认 ~/.dsh/logs/) |
客户端在渲染进程里没有文件系统权限,所以它把事件缓冲后批量 POST /api/dsh-paste-chip/log,宿主半写盘(lib/index.js)。单文件 4MB 轮转一份 .1;DSH_HOME 不可写时退到临时目录。
事件清单:
| 事件 | 含义 |
|---|---|
host.boot | 宿主半加载了;文件不存在 = 宿主半根本没加载 |
client.loaded | 客户端半执行了,带版本与三个服务是否可用 |
source.registered / slot.registered | 引用来源、槽位挂载成功 |
slot.session | 会话 actx 是否解析到(actxResolved) |
host.mount / scope.resolve | 组件挂载、编辑器作用域怎么找到的(self / document / none) |
paste.seen | 编辑器里看见了粘帖,带字符数与是否该折叠 |
paste.outside-scope / paste.no-channel | 拦错了地方 / 通道缺失 |
paste.outcome | 最终结果:chip / fallback / refused |
codec.serialize / codec.clipboardText | 发送时展开正文,带 hit 与字符数 |
storage.full | localStorage 配额打满 |
window.error / window.unhandledrejection | 未捕获异常 |
log.reject | 客户端日志格式坏了(宿主侧记) |
查法:
tail -f ~/.dsh/logs/dsh-paste-chip.log # 追随
grep '"ev":"paste.outcome"' ~/.dsh/logs/dsh-paste-chip.log | tail
grep '"ev":"host.boot"' ~/.dsh/logs/dsh-paste-chip.log # 宿主到底加载没有
curl -s http://127.0.0.1:<port>/api/dsh-paste-chip/log # 只读状态:路径与体积
验证
node test/harness.mjs # 15/15:替身 DOM/React/fetch 驱动真实 lib/client.js
node test/host.mjs # 7/7:真 fs 验证落盘、轮转、坏 JSON 留痕
覆盖:阈值边界(1199/1200、19/20 行)、折叠写入与按 ref 取回、插入失败回退纯文本、超限拒绝、DOM 只拦长文本、bail 的 payload 契约、serialize 正常展开与缺失时报错、槽位与来源注册。
未验证(需要真机):胶囊在真实 UI 里的渲染位置与点击行为、与其他插件的 paste 竞争。
已知风险
已修:胶囊标签被二次折叠导致「发出的是标签」
0.1.18 之前,粘贴折叠完成后,草稿里出现的标签本身又会被「外部渠道写入」的观察器认成一次新插入,于是被再包一层胶囊。结果发送时展开出来的是标签文本(实测 1041 字的粘贴只发出 24 个字符)。
现在观察器会跳过三类不该接管的变化:自己生成过的标签(精确匹配)、形如标签的短文本(形状兜底)、以及任何刚打过字的变化。
/菜单:为了进 roster 必须注册一个trigger: '/'的来源,candidates()返回空、showGroupTitle: false。理论上不渲染任何行,但没在真机上确认过是否会留下一个空组。- 作用域事件:
slash/input-insert-reference目前由内置 trigger 插件 bail;第三方插件调用同一条通道在 0.2.1-rc.2 上成立(payload 契约已对齐),但宿主改版可能变。 - DOM 定位:优先从自己节点向上找
[data-lexical-editor],找不到退化为页面里第一个 Lexical 编辑器;两者都没有就完全不拦(宁可不生效,也不误伤别的输入区)。
卸载
删掉上面三处(依赖 / dsh.profile.bundles 条目 / cordis.patch.yml 行),pnpm install,重启客户端。草稿里残留的胶囊会变成失效态(正文仍在 localStorage 的 dsh-paste-chip.v1.* 下,可手动清理)。
目录
lib/index.js 宿主半(空实现)
lib/client.js 客户端半:拦截、存储、胶囊、展开
cordis.patch.yml 插件挂载声明
test/harness.mjs 客户端逻辑离线验证
test/host.mjs 宿主半落盘验证
docs/UX-SPEC.md UX 交互说明(层级 / 流程 / 三态 / 文案 / CSS 规格)
docs/chip-preview.html 样式对照页(可复测)
Plugins relacionados
dsh-web (dsh-task-board)
zhu1090093659/dsh-web
dsh-web (dsh-web-all)
zhu1090093659/dsh-web
dsh-web-ui (dsh-task-board)
zhu1090093659/dsh-web-ui
dsh-web-ui (dsh-web-ui-all)
zhu1090093659/dsh-web-ui