dsh-client-ui-quote 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,8 +16,9 @@
16
16
  | **引用清单** | 右侧栏的一个**页面**:本会话发出去的引用与评论的清单。搜索框按引用/评论过滤、点一条跳回原文、`导出 Markdown` 落一个文件、历史没读完时可 `加载更早的消息`。它作为**页面类型**注册进右侧栏(`ctx.sidebarRightTabs.register`),并在指南页留下一个胶囊作入口;`Alt+L` 同样能打开。数据来自会话自己的事件日志(`ctx.sessions.retain` → `session.eventSource`),不依赖屏幕上有没有那条消息。 |
17
17
  | **侧边线程回流** | 同一个页面底部是这条会话的**侧边线程**:显示它最后问的那个问题与消息条数,并给两个动作——**`带回主会话`** 把线程最后「问题 + 答案」写成主会话输入框里的草稿(**不发送**,你自己按发送),**`导出 Markdown`** 把整条线程落成一个文件。线程内容同样从它自己的事件日志读;没有线程时这一节只说明「还没有侧边线程」。 |
18
18
  | **改这条引用** | 悬停卡片里的 `改这条引用`:把那条引用当初的**引用与评论都原样放回评论框**(引用正文、评论各占一个输入框,都能改),你改完按 `⏎` 就发出一条**新的**引用(DSH 没有编辑已发送消息的 API,这是唯一诚实的做法)。旧胶囊随即挂上 **`已修订`** 标记,卡片里直接显示改后的评论;**连正文一起改过的**,卡片里也把改后的原文亮出来。标记按引用原文记在本机,跨刷新仍在。 |
19
+ | **逐条回应** | 评论框里的一格**勾选项**(默认关):勾上,这条引用的块尾就多一行**只有模型读得到的指令**——请按出现顺序把引用记为「引用1」「引用2」…并在回复中逐条对应。模型的回复里每个 `「引用N」` 会变成一枚**小徽章**:悬停就是那条引用的完整卡片(含 `跳回原文` 与 `改这条引用`),点了就跳;编号超出这条消息引用数的标签保持纯文本。指令行与围栏一样只是**线上形态**,胶囊、卡片、导出里都不出现。 |
19
20
  - 引用以 DSH 原生行内原子节点 `reference-chip` 插入(带图标的胶囊);走「评论」时评论一起放进这颗芯片(芯片标签显示为 `引用摘要 · 评论摘要`),引用与评论始终是一体,草稿里不会散成两段。
20
- - **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~` 围栏。
21
+ - **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~` 围栏。**文件预览的引用还带着它出自哪个文件、哪些行**:宿主标了路径(预览头部的路径标签,或 DOM 上的路径属性)时就写进围栏的信息位——`> ❝ ``` src/app.ts#L12-L48`;预览按行渲染时连行号一起带上(GitHub 的 `#L` 写法)。模型由此可以直接打开这个文件、翻到这几行动手,清单导出的围栏行同样带着它;探测不到就维持原样,什么也不损失。
21
22
  - **引用正文可以编辑**:评论框上半格是引用自己的输入框(评论在下半格),划选带进了提示符噪音或多选了几行,当场裁掉再发;「改这条引用」时旧正文回填同一格,改完随新引用发出。
22
23
  - 发送时由 DSH 回调本插件取「模型形态」:**`> ❝ …` 引用行 + 同一个块里的 `> ❞ …` 评论行**。消息里那段块会被就地换成 Kimi 那样的一颗胶囊
23
24
  (`❝ 引用摘要 │ 评论摘要`,鼠标移上去就能读到引用与评论的全文、点开可展开、悬停出现 `复制`),你自己写的话保持原样,模型收到的仍是完整引用文本。
@@ -52,18 +53,23 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
52
53
  name: dsh-client-ui-quote
53
54
  ```
54
55
 
55
- ### 方式三:npm 包(发布通道已就位,待 NPM_TOKEN)
56
+ ### 方式三:npm 包(已上架)
56
57
 
57
- `package.json` 已经是可发布状态:`private` 已移除、`files` 白名单只带 `lib` / `locale` / `test` / `icon.svg` / `cordis.patch.yml` / 文档,`prepublishOnly` 会在发布前跑完 253 项离线断言,`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
58
+ 已发布到 npm:`dsh-client-ui-quote@0.11.1`(0.10.0 起上架)。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 290 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
58
59
 
59
- **发布有两条路**(都只差一个 npm Access Token):
60
+ 装:
60
61
 
61
- 1. **GitHub Actions(推荐)**:`.github/workflows/publish.yml` 已就位——往仓库里配一个 secret `NPM_TOKEN`(npmjs.com → Access Tokens → Generate New Token,选 **Automation** 类型;或 `gh secret set NPM_TOKEN --repo BangBang-03/dsh-client-ui-quote`),然后推 `v*` 标签或在 Actions 页手动 Run workflow 即发布。之后的版本:改版本号 → 打 `vX.Y.Z` 标签推上去,发布全自动。
62
- 2. **本机 npm**:`npm login && npm publish`——注意本机直连 `registry.npmjs.org` 不可达(系统 DNS 返回污染地址、真实 Cloudflare IP 也握手超时),要挂一个能通 npmjs 的代理再发。
62
+ ```bash
63
+ dsh plugin --profile desktop add dsh-client-ui-quote
64
+ ```
63
65
 
64
- 装:`dsh plugin --profile desktop add dsh-client-ui-quote`。npmjs 侧发布后 npmmirror 一般 10 分钟内同步,DSH 若配置了镜像源即可国内直装。
66
+ npmjs 上架后 npmmirror 一般 10 分钟内同步,DSH 配置了镜像源即可国内直装。
65
67
 
66
- 在那之前请用方式一或方式二。
68
+ **发布运维备忘**(给未来的自己):
69
+
70
+ - 发布用的是 90 天期 granular token(勾选 **Bypass 2FA**——npm 2026-08 起强制 CI 直发必须带这个标记),**2027-01-09 到期**。到期前重签:npmjs.com → Access Tokens → Generate New Token → Granular → 勾 Bypass 2FA、Read and write (publish and stage)、All packages、90 days → 生成后 `gh secret set NPM_TOKEN --repo BangBang-03/dsh-client-ui-quote`(gh 需已登录)。
71
+ - npm 对**新账号的首包**有安全审核 hold:发布成功后包页会短暂显示 `0.0.0-stage` 占位,属于正常现象,审核自动放行(本包 0.10.0 实测约 1 小时内过审),无需人工操作。
72
+ - npm 官方的长期方向是 Trusted Publishing(OIDC 直发):2027-01 废弃 bypass-2FA 直发之前,建议在包 Settings → Trusted Publisher 登记 `BangBang-03/dsh-client-ui-quote` + `publish.yml`,并在 workflow 里加 `id-token: write` 权限与 `--provenance` 参数。
67
73
 
68
74
  ### 卸载
69
75
 
@@ -73,7 +79,7 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
73
79
 
74
80
  1. 在会话正文、**文件预览**或**终端**里**拖动选中**一段文字(后两者没有自己的会话 id,引用会落进当前屏幕上那条会话,并且以**围栏代码块**的线格式发给模型;输入框内的选区属于改稿,不触发)。
75
81
  2. 选区**下方**浮出浮层(下方空间不够时才改到上方),选择 **评论**、**添加到对话** 或 **侧边对话**。同一组动作也有键位:**`Alt+Q`** 直接引用、**`Alt+C`** 直接开评论框、**`Alt+B`** 直接开侧边对话(都在设置里可改;没有选区时按键原样让给其它快捷键)。
76
- 3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**),页面选区被清除、焦点交给输入框,可以接着写正式提问。
82
+ 3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**;评论框里还有一格 **`让模型逐条回应`** 勾选项,勾上这条引用就带着逐条指令发出去),页面选区被清除、焦点交给输入框,可以接着写正式提问。
77
83
  4. **侧边对话**写的是另一条会话的输入框:右侧栏打开该线程的页签(标题取引用首行),引用以纯块引用形态落在它的草稿里,**不发送**;你接着在这条线程里写自己的问题、自己按发送。同一条主会话只会有一条侧边线程,第二次点第三项就把新引用接在草稿后面。同一会话上一次请求还在进行时,再次点击会并入第一次,不会开出两条重复线程。
78
84
  5. 浮层收起条件:单击别处、选区清空、按 `Esc`、滚动会话、窗口尺寸变化,以及动作完成之后。双击选词不会把浮层关掉。
79
85
  6. 发送出去以后,消息里那段块会显示成一颗**胶囊**:收起时是单行 `❝ 引用摘要 │ 评论摘要`,**鼠标移上去**会浮出一张卡片显示完整的引用与评论
@@ -129,6 +135,12 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
129
135
  模型读到块引用里一个真正的围栏代码块;`splitQuoteRuns` 解析时把首行的围栏(和收尾那一行)剥掉,胶囊拿到的仍是原始代码文本,
130
136
  渲染加 `data-code="true"`(等宽、`pre`、展开时横向滚动),卡片加 `dshq-pop-code`,侧边对话落进子线程草稿的则是不带标记的裸围栏;
131
137
  正文本身含 ``` 时序列化自动换 `~~~` 围栏。没有围栏的块照旧按普通引用解析,新旧消息互不影响;
138
+ **预览引用的文件路径与行号**走的是围栏自带的信息位(`> ❝ ``` src/app.ts#L12-L48`)——Markdown 里这本来就是「渲染器不显示、模型读得到」的位置,
139
+ `sanitizePath` 把带空白/围栏字符的伪路径挡掉。探测按两种宿主形状来:先在预览根(`[data-document-preview]`)里找宿主自己的路径标签
140
+ (`[data-textpreview-path]`,完整路径在它的 `title` 里——标签在头部,与选区所在的 body 是兄弟分支),再退回「宿主或其祖先上的路径属性」;
141
+ 行号取自 `data-textpreview-line` 标记的行元素(`withLineRange` 拼成 GitHub 的 `#L` 写法,起止顺序自动归一)。两种都没命中就原样发;
142
+ - **逐条回应的指令行**同样只是线上形态:勾了评论框里的开关,块尾多一行 `> ※ 指令:…`,`splitQuoteRuns` 按 `※ 指令` 前缀把它从块里剥掉,
143
+ 所以胶囊、卡片、清单导出全都看不到它(用户自己的评论/引用恰好以 `※ 指令` 开头的概率,比它带来的清晰收益小得多,已记入已知限制);
132
144
  - 客户端插件用 `MutationObserver` 找「只含一个文本子节点、内部有 `❝`、位于 `[data-conversation-content]` 里」的那个 run,
133
145
  把整条消息拆成 `前文 / 引用 / 中间文字 / 引用 / 后文` 这样一串分段(`splitQuoteRuns`),给原 run 加 `display:none`,
134
146
  在它**前面**按顺序插入自己的元素:每段引用一颗胶囊,每段用户自己的话一个 `span.dshq-body`(保持 `pre-wrap`)——
@@ -147,6 +159,15 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
147
159
  卡片跟着锚点活:锚点一旦离开文档(发消息会把芯片拿走,而被移除的节点不会再发 `mouseleave`)卡片立刻收起,
148
160
  React 重渲染换掉胶囊时同理 —— 不会留下一张浮在空处的卡片。
149
161
 
162
+ ### 逐条回应的回复徽章
163
+
164
+ 指令行只是让模型**肯按编号回答**;让回答**连得回来**的是另一套覆盖层。模型按指令把每条回应标成 `「引用1」…「引用N」` 之后:
165
+
166
+ - 徽章增强器在**同一条会话面板**里找这条回复之前**最近的一条带胶囊的消息**(一次树序遍历同时解决「最近」和「在前」:走到徽章文本节点之前最后记账的那条 run 就是它),把它发出去的引用按顺序编号——第 N 颗胶囊就是「引用N」。
167
+ - 回复文本里的 `「引用N」` 于是被切成一枚 `span.dshq-ref` 徽章(**原文一个字不改**,只是套上底色和悬停行为);编号超出那条消息引用数的标签(模型数错了,或有人在正文里自己写了「引用1」)保持纯文本——一枚点了没反应的徽章比没有徽章更糟。
168
+ - 徽章复用悬停卡片:`mouseenter` 就是那张卡(引用、评论全文,`跳回原文` 与 `改这条引用` 按坐标有无自动出现),点击等同清单页里点一行——有坐标就跳,没坐标无声。切分文本节点比胶囊那套「只隐藏、再旁边画」更进一步,规则也补得更紧:一个文本节点只切一次(账本记着原文);React 重写了文本(值不再是账本里的首块)就整体撤掉重切;卸载时原文放回。这层只挂在**助手消息**的文本上——用户自己消息里的「引用1」是草稿的一部分,谁也不该动。
169
+ - 侧边线程(第三项)的围栏同样带路径(``` src/app.ts 的裸围栏),子线程的模型由此也知道代码出自哪个文件。
170
+
150
171
  ### 侧边对话(第三项)
151
172
 
152
173
  第三项是本插件自己的扩展(Kimi 的浮层只有两项),融合了三家 agent 同类组件的优点:**Kimi Code 的 `/btw` 侧边聊天**——一键在侧边栏开一条继承主会话上下文的线程;**Qoder 的「在侧边任务中提问」**——同一会话进行中的创建请求自动并入第一次,失败只弹一条提示,绝不大张旗鼓;**WorkBuddy 的划选引用**——引用是线程里持久的内容,而不是一次性的草稿附件。与前两家不同的是**问什么由你来写**:引用只落进那条线程的输入框,不会被当成第一条消息替你发出去。样式上第三项用 DeepSeek 的鲸鱼蓝(`#4d6bfe`)画图标与悬停色,白底浅色主题是主基调,深色主题同一色相。
@@ -206,7 +227,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
206
227
  1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
207
228
  2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标不随引用文本发给模型(不污染 prompt)。
208
229
  3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在 4471 条里没有对应物。
209
- 4. **工程化**:253 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
230
+ 4. **工程化**:290 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
210
231
 
211
232
  反过来也要说清楚:**本插件不做的**——不做翻译/解释类选区动作(81 条同类在做)、不做多选合并引用(7 条有)、不做选区折叠/行内 diff 这类渲染花样。它只把「引用」这一件事做穿。
212
233
 
@@ -239,8 +260,8 @@ locale/zh.json 插件页卡片文案(中文)
239
260
  locale/en.json 插件页卡片文案(英文)
240
261
  test/check.mjs 离线自检(清单 + 组件契约)
241
262
  test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
242
- test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用,159 项断言)
243
- test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,94 项断言)
263
+ test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章,194 项断言)
264
+ test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,96 项断言)
244
265
  test/capsule.html 真浏览器里跑同一套胶囊断言(可选)
245
266
  ```
246
267
 
@@ -287,10 +308,10 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
287
308
  卡片给出 `跳回原文` 与 `改这条引用` 两个动作、点后者把栏切到评论模式并**预填旧评论与旧正文**、
288
309
  按确认后 `insertReference` 恰好一次(新芯片的 `ref` 带改后的评论)、旧引用被记为「已修订」并落进 `localStorage`、
289
310
  旧胶囊挂上 `已修订` 徽标、它的卡片显示改后的评论(连正文改过的连改后原文一起显示)、空修订不算修订、
290
- 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 159 项)。
311
+ 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 194 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用)。
291
312
  `test/capsule.html` 是同一套断言的真浏览器版,用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。
292
313
 
293
- `test/panel.mjs` 单独跑引用清单页与侧边线程回流(94 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
314
+ `test/panel.mjs` 单独跑引用清单页与侧边线程回流(96 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
294
315
  引用按原文逐行拆出来而评论跟着同一条记录、最新一条排最前、空查询保持全量、引用与评论都能被搜到且忽略大小写、
295
316
  Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超长引用在标题里被截断、非法时间导出为空;
296
317
  读取侧用桩 `sessions.retain` 真跑一遍——读出两行且 `hasMore` 透传、**读完必须 release**(记录 `source: "quoteList"`)、
@@ -306,7 +327,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
306
327
  `bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
307
328
  `exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
308
329
 
309
- 合计 **253 项**(check.mjs 的契约断言 + capsule 159 + panel 94),`npm test` 全绿。
330
+ 合计 **290 项**(check.mjs 的契约断言 + capsule 194 + panel 96),`npm test` 全绿。
310
331
 
311
332
  ## 已知限制
312
333
 
@@ -318,6 +339,9 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
318
339
  线程创建要求父会话处于运行态(历史归档会话会收到 409,同样落 toast)。
319
340
  - **「改这条引用」是追加,不是原地编辑**:DSH 的 transcript 只追加(侦察确认全树没有 `editMessage` / `updateMessage` / `rewriteMessage` / 回收 API,只有**尚未进入会话**的队列条目可改)。所以修订发出一条新引用(评论、引用正文都可改),旧胶囊只挂 `已修订` 标记——标记存在**本机** `localStorage`(`dsh-client-ui-quote:v1:quote-revised`,按引用原文归一键、上限 60 条),因此不会跨标签页、跨设备同步,也不进入模型上下文;模型看到的是那条改后的新引用。正文改过后键仍是旧原文:`已修订` 挂在**旧**胶囊上(它的卡片里显示改后的原文与评论),改后的新胶囊不挂标。
320
341
  - **代码引用的识别靠首行围栏**:`> ❝ ``` ` 开头的块按代码解析(围栏剥掉、胶囊等宽)。因此一条**普通**引用如果正文恰好以 ``` 或 `~~~` 开头(比如在 Markdown 文件预览里引用了源码),回读时也会被当成代码引用——显示上是等宽排版,文字不丢。
342
+ - **逐条回应的指令行按 `※ 指令` 前缀剥离**:剥离规则收得很窄,但一条引用或评论如果恰好以 `※ 指令` 开头,回读时那一行会被当成指令剥掉(胶囊里看不到它)。要让这样的内容活着,别勾逐条回应即可——指令行只在勾选时才写入。
343
+ - **文件预览的路径与行号是探测来的**:路径优先读宿主预览头部的路径标签(`[data-textpreview-path]` 的 `title`,实测 0.2.0-rc.2 的侧栏文档预览就是这个形状),其次探一组候选属性;行号只在预览按行渲染时可得(`data-textpreview-line`,纯文本视图有、高亮代码视图没有)。两者都拿不到就不带(引用形态与从前完全一样);终端来源的引用没有路径可言。路径与行号写进围栏信息位,`sanitizePath` 把带空白或围栏字符的值挡掉。
344
+ - **回复徽章依赖模型守格式**:模型没按指令输出 `「引用N」` 就没有徽章——无损,只是少了联动;输出越界的编号(数错了)保持纯文本。徽章的编号以回复**之前最近的一条带胶囊的消息**为准;切分的是助手消息的文本节点,DSH 若改了助手消息的渲染形态,徽章增强器要跟着调(与胶囊的 `isQuoteRun` 同一条既有限制)。
321
345
  - 依赖 DSH 客户端的内部接口(`conversation.input.overlay` 槽位、`inputTriggers.registerSource`、`conversation.input.shell(id).insertReference`、
322
346
  `reference-chip` 节点),DSH 升级有可能失效。
323
347
  - **引用清单页依赖右侧栏的两个服务**(`sidebarRightTabs` 与 `sidebarRight`,实测 0.2.0-rc.2);宿主没有它们时页面不注册、`Alt+L` 给一条 toast
@@ -355,3 +379,8 @@ and it closes with the chip as soon as the message is sent. Each chip resolves i
355
379
  reuses a chip node for the next quote, so a card can never show the previous one's text.
356
380
  Install with `dsh plugin --profile desktop add link:<clone-path>` (DSH >= 0.2.0-rc.2, web/desktop client).
357
381
  Test with `npm test`. MIT licensed.
382
+
383
+ Two wire-only extras ride on the same pipeline: a per-quote reply toggle in the comment box (the block carries a
384
+ transport-only instruction, the model answers `「引用N」` by `「引用N」`, and each label becomes a badge that reopens
385
+ the quote's hover card), and a preview quote that puts its file into the fence's info string so the model can open
386
+ the file itself.
package/lib/client.js CHANGED
@@ -83,7 +83,8 @@ window.__ModuleLoader__.load({
83
83
  threadBackFailed: "没读到这个线程的内容",
84
84
  threadCount: "{n} 条消息",
85
85
  revise: "改这条引用",
86
- revised: "已修订"
86
+ revised: "已修订",
87
+ replyToggle: "让模型逐条回应"
87
88
  };
88
89
 
89
90
  /** English copy, verbatim from Kimi Code's `selection` namespace. */
@@ -119,7 +120,8 @@ window.__ModuleLoader__.load({
119
120
  threadBackFailed: "Could not read that thread",
120
121
  threadCount: "{n} messages",
121
122
  revise: "Revise this quote",
122
- revised: "Revised"
123
+ revised: "Revised",
124
+ replyToggle: "Ask for per-quote replies"
123
125
  };
124
126
 
125
127
  /* ------------------------------------------------------------------ *
@@ -150,6 +152,35 @@ window.__ModuleLoader__.load({
150
152
  */
151
153
  const COMMENT_MARK = "\u275E";
152
154
 
155
+ /**
156
+ * The mark that opens the transport-only reply instruction line. When the
157
+ * comment box's 逐条回应 toggle is on, the block ends with one more
158
+ * blockquote line starting with this mark; the model reads it and answers
159
+ * per quote (`「引用1」…「引用N」`), while `splitQuoteRuns` strips the line
160
+ * on the way back out — a capsule, a card and the export never show it.
161
+ * The `指令:` phrase after the mark is part of the strip rule, so a line
162
+ * that happens to start with a bare `※` is left alone.
163
+ */
164
+ const REPLY_MARK = "\u203B";
165
+ const REPLY_INSTRUCTION =
166
+ "指令:请把本条消息里的引用块按出现顺序记为「引用1」「引用2」…,回复时逐条对应回应,每条回应前标注「引用N」。";
167
+
168
+ /**
169
+ * Attributes a file preview may carry its path in. The preview DOM is a
170
+ * host detail this plugin does not own, so the lookup walks a few ancestors
171
+ * and takes the first hit; when none matches, the quote travels without a
172
+ * path — exactly the shape it had before the probe existed.
173
+ */
174
+ const PREVIEW_PATH_ATTRS = [
175
+ "data-textpreview-path",
176
+ "data-preview-path",
177
+ "data-document-path",
178
+ "data-file-path",
179
+ "data-path",
180
+ "data-file",
181
+ "data-filename"
182
+ ];
183
+
153
184
  /** Transcript host: a selection outside it is none of our business. */
154
185
  const CONTENT_SELECTOR = "[data-conversation-content]";
155
186
  /** The composer card: a selection inside it is a draft edit, not a quote. */
@@ -300,6 +331,10 @@ background:transparent;color:inherit;font:inherit;cursor:default}
300
331
  #${BAR_ID} .dshq-btn-primary:hover{background:var(--dshq-accent);opacity:.9}
301
332
  #${BAR_ID} .dshq-btn:disabled{opacity:.45}
302
333
  #${BAR_ID} .dshq-enter{margin-left:4px;opacity:.65}
334
+ #${BAR_ID} .dshq-reply{display:flex;align-items:center;gap:6px;font-size:11.5px;line-height:1.5;
335
+ opacity:.78;cursor:pointer;user-select:none}
336
+ #${BAR_ID} .dshq-reply:hover{opacity:1}
337
+ #${BAR_ID} .dshq-reply-check{margin:0;accent-color:var(--dshq-accent,currentColor)}
303
338
  #${TOAST_ID}{position:fixed;z-index:2147483001;display:none;padding:4px 10px;border-radius:6px;
304
339
  background:rgba(24,24,27,.92);color:#f4f4f5;font-size:12px;line-height:1.5;pointer-events:none;
305
340
  -webkit-app-region:no-drag}
@@ -334,6 +369,9 @@ text-overflow:clip;overflow-wrap:anywhere}
334
369
  .dshq-cap[data-expanded="true"] .dshq-cap-comment{flex:1 1 100%;max-width:none;margin-top:5px;
335
370
  padding-left:9px;white-space:pre-wrap;overflow:visible;text-overflow:clip;overflow-wrap:anywhere}
336
371
  .dshq-body{display:block;white-space:pre-wrap;overflow-wrap:anywhere}
372
+ .dshq-ref{display:inline;padding:0 4px;border-radius:6px;cursor:pointer;color:inherit;font-weight:600;
373
+ background:rgba(127,127,127,.12);background:color-mix(in srgb,currentColor 9%,transparent)}
374
+ .dshq-ref:hover{background:rgba(127,127,127,.2);background:color-mix(in srgb,currentColor 16%,transparent)}
337
375
  /* Hover card: a collapsed pill keeps one clipped line per half and the chip in
338
376
  the composer is clipped even harder, so hovering either one shows the quote and
339
377
  the comment in full. It is parked on <body> with position:fixed because a chat
@@ -447,12 +485,83 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
447
485
  * ------------------------------------------------------------------ */
448
486
 
449
487
  /**
450
- * @returns {{ text: string, x: number, y: number, bottom: number, sessionId: string, hostKind: "chat" | "preview" | "terminal" } | null}
488
+ * @returns {{ text: string, x: number, y: number, bottom: number, sessionId: string, hostKind: "chat" | "preview" | "terminal", path: string | null } | null}
451
489
  * the selected text plus its viewport anchor, or null when the current
452
490
  * selection is not a quotable transcript selection. `hostKind` says where
453
491
  * the selection lives: a preview/terminal quote is code and travels in a
454
- * fenced block, a chat quote stays a plain blockquote.
492
+ * fenced block, a chat quote stays a plain blockquote. `path` is the file
493
+ * a preview selection came from, when the host exposes one.
494
+ */
495
+ /**
496
+ * A path is only worth sending when it reads as one: no whitespace (the
497
+ * fence info string cannot carry it), no fence characters, not absurdly
498
+ * long. Anything else degrades to no path at all.
455
499
  */
500
+ function sanitizePath(value) {
501
+ const path = String(value === null || value === undefined ? "" : value).trim();
502
+ if (path === "" || path.length > 200 || /\s|[`~]/u.test(path)) return null;
503
+ return path;
504
+ }
505
+
506
+ /**
507
+ * Path + line range as one fence-friendly token. The range rides in
508
+ * GitHub's `#L12-L48` spelling — the one shape every developer (and every
509
+ * model) already reads. An unusable path costs the whole thing; a missing
510
+ * or one-sided range costs only the suffix.
511
+ */
512
+ function withLineRange(path, start, end) {
513
+ const clean = sanitizePath(path);
514
+ if (clean === null) return null;
515
+ const from = Number.isSafeInteger(start) && start >= 1 ? start : null;
516
+ const to = Number.isSafeInteger(end) && end >= 1 ? end : null;
517
+ if (from === null || to === null) return clean;
518
+ if (from === to) return clean + "#L" + from;
519
+ return clean + "#L" + Math.min(from, to) + "-L" + Math.max(from, to);
520
+ }
521
+
522
+ /**
523
+ * The file a preview selection came from, when the host exposes it. Two
524
+ * shapes are probed, in order:
525
+ *
526
+ * 1. The host's own preview root carries a `PathLabel` —
527
+ * `[data-textpreview-path]`, a boolean flag whose element holds the
528
+ * full path in its `title` (and in its text). This is the real shape
529
+ * of the sidebar document preview; the label sits in the header, a
530
+ * sibling of the body the selection lives in.
531
+ * 2. A path-shaped attribute on the host or its ancestors — for any other
532
+ * preview host that marks the path as data.
533
+ *
534
+ * A miss is the normal case for an unknown host and costs nothing: the
535
+ * quote then travels exactly as before.
536
+ */
537
+ function previewPathOf(host) {
538
+ if (typeof host.closest === "function") {
539
+ // Two plain `closest` calls rather than one comma list: the wire only
540
+ // ever needs one root, and a comma is one selector-engine feature
541
+ // the offline shim does not have to grow.
542
+ const root = host.closest("[data-document-preview]") ?? (host.closest("[data-textpreview-state]") ?? null);
543
+ if (root !== null && typeof root.querySelector === "function") {
544
+ const label = root.querySelector("[data-textpreview-path]");
545
+ if (label !== null) {
546
+ const title = typeof label.getAttribute === "function" ? label.getAttribute("title") : null;
547
+ const clean = sanitizePath(typeof title === "string" && title !== "" ? title : label.textContent);
548
+ if (clean !== null) return clean;
549
+ }
550
+ }
551
+ }
552
+ let node = host;
553
+ for (let hop = 0; node !== null && node !== undefined && hop < 6; hop += 1, node = node.parentElement) {
554
+ if (typeof node.getAttribute !== "function") continue;
555
+ for (const attr of PREVIEW_PATH_ATTRS) {
556
+ const value = node.getAttribute(attr);
557
+ if (typeof value !== "string") continue;
558
+ const clean = sanitizePath(value);
559
+ if (clean !== null) return clean;
560
+ }
561
+ }
562
+ return null;
563
+ }
564
+
456
565
  function readSelection() {
457
566
  const selection = document.getSelection === undefined ? null : document.getSelection();
458
567
  if (selection === null || selection.rangeCount === 0 || selection.isCollapsed) return null;
@@ -492,6 +601,20 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
492
601
  const rect = range.getBoundingClientRect();
493
602
  if (rect.width === 0 && rect.height === 0) return null;
494
603
 
604
+ // A preview selection may carry the file it came from — and, when the
605
+ // host numbers its lines (`data-textpreview-line`), the exact range:
606
+ // both ride in the fence's info string so the model can open the file
607
+ // at the very lines it is looking at.
608
+ let path = null;
609
+ if (hostKind === "preview") {
610
+ const lineOf = (node) => {
611
+ const row = closestFrom(node, "[data-textpreview-line]");
612
+ const value = row === null ? Number.NaN : Number(row.getAttribute("data-textpreview-line"));
613
+ return Number.isSafeInteger(value) && value >= 1 ? value : null;
614
+ };
615
+ path = withLineRange(previewPathOf(host), lineOf(range.startContainer), lineOf(range.endContainer));
616
+ }
617
+
495
618
  return {
496
619
  text,
497
620
  x: rect.left + rect.width / 2,
@@ -499,6 +622,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
499
622
  bottom: rect.bottom,
500
623
  sessionId,
501
624
  hostKind,
625
+ path: typeof path === "string" ? path : null,
502
626
  // Only a transcript selection has a row to jump back to.
503
627
  source: chat === null ? null : chatRowOf(range.startContainer)
504
628
  };
@@ -530,8 +654,18 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
530
654
  * `> ❝ ``` ` / `> …` / `> ``` ` — so the model sees a real code block
531
655
  * while the block (and its capsule) still opens at the ❝ mark. The fence
532
656
  * is transport only: `splitQuoteRuns` strips it again on the way out.
657
+ * A preview quote can carry its file the same way: `meta.path` rides in
658
+ * the fence's info string (`> ❝ ``` src/app.ts`), which is where Markdown
659
+ * keeps metadata the model reads but renderers never show as content.
660
+ *
661
+ * `meta.reply === true` appends one transport line at the block's tail —
662
+ * the 逐条回应 instruction the model answers quote by quote. Like the
663
+ * fence it is wire-only: `splitQuoteRuns` strips it before anything
664
+ * downstream (capsule, card, export) ever sees it.
533
665
  */
534
- function quoteBlock(text, comment, code) {
666
+ function quoteBlock(text, comment, code, meta) {
667
+ const options = meta === null || meta === undefined || typeof meta !== "object" ? {} : meta;
668
+ const path = sanitizePath(options.path) ?? "";
535
669
  const halves = [
536
670
  { mark: QUOTE_MARK, lines: blockLines(text) },
537
671
  { mark: COMMENT_MARK, lines: blockLines(comment) }
@@ -540,7 +674,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
540
674
  for (const half of halves) {
541
675
  if (code === true && half.mark === QUOTE_MARK && half.lines.length > 0) {
542
676
  const fence = half.lines.some((line) => /^\s*```/u.test(line)) ? "~~~" : "```";
543
- block.push("> " + half.mark + " " + fence);
677
+ block.push("> " + half.mark + " " + fence + (path === "" ? "" : " " + path));
544
678
  for (const line of half.lines) block.push(line.trim() === "" ? ">" : "> " + line);
545
679
  block.push("> " + fence);
546
680
  continue;
@@ -551,6 +685,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
551
685
  else block.push(line.trim() === "" ? ">" : "> " + line);
552
686
  }
553
687
  }
688
+ if (options.reply === true) block.push("> " + REPLY_MARK + " " + REPLY_INSTRUCTION);
554
689
  return block.join("\n");
555
690
  }
556
691
 
@@ -594,14 +729,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
594
729
  body.push(lines[index].replace(/^>\s?/u, ""));
595
730
  index += 1;
596
731
  }
732
+ // The 逐条回应 instruction is transport for the model: it rides at
733
+ // the block's tail and never reaches a capsule, a card or the
734
+ // export. The `指令:` phrase keeps the strip narrow — a quote or
735
+ // comment that merely starts with a `※` is left alone.
736
+ const carried = body.filter((entry) => !/^\s*※\s*指令/u.test(entry));
597
737
  const before = loose.replace(/^\s+/u, "").replace(/\s+$/u, "");
598
738
  if (before !== "") segments.push({ type: "text", text: before });
599
739
  loose = "";
600
740
  // `> ❞ …` opens the comment half of the same block: everything from
601
741
  // there down is the comment, everything above it is the quote.
602
- const cut = body.findIndex((entry) => entry.replace(/^\s+/u, "").startsWith(COMMENT_MARK));
603
- let quoteLines = cut === -1 ? body : body.slice(0, cut);
604
- const commentLines = cut === -1 ? [] : body.slice(cut);
742
+ const cut = carried.findIndex((entry) => entry.replace(/^\s+/u, "").startsWith(COMMENT_MARK));
743
+ let quoteLines = cut === -1 ? carried : carried.slice(0, cut);
744
+ const commentLines = cut === -1 ? [] : carried.slice(cut);
605
745
  if (commentLines.length > 0) {
606
746
  commentLines[0] = commentLines[0]
607
747
  .replace(/^\s+/u, "")
@@ -638,9 +778,9 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
638
778
  }
639
779
 
640
780
  /** Quote (+ optional comment) exactly as it should land in the draft. */
641
- function buildPayload(text, comment, code) {
781
+ function buildPayload(text, comment, code, meta) {
642
782
  const prefix = composerText().trim() === "" ? "" : "\n";
643
- return prefix + quoteBlock(text, comment, code) + "\n\n";
783
+ return prefix + quoteBlock(text, comment, code, meta) + "\n\n";
644
784
  }
645
785
 
646
786
  /** Kimi inserts at the caret; the composer answers a collapsed span at the end. */
@@ -667,11 +807,11 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
667
807
  * comment rides in that same ref, which is what lets the transcript keep
668
808
  * the pair in one capsule instead of two unrelated pieces of text.
669
809
  */
670
- function chipRef(text, comment, code) {
810
+ function chipRef(text, comment, code, meta) {
671
811
  // The leading newline puts the block on its own paragraph when the chip
672
812
  // lands after typed text; the send path trims it when the quote is
673
813
  // the whole message.
674
- return "\n" + quoteBlock(text, comment, code) + "\n\n";
814
+ return "\n" + quoteBlock(text, comment, code, meta) + "\n\n";
675
815
  }
676
816
 
677
817
  /** The chip label spells the pair out the way Kimi's `display_text` does. */
@@ -729,7 +869,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
729
869
  * shell owns the editor that can place an atomic node. Every step is
730
870
  * optional — a missing service just falls back to the Markdown form.
731
871
  */
732
- function insertChip(entry, sessionId, text, comment, code) {
872
+ function insertChip(entry, sessionId, text, comment, code, meta) {
733
873
  if (!ensureSource() || pluginCtx === null) return false;
734
874
  let hub = null;
735
875
  try {
@@ -746,7 +886,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
746
886
  shell = null;
747
887
  }
748
888
  if (shell === null || shell === undefined || typeof shell.insertReference !== "function") return false;
749
- const model = chipRef(text, comment, code);
889
+ const model = chipRef(text, comment, code, meta);
750
890
  const attachment = {
751
891
  source: SOURCE_NAME,
752
892
  ref: model,
@@ -1541,7 +1681,9 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1541
1681
 
1542
1682
  const parent = run.parentNode;
1543
1683
  if (parent === null) return;
1544
- capsules.set(run, { text, nodes });
1684
+ // The segments ride along in the ledger: the reply badges need the
1685
+ // numbered quotes of the nearest preceding capsule-carrying message.
1686
+ capsules.set(run, { text, nodes, segments });
1545
1687
  run.style.display = "none";
1546
1688
  for (const node of nodes) parent.insertBefore(node, run);
1547
1689
  }
@@ -1572,9 +1714,13 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1572
1714
  for (const record of records) {
1573
1715
  if (record.type === "characterData") {
1574
1716
  collectRuns(record.target, capsuleDirty);
1717
+ collectRefRuns(record.target, refDirty);
1575
1718
  continue;
1576
1719
  }
1577
- for (const node of record.addedNodes) collectRuns(node, capsuleDirty);
1720
+ for (const node of record.addedNodes) {
1721
+ collectRuns(node, capsuleDirty);
1722
+ collectRefRuns(node, refDirty);
1723
+ }
1578
1724
  for (const node of record.removedNodes) {
1579
1725
  if (node.nodeType === 1 && capsules.has(node)) capsuleDirty.add(node);
1580
1726
  }
@@ -1601,6 +1747,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1601
1747
  if (run.isConnected) enhance(run);
1602
1748
  else if (capsules.has(run)) dropCapsule(run);
1603
1749
  }
1750
+ // Badges come last: the quotes they link to must be in the ledger first.
1751
+ flushRefBadges();
1604
1752
  wireChips();
1605
1753
  }
1606
1754
 
@@ -1626,7 +1774,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1626
1774
  capsuleObserver.observe(target, { childList: true, subtree: true, characterData: true });
1627
1775
  }
1628
1776
  if (typeof document.querySelectorAll === "function") {
1629
- for (const host of document.querySelectorAll(CONTENT_SELECTOR)) collectRuns(host, capsuleDirty);
1777
+ for (const host of document.querySelectorAll(CONTENT_SELECTOR)) {
1778
+ collectRuns(host, capsuleDirty);
1779
+ collectRefRuns(host, refDirty);
1780
+ }
1630
1781
  }
1631
1782
  // A fixed card would otherwise hang in place after the page moved.
1632
1783
  if (!popoverWired && typeof window !== "undefined" && typeof window.addEventListener === "function") {
@@ -1649,6 +1800,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1649
1800
  }
1650
1801
  capsuleDirty.clear();
1651
1802
  for (const run of Array.from(capsules.keys())) dropCapsule(run);
1803
+ uninstallRefBadges();
1652
1804
  cancelPopoverHide();
1653
1805
  unhookChips();
1654
1806
  if (popover !== null) {
@@ -1668,6 +1820,199 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1668
1820
  }
1669
1821
  }
1670
1822
 
1823
+ /* ------------------------------------------------------------------ *
1824
+ * Reply badges — the model's per-quote answers, linked back
1825
+ * ------------------------------------------------------------------ */
1826
+
1827
+ /**
1828
+ * The other half of 逐条回应: when the instruction rode along with the
1829
+ * quotes, the model labels its answers `「引用1」…「引用N」`, and each
1830
+ * label becomes a badge — hover it and the quote's card opens (jump and
1831
+ * revise included, exactly as on a capsule), because the reply is only
1832
+ * useful while it stays tied to the passage it answers.
1833
+ *
1834
+ * The label lives inside a markdown-rendered assistant run, so this pass
1835
+ * splits the text node it sits in — one step more invasive than the
1836
+ * capsule overlay, which only hides and re-reads whole runs. The rules
1837
+ * that keep it safe: a text node is split only once (the ledger holds the
1838
+ * original), a rewrite by React is detected by the value no longer being
1839
+ * our head chunk (our nodes go, the node is re-read fresh), and uninstall
1840
+ * puts the original text back. A label whose number outruns the quotes of
1841
+ * the nearest preceding quoted message is left as plain text — the model
1842
+ * may miscount, and a dead badge would be worse than no badge.
1843
+ */
1844
+
1845
+ /** text node -> { text, head, nodes } for every split reply run. */
1846
+ const refBadges = new Map();
1847
+ const refDirty = new Set();
1848
+ const REF_LABEL = /\u300c\u5f15\u7528\d+\u300d/u;
1849
+
1850
+ function isRefCandidate(node) {
1851
+ if (node === null || node.nodeType !== 3) return false;
1852
+ const value = typeof node.nodeValue === "string" ? node.nodeValue : "";
1853
+ if (REF_LABEL.test(value) !== true) return false;
1854
+ const parent = node.parentElement;
1855
+ if (parent === null || typeof parent.closest !== "function") return false;
1856
+ if (parent.closest("[data-dshq]") !== null) return false;
1857
+ return parent.closest(CONTENT_SELECTOR) !== null;
1858
+ }
1859
+
1860
+ /** Gather candidate text nodes out of a mutation's added subtree. */
1861
+ function collectRefRuns(node, into) {
1862
+ if (node.nodeType === 3) {
1863
+ if (isRefCandidate(node) === true) into.add(node);
1864
+ return;
1865
+ }
1866
+ const element = elementOf(node);
1867
+ if (element === null) return;
1868
+ const stack = [element];
1869
+ while (stack.length > 0) {
1870
+ const current = stack.pop();
1871
+ for (const child of current.childNodes) {
1872
+ if (child.nodeType === 3) {
1873
+ if (isRefCandidate(child) === true) into.add(child);
1874
+ } else if (child.nodeType === 1 && child.childNodes.length > 0) {
1875
+ stack.push(child);
1876
+ }
1877
+ }
1878
+ }
1879
+ }
1880
+
1881
+ /**
1882
+ * The quotes of the nearest preceding capsule-carrying run, in message
1883
+ * order. One walk over the session host settles both "nearest" and
1884
+ * "before" — the run that was last seen when the walk reached the badge's
1885
+ * text node is the one that precedes it.
1886
+ */
1887
+ function precedingQuoteSegments(node, host) {
1888
+ let best = null;
1889
+ let seen = false;
1890
+ const walk = (current) => {
1891
+ if (seen === true) return;
1892
+ if (current === node) {
1893
+ seen = true;
1894
+ return;
1895
+ }
1896
+ if (capsules.has(current)) best = current;
1897
+ for (const child of current.childNodes) walk(child);
1898
+ };
1899
+ walk(host);
1900
+ if (best === null) return [];
1901
+ const known = capsules.get(best);
1902
+ const segments = known === undefined || known === null ? null : known.segments;
1903
+ if (!Array.isArray(segments)) return [];
1904
+ return segments.filter((segment) => segment !== null && segment !== undefined && segment.type === "quote");
1905
+ }
1906
+
1907
+ function removeRefNodes(known) {
1908
+ if (known === undefined || known === null) return;
1909
+ for (const node of known.nodes) {
1910
+ if (node !== null && node !== undefined && node.isConnected) node.remove();
1911
+ }
1912
+ }
1913
+
1914
+ function refBadgeFor(doc, sessionId, label, segment) {
1915
+ const info = {
1916
+ quote: segment.quote,
1917
+ comment: typeof segment.comment === "string" ? segment.comment : "",
1918
+ sessionId,
1919
+ code: segment.code !== null && segment.code !== undefined
1920
+ };
1921
+ const badge = el(doc, "span", { class: "dshq-ref", "data-dshq": "ref", text: label });
1922
+ badge.addEventListener("mouseenter", () => showPopover(badge, info));
1923
+ badge.addEventListener("mouseleave", hidePopoverSoon);
1924
+ badge.addEventListener("click", (event) => {
1925
+ // A drag-select over the badge must not fire a jump.
1926
+ const selection = doc.getSelection === undefined ? null : doc.getSelection();
1927
+ if (selection !== null && selection.isCollapsed === false) return;
1928
+ jumpToSource(segment.quote, sessionId);
1929
+ });
1930
+ return badge;
1931
+ }
1932
+
1933
+ /** Split one text node into plain text and badges, once. */
1934
+ function enhanceRefNode(node) {
1935
+ if (refBadges.has(node)) return;
1936
+ if (isRefCandidate(node) !== true) return;
1937
+ const value = node.nodeValue;
1938
+ const parts = value.split(/(\u300c\u5f15\u7528\d+\u300d)/u);
1939
+ if (parts.length < 3) return;
1940
+ const parent = node.parentNode;
1941
+ const doc = node.ownerDocument === null || node.ownerDocument === undefined ? (parent === null ? null : parent.ownerDocument) : node.ownerDocument;
1942
+ if (parent === null || doc === null || doc === undefined) return;
1943
+ const host = parent.closest !== undefined ? parent.closest(CONTENT_SELECTOR) : null;
1944
+ if (host === null) return;
1945
+ const sessionId = (() => {
1946
+ const id = host.getAttribute("data-conversation-session");
1947
+ return typeof id === "string" ? id : null;
1948
+ })();
1949
+ const quotes = precedingQuoteSegments(node, host);
1950
+ if (quotes.length === 0) return;
1951
+
1952
+ const nodes = [];
1953
+ // The original node keeps the first chunk; everything else is inserted
1954
+ // after it, in order, so React's node stays where reconciliation left it.
1955
+ node.nodeValue = parts[0];
1956
+ let previous = node;
1957
+ for (let index = 1; index < parts.length; index += 1) {
1958
+ const part = parts[index];
1959
+ let created = null;
1960
+ if (index % 2 === 1) {
1961
+ const number = Number(part.slice(3, -1));
1962
+ const segment = number >= 1 && number <= quotes.length ? quotes[number - 1] : null;
1963
+ created = segment === null ? doc.createTextNode(part) : refBadgeFor(doc, sessionId, part, segment);
1964
+ } else {
1965
+ created = doc.createTextNode(part);
1966
+ }
1967
+ if (created === null) continue;
1968
+ parent.insertBefore(created, previous.nextSibling);
1969
+ previous = created;
1970
+ nodes.push(created);
1971
+ }
1972
+ refBadges.set(node, { text: value, head: parts[0], nodes });
1973
+ }
1974
+
1975
+ /** Reconcile the ledger, then split whatever is still pending. */
1976
+ function flushRefBadges() {
1977
+ for (const [node, known] of Array.from(refBadges)) {
1978
+ if (node.isConnected === false) {
1979
+ removeRefNodes(known);
1980
+ refBadges.delete(node);
1981
+ continue;
1982
+ }
1983
+ // React rewrote the text: our chunks duplicate the tail now. Drop
1984
+ // everything we added and re-read the node's fresh value.
1985
+ if (node.nodeValue !== known.head) {
1986
+ removeRefNodes(known);
1987
+ refBadges.delete(node);
1988
+ refDirty.add(node);
1989
+ }
1990
+ }
1991
+ if (refDirty.size === 0) return;
1992
+ const candidates = Array.from(refDirty);
1993
+ refDirty.clear();
1994
+ for (const node of candidates) {
1995
+ if (node.isConnected === true) enhanceRefNode(node);
1996
+ }
1997
+ }
1998
+
1999
+ function uninstallRefBadges() {
2000
+ for (const [node, known] of Array.from(refBadges)) {
2001
+ removeRefNodes(known);
2002
+ // Restoring is only honest while React never touched the value
2003
+ // after the split; otherwise the fresh value is the truth.
2004
+ if (node.isConnected && node.nodeValue === known.head) {
2005
+ try {
2006
+ node.nodeValue = known.text;
2007
+ } catch (error) {
2008
+ // A read-only node only costs its original wording.
2009
+ }
2010
+ }
2011
+ }
2012
+ refBadges.clear();
2013
+ refDirty.clear();
2014
+ }
2015
+
1671
2016
  /* ------------------------------------------------------------------ *
1672
2017
  * Bar element
1673
2018
  * ------------------------------------------------------------------ */
@@ -1683,11 +2028,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1683
2028
  mode: null,
1684
2029
  sessionId: null,
1685
2030
  comment: "",
1686
- /** True when the selection came from the preview or the terminal:
1687
- * the quote travels as a fenced code block, not a plain quote. */
1688
- code: false,
1689
- /** Where the current selection was picked up, for the jump-back row. */
1690
- source: null,
2031
+ /** True when the selection came from the preview or the terminal:
2032
+ * the quote travels as a fenced code block, not a plain quote. */
2033
+ code: false,
2034
+ /** The file a preview selection came from, when the host exposes it. */
2035
+ path: null,
2036
+ /** 逐条回应: the current quote asks the model to answer per quote. */
2037
+ reply: false,
2038
+ /** Where the current selection was picked up, for the jump-back row. */
2039
+ source: null,
1691
2040
  /** The quote key being corrected, when the box was opened by 改这条引用. */
1692
2041
  revising: null,
1693
2042
  settle: null,
@@ -1871,6 +2220,20 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1871
2220
  quoteInput.addEventListener("keydown", onKey);
1872
2221
  input.addEventListener("keydown", onKey);
1873
2222
 
2223
+ // 逐条回应: one opt-in checkbox. On, the block carries a transport
2224
+ // instruction asking the model to answer per quote, and the reply's
2225
+ // `「引用N」` labels become badges that link back to these quotes.
2226
+ // Off (the default), the wire stays exactly the shape it always was.
2227
+ const replyCheck = el(doc, "input", { type: "checkbox", class: "dshq-reply-check" });
2228
+ replyCheck.checked = state.reply === true;
2229
+ replyCheck.addEventListener("change", () => {
2230
+ state.reply = replyCheck.checked === true;
2231
+ });
2232
+ const replyToggle = el(doc, "label", { class: "dshq-reply" }, [
2233
+ replyCheck,
2234
+ el(doc, "span", { text: translate("replyToggle") })
2235
+ ]);
2236
+
1874
2237
  grow(quoteInput);
1875
2238
  syncConfirm();
1876
2239
 
@@ -1878,6 +2241,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1878
2241
  el(doc, "div", { class: "dshq-comment" }, [
1879
2242
  quoteInput,
1880
2243
  input,
2244
+ replyToggle,
1881
2245
  el(doc, "div", { class: "dshq-actions" }, [
1882
2246
  el(doc, "button", { type: "button", class: "dshq-btn", "data-action": "cancel" }, [translate("cancel")]),
1883
2247
  confirm
@@ -1921,6 +2285,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1921
2285
  state.sessionId = found.sessionId;
1922
2286
  state.comment = "";
1923
2287
  state.code = found.hostKind === "preview" || found.hostKind === "terminal";
2288
+ state.path = typeof found.path === "string" ? found.path : null;
2289
+ state.reply = false;
1924
2290
  state.source = found.source === undefined ? null : found.source;
1925
2291
  state.mode = "menu";
1926
2292
  state.suppress = false;
@@ -1952,6 +2318,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1952
2318
  state.quote = quote;
1953
2319
  state.comment = typeof comment === "string" ? comment : "";
1954
2320
  state.code = code === true;
2321
+ state.path = null;
2322
+ state.reply = false;
1955
2323
  state.sessionId = sessionId === null || sessionId === undefined ? null : sessionId;
1956
2324
  state.source = sourceFor(quote);
1957
2325
  state.revising = sourceKeyOf(quote);
@@ -1977,6 +2345,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1977
2345
  state.sessionId = null;
1978
2346
  state.comment = "";
1979
2347
  state.code = false;
2348
+ state.path = null;
2349
+ state.reply = false;
1980
2350
  state.source = null;
1981
2351
  state.revising = null;
1982
2352
  state.suppress = true;
@@ -2014,6 +2384,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2014
2384
  const source = state.source;
2015
2385
  const revising = state.revising;
2016
2386
  const code = state.code;
2387
+ const meta = { path: state.path, reply: state.reply === true };
2017
2388
  const entry = sessionId === null ? null : sessions.get(sessionId);
2018
2389
  const trimmed = typeof comment === "string" ? comment.trim() : "";
2019
2390
  close();
@@ -2034,11 +2405,11 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2034
2405
  // the fallback for every build where that service face is missing.
2035
2406
  // A comment rides inside that chip — one node, one capsule after the
2036
2407
  // send — so 评论 writes nothing else into the draft.
2037
- if (insertChip(entry, sessionId, text, trimmed, code)) {
2408
+ if (insertChip(entry, sessionId, text, trimmed, code, meta)) {
2038
2409
  focusComposer();
2039
2410
  return;
2040
2411
  }
2041
- if (insertPayload(entry, buildPayload(text, trimmed, code))) focusComposer();
2412
+ if (insertPayload(entry, buildPayload(text, trimmed, code, meta))) focusComposer();
2042
2413
  else toast(translate("failed"));
2043
2414
  }
2044
2415
 
@@ -2093,13 +2464,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2093
2464
  * `❝` marks stay out on purpose — they exist for the MAIN transcript's
2094
2465
  * capsule enhancer, and the side thread renders its own way. A code quote
2095
2466
  * keeps its fence instead: that composer talks to the model, and a fenced
2096
- * block is what the model reads as code.
2467
+ * block is what the model reads as code. A preview quote puts its file
2468
+ * into the fence's info string, the same slot the main wire uses.
2097
2469
  */
2098
- function sideQuestion(text, code) {
2470
+ function sideQuestion(text, code, path) {
2099
2471
  const lines = blockLines(text);
2100
2472
  if (code === true && lines.length > 0) {
2101
2473
  const fence = lines.some((line) => /^\s*```/u.test(line)) ? "~~~" : "```";
2102
- return [fence].concat(lines, [fence]).join("\n");
2474
+ const clean = sanitizePath(path) ?? "";
2475
+ return [fence + (clean === "" ? "" : " " + clean)].concat(lines, [fence]).join("\n");
2103
2476
  }
2104
2477
  return lines
2105
2478
  .map((line) => (line.trim() === "" ? ">" : "> " + line))
@@ -2301,9 +2674,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2301
2674
  * @param {string} sessionId - the session the quote came from.
2302
2675
  * @param {string} text - the quoted text.
2303
2676
  * @param {boolean} [code] - true when the quote is code (preview/terminal).
2677
+ * @param {string | null} [path] - the file a preview quote came from.
2304
2678
  * @returns {Promise<void>} resolves once the draft is written or refused.
2305
2679
  */
2306
- function sideChatRequest(service, sessionId, text, code) {
2680
+ function sideChatRequest(service, sessionId, text, code, path) {
2307
2681
  if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text.trim() === "") {
2308
2682
  return Promise.resolve();
2309
2683
  }
@@ -2335,7 +2709,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2335
2709
  rememberSideThread(sessionId, childId);
2336
2710
  const threadId = await displayedThreadId(sessionId, childId);
2337
2711
  rememberSideThread(sessionId, threadId);
2338
- if (!(await prefillDraft(threadId, sideQuestion(text, code === true)))) {
2712
+ if (!(await prefillDraft(threadId, sideQuestion(text, code === true, path)))) {
2339
2713
  console.warn("[ui-quote] sidechat draft not written:", threadId);
2340
2714
  toast(translateFor(sessionId, "sideChatDraftFailed"));
2341
2715
  }
@@ -2360,6 +2734,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2360
2734
  if (sessionId === null || text === null) return;
2361
2735
  const service = sideChatService();
2362
2736
  const code = state.code;
2737
+ const path = state.path;
2363
2738
  close();
2364
2739
  // Kimi drops the transcript selection the moment an action is chosen.
2365
2740
  const selection = document.getSelection === undefined ? null : document.getSelection();
@@ -2368,7 +2743,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2368
2743
  toast(translateFor(sessionId, "sideChatFailed"));
2369
2744
  return;
2370
2745
  }
2371
- sideChatRequest(service, sessionId, text, code);
2746
+ sideChatRequest(service, sessionId, text, code, path);
2372
2747
  }
2373
2748
 
2374
2749
  /* ------------------------------------------------------------------ *
@@ -2624,7 +2999,10 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
2624
2999
  time: typeof event.time === "string" ? event.time : "",
2625
3000
  quote: segment.quote,
2626
3001
  comment: typeof segment.comment === "string" ? segment.comment : "",
2627
- code: segment.code !== null && segment.code !== undefined
3002
+ code: segment.code !== null && segment.code !== undefined,
3003
+ // The fence's info string (a preview quote carries its file
3004
+ // there) — the export puts it back on its own fence line.
3005
+ fence: typeof segment.code === "string" ? segment.code : ""
2628
3006
  });
2629
3007
  }
2630
3008
  }
@@ -2651,9 +3029,11 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
2651
3029
  }
2652
3030
  list.forEach((row, index) => {
2653
3031
  lines.push("## " + (index + 1) + ". " + firstLineOf(row.quote, 80), "");
2654
- // A code quote exports as a real fenced block, not a blockquote.
3032
+ // A code quote exports as a real fenced block, not a blockquote; a
3033
+ // preview quote's file rides in the info string, as it did on the wire.
2655
3034
  if (row.code === true) {
2656
- lines.push("```", String(row.quote), "```", "");
3035
+ const info = typeof row.fence === "string" && row.fence !== "" ? " " + row.fence : "";
3036
+ lines.push("```" + info, String(row.quote), "```", "");
2657
3037
  } else {
2658
3038
  for (const line of String(row.quote).split("\n")) lines.push("> " + line);
2659
3039
  lines.push("");
@@ -3384,6 +3764,9 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
3384
3764
  splitQuoteRuns,
3385
3765
  chipRef,
3386
3766
  chipLabel,
3767
+ sanitizePath,
3768
+ previewPathOf,
3769
+ withLineRange,
3387
3770
  rememberChip,
3388
3771
  normalizeLabel,
3389
3772
  barTop,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.10.0",
4
- "description": "Select text in the DSH transcript, a file preview or the terminal to quote it into the composer or into a side thread's composer, Kimi-Code style (comment / add to conversation / side chat), with Alt+Q/C/B, a jump back to the quoted source, a searchable quote list page that exports as Markdown, one-click back-flow of a side thread's conclusion, and revisions of a sent quote that mark the old capsule as superseded. The quoted text itself is editable in the comment box (trim a selection, correct a revision's wording), and a selection from the file preview or the terminal travels to the model as a fenced code block.",
3
+ "version": "0.11.1",
4
+ "description": "Select text in the DSH transcript, a file preview or the terminal to quote it into the composer or into a side thread's composer, Kimi-Code style (comment / add to conversation / side chat), with Alt+Q/C/B, a jump back to the quoted source, a searchable quote list page that exports as Markdown, one-click back-flow of a side thread's conclusion, and revisions of a sent quote that mark the old capsule as superseded. The quoted text itself is editable in the comment box (trim a selection, correct a revision's wording), a selection from the file preview or the terminal travels to the model as a fenced code block carrying its file path and line range, and a per-quote reply toggle has the model answer quote by quote, each 「引用N」 label in the reply becoming a badge linked back to the quote.",
5
5
  "icon": "icon.svg",
6
6
  "license": "MIT",
7
7
  "author": "BangBang-03",
package/test/capsule.mjs CHANGED
@@ -1047,6 +1047,304 @@ try {
1047
1047
  codeCap.dispatch("mouseleave", {});
1048
1048
  }
1049
1049
 
1050
+ /* ------------------------------------------------------------------ *
1051
+ * 文件路径 + 逐条回应 — the wire's two new transport-only forms
1052
+ * ------------------------------------------------------------------ */
1053
+
1054
+ {
1055
+ // 预览引用: the file rides in the fence's info string, where Markdown keeps
1056
+ // metadata the model reads but renderers never show as content.
1057
+ check(
1058
+ "a preview quote puts its file into the fence info string",
1059
+ testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts" }) === "> ❝ ``` src/app.ts\n> const x = 1;\n> ```",
1060
+ JSON.stringify(testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts" }))
1061
+ );
1062
+ const pathSegments = testing.splitQuoteRuns(testing.quoteBlock("const x = 1;", "看一下", true, { path: "src/app.ts" }));
1063
+ check(
1064
+ "the path parses back as the fence's info string",
1065
+ pathSegments?.length === 1 && pathSegments[0].code === "src/app.ts" && pathSegments[0].quote === "const x = 1;" && pathSegments[0].comment === "看一下",
1066
+ JSON.stringify(pathSegments)
1067
+ );
1068
+ check(
1069
+ "a path rides along when the content forces ~~~",
1070
+ testing.quoteBlock("```\nx = 1\n```", "", true, { path: "src/a.ts" }) === "> ❝ ~~~ src/a.ts\n> ```\n> x = 1\n> ```\n> ~~~",
1071
+ JSON.stringify(testing.quoteBlock("```\nx = 1\n```", "", true, { path: "src/a.ts" }))
1072
+ );
1073
+ check(
1074
+ "a path with whitespace is dropped, not smuggled",
1075
+ testing.quoteBlock("x", "", true, { path: "my file.ts" }) === "> ❝ ```\n> x\n> ```",
1076
+ JSON.stringify(testing.quoteBlock("x", "", true, { path: "my file.ts" }))
1077
+ );
1078
+ check(
1079
+ "sanitizePath takes the obvious rejects",
1080
+ testing.sanitizePath(" src/a.ts ") === "src/a.ts" &&
1081
+ testing.sanitizePath("a b") === null &&
1082
+ testing.sanitizePath("a`b") === null &&
1083
+ testing.sanitizePath("a~b") === null &&
1084
+ testing.sanitizePath("") === null,
1085
+ JSON.stringify([testing.sanitizePath(" src/a.ts "), testing.sanitizePath("a b"), testing.sanitizePath("a`b"), testing.sanitizePath("a~b"), testing.sanitizePath("")])
1086
+ );
1087
+
1088
+ // 逐条回应: one instruction line at the block's tail, wire-only.
1089
+ const replyWire = testing.quoteBlock("正文", "看看这里", false, { reply: true });
1090
+ check(
1091
+ "逐条回应 appends one instruction line to the block",
1092
+ replyWire === "> ❝ 正文\n> ❞ 看看这里\n> ※ 指令:请把本条消息里的引用块按出现顺序记为「引用1」「引用2」…,回复时逐条对应回应,每条回应前标注「引用N」。",
1093
+ JSON.stringify(replyWire)
1094
+ );
1095
+ const replySegments = testing.splitQuoteRuns(replyWire);
1096
+ check(
1097
+ "the instruction never reaches the capsule",
1098
+ replySegments?.length === 1 && replySegments[0].comment === "看看这里" && JSON.stringify(replySegments).includes("指令") === false,
1099
+ JSON.stringify(replySegments)
1100
+ );
1101
+ const replyBare = testing.splitQuoteRuns(testing.quoteBlock("正文", "", false, { reply: true }));
1102
+ check(
1103
+ "a reply instruction without a comment leaves the comment half empty",
1104
+ replyBare?.length === 1 && replyBare[0].comment === "" && replyBare[0].quote === "正文",
1105
+ JSON.stringify(replyBare)
1106
+ );
1107
+ const replyCode = testing.splitQuoteRuns(testing.quoteBlock("x=1", "改这里", true, { path: "src/a.ts", reply: true }));
1108
+ check(
1109
+ "path and instruction travel in one code block",
1110
+ replyCode?.length === 1 && replyCode[0].code === "src/a.ts" && replyCode[0].comment === "改这里" && JSON.stringify(replyCode).includes("指令") === false,
1111
+ JSON.stringify(replyCode)
1112
+ );
1113
+ check(
1114
+ "the side thread's fence carries the file too",
1115
+ testing.sideQuestion("x=1", true, "src/app.ts") === "``` src/app.ts\nx=1\n```",
1116
+ JSON.stringify(testing.sideQuestion("x=1", true, "src/app.ts"))
1117
+ );
1118
+ check(
1119
+ "the side thread drops a whitespace path",
1120
+ testing.sideQuestion("x=1", true, "my file.ts") === "```\nx=1\n```",
1121
+ JSON.stringify(testing.sideQuestion("x=1", true, "my file.ts"))
1122
+ );
1123
+
1124
+ // previewPathOf: walk the ancestors, skip values that cannot be a path.
1125
+ const outer = dom.document.createElement("div");
1126
+ outer.setAttribute("data-file-path", "src/app.ts");
1127
+ const mid = dom.document.createElement("div");
1128
+ mid.setAttribute("data-textpreview-body", "");
1129
+ mid.setAttribute("data-path", "bad path");
1130
+ outer.append(mid);
1131
+ check(
1132
+ "previewPathOf finds the ancestor's path and skips a whitespace one",
1133
+ testing.previewPathOf(mid) === "src/app.ts",
1134
+ JSON.stringify(testing.previewPathOf(mid))
1135
+ );
1136
+ check("previewPathOf misses cleanly on a bare host", testing.previewPathOf(dom.document.createElement("div")) === null);
1137
+
1138
+ // The real host shape: the path label lives in a header SIBLING of the body
1139
+ // the selection is in — the probe has to find it through the preview root,
1140
+ // reading the full path from the label's title.
1141
+ const previewRoot = dom.document.createElement("div");
1142
+ previewRoot.setAttribute("data-document-preview", "doc-1");
1143
+ const header = dom.document.createElement("div");
1144
+ const label = dom.document.createElement("span");
1145
+ label.setAttribute("data-textpreview-path", "");
1146
+ label.setAttribute("title", "src/app.ts");
1147
+ header.append(label);
1148
+ const previewBody = dom.document.createElement("div");
1149
+ previewBody.setAttribute("data-textpreview-body", "");
1150
+ previewRoot.append(header);
1151
+ previewRoot.append(previewBody);
1152
+ check(
1153
+ "previewPathOf reads the header's path label through the preview root",
1154
+ testing.previewPathOf(previewBody) === "src/app.ts",
1155
+ JSON.stringify(testing.previewPathOf(previewBody))
1156
+ );
1157
+ const clippedLabel = dom.document.createElement("span");
1158
+ clippedLabel.setAttribute("data-textpreview-path", "");
1159
+ clippedLabel.setAttribute("title", "my file.ts");
1160
+ const clippedRoot = dom.document.createElement("div");
1161
+ clippedRoot.setAttribute("data-document-preview", "doc-2");
1162
+ clippedRoot.append(clippedLabel, dom.document.createElement("div"));
1163
+ check(
1164
+ "a label whose path cannot be a token costs nothing",
1165
+ testing.previewPathOf(clippedRoot.lastChild) === null,
1166
+ JSON.stringify(testing.previewPathOf(clippedRoot.lastChild))
1167
+ );
1168
+
1169
+ // withLineRange: GitHub's #L spelling, normalized, or nothing.
1170
+ check(
1171
+ "withLineRange composes the GitHub line range",
1172
+ testing.withLineRange("src/app.ts", 12, 48) === "src/app.ts#L12-L48" &&
1173
+ testing.withLineRange("src/app.ts", 7, 7) === "src/app.ts#L7" &&
1174
+ testing.withLineRange("src/app.ts", 48, 12) === "src/app.ts#L12-L48",
1175
+ JSON.stringify([testing.withLineRange("src/app.ts", 12, 48), testing.withLineRange("src/app.ts", 7, 7), testing.withLineRange("src/app.ts", 48, 12)])
1176
+ );
1177
+ check(
1178
+ "withLineRange degrades honestly",
1179
+ testing.withLineRange("my file.ts", 1, 2) === null && testing.withLineRange("src/a.ts", null, 4) === "src/a.ts" && testing.withLineRange("src/a.ts", 0, 4) === "src/a.ts",
1180
+ JSON.stringify([testing.withLineRange("my file.ts", 1, 2), testing.withLineRange("src/a.ts", null, 4), testing.withLineRange("src/a.ts", 0, 4)])
1181
+ );
1182
+ check(
1183
+ "a path with a range still parses back as the fence info string",
1184
+ testing.splitQuoteRuns(testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts#L12-L48" }))?.[0]?.code === "src/app.ts#L12-L48",
1185
+ JSON.stringify(testing.splitQuoteRuns(testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts#L12-L48" }))
1186
+ )
1187
+ );
1188
+ }
1189
+
1190
+ /* ------------------------------------------------------------------ *
1191
+ * 回复徽章 — the model's 「引用N」 labels link back to the quotes
1192
+ * ------------------------------------------------------------------ */
1193
+
1194
+ {
1195
+ const badgeHost = dom.document.createElement("div");
1196
+ badgeHost.setAttribute("data-conversation-content", "");
1197
+ badgeHost.setAttribute("data-conversation-session", "session-badge");
1198
+ const userBubble = dom.document.createElement("div");
1199
+ const quoteRun = dom.document.createElement("span");
1200
+ quoteRun.className = "plainRun";
1201
+ const replyValue = "回答一「引用1」;回答二「引用2」;越界「引用9」;结尾";
1202
+ quoteRun.textContent = "问题" + testing.quoteBlock("第一处引用", "注一") + "\n\n中间的话\n\n" + testing.quoteBlock("第二处引用", "注二") + "\n\n";
1203
+ userBubble.append(quoteRun);
1204
+ badgeHost.append(userBubble);
1205
+ const replyParagraph = dom.document.createElement("p");
1206
+ const replyText = dom.document.createTextNode(replyValue);
1207
+ replyParagraph.append(replyText);
1208
+ badgeHost.append(replyParagraph);
1209
+ dom.document.body.append(badgeHost);
1210
+ dom.document.deliver();
1211
+ await wait(120);
1212
+
1213
+ const badges = () => replyParagraph.querySelectorAll(".dshq-ref");
1214
+ check("two quoted messages come back as two capsules", badgeHost.querySelectorAll(".dshq-cap").length === 2, badgeHost.querySelectorAll(".dshq-cap").length + " capsule(s)");
1215
+ check("a reply label becomes a badge", badges().length === 2, badges().length + " badge(s)");
1216
+ check(
1217
+ "the reply reads the same with badges in place",
1218
+ replyParagraph.textContent === replyValue,
1219
+ JSON.stringify(replyParagraph.textContent)
1220
+ );
1221
+ check(
1222
+ "only labels the quotes can answer are badged",
1223
+ JSON.stringify(Array.from(badges()).map((badge) => badge.textContent)) === JSON.stringify(["「引用1」", "「引用2」"]),
1224
+ JSON.stringify(Array.from(badges()).map((badge) => badge.textContent))
1225
+ );
1226
+
1227
+ // An assistant reply in a conversation with no quoted message stays plain.
1228
+ const bareHost = dom.document.createElement("div");
1229
+ bareHost.setAttribute("data-conversation-content", "");
1230
+ bareHost.setAttribute("data-conversation-session", "session-bare");
1231
+ const bareParagraph = dom.document.createElement("p");
1232
+ bareParagraph.textContent = "回应「引用1」但没有先前的引用";
1233
+ bareHost.append(bareParagraph);
1234
+ dom.document.body.append(bareHost);
1235
+ dom.document.deliver();
1236
+ await wait(120);
1237
+ check("a reply without a preceding quote is not badged", bareParagraph.querySelectorAll(".dshq-ref").length === 0, bareParagraph.querySelectorAll(".dshq-ref").length + " badge(s)");
1238
+
1239
+ // The badge reuses the hover card, quote and comment in full.
1240
+ const firstBadge = badges()[0];
1241
+ firstBadge.rect = { left: 80, top: 300, width: 64, height: 20 };
1242
+ firstBadge.dispatch("mouseenter", {});
1243
+ check(
1244
+ "hovering a badge opens the quote's card",
1245
+ pop()?.querySelector(".dshq-pop-quote")?.textContent === "第一处引用",
1246
+ JSON.stringify(pop()?.querySelector(".dshq-pop-quote")?.textContent)
1247
+ );
1248
+ check(
1249
+ "the badge's card shows the comment too",
1250
+ pop()?.querySelector(".dshq-pop-comment")?.textContent === "注一",
1251
+ JSON.stringify(pop()?.querySelector(".dshq-pop-comment")?.textContent)
1252
+ );
1253
+ firstBadge.dispatch("mouseleave", {});
1254
+ await wait(200);
1255
+ check("leaving a badge closes the card", pop()?.dataset.visible === "false", JSON.stringify(pop()?.dataset.visible));
1256
+
1257
+ // A quote the plugin never watched has no coordinates: clicking the badge
1258
+ // must not pretend to jump — no throw, no toast. (An earlier section left a
1259
+ // jump-failure toast mid-display; hide it first so only this click counts.)
1260
+ const leftoverToast = dom.document.getElementById("dsh-quote-toast");
1261
+ if (leftoverToast !== null) leftoverToast.dataset.visible = "false";
1262
+ check("the badge's quote was never traced", testing.sourceFor("第一处引用") === null);
1263
+ firstBadge.dispatch("click", { stopPropagation() {} });
1264
+ check(
1265
+ "a badge without coordinates jumps nowhere quietly",
1266
+ dom.document.getElementById("dsh-quote-toast")?.dataset.visible !== "true",
1267
+ JSON.stringify(dom.document.getElementById("dsh-quote-toast")?.dataset.visible)
1268
+ );
1269
+
1270
+ dom.document.deliver();
1271
+ await wait(120);
1272
+ check("an unrelated flush does not duplicate badges", badges().length === 2, badges().length + " badge(s)");
1273
+
1274
+ // React rewrites the text node: the stale split goes and the fresh value
1275
+ // is re-read, so the badges always match what the reply says now.
1276
+ replyText.textContent = "改写后只提「引用2」一次";
1277
+ dom.document.deliver();
1278
+ await wait(120);
1279
+ check(
1280
+ "a rewritten reply re-badges from the fresh text",
1281
+ badges().length === 1 && badges()[0].textContent === "「引用2」" && replyParagraph.textContent === "改写后只提「引用2」一次",
1282
+ JSON.stringify([badges().length, replyParagraph.textContent])
1283
+ );
1284
+
1285
+ // Uninstall puts the original text back and takes every badge with it.
1286
+ capsuleEffect.dispose();
1287
+ check(
1288
+ "uninstall restores the reply text and drops the badges",
1289
+ replyParagraph.querySelectorAll(".dshq-ref").length === 0 && replyParagraph.textContent === "改写后只提「引用2」一次",
1290
+ JSON.stringify(replyParagraph.textContent)
1291
+ );
1292
+ }
1293
+
1294
+ /* ------------------------------------------------------------------ *
1295
+ * 逐条回应的开关 — the comment box checkbox drives the wire
1296
+ * ------------------------------------------------------------------ */
1297
+
1298
+ {
1299
+ const overlay = registered.find((entry) => entry.spec.name === "conversation.input.overlay");
1300
+ const toggleInsertions = [];
1301
+ shells.set("session-toggle", {
1302
+ insertReference(attachment) {
1303
+ toggleInsertions.push(attachment);
1304
+ return true;
1305
+ }
1306
+ });
1307
+ if (overlay !== undefined) overlay.Component({ sessionId: "session-toggle", inputActions: { captureInsertion: () => ({ draftRev: 1 }) } });
1308
+
1309
+ testing.startRevision("一处引用", "", "session-toggle", null, false);
1310
+ const bar = dom.document.getElementById("dsh-quote-bar");
1311
+ const replyCheck = bar?.querySelector(".dshq-reply-check");
1312
+ check("the comment box carries the per-quote toggle", replyCheck !== null && replyCheck !== undefined);
1313
+ check("the toggle starts off", replyCheck?.checked === false, String(replyCheck?.checked));
1314
+ const quoteBox = bar.querySelector(".dshq-quote-input");
1315
+ const commentBox = bar.querySelector(".dshq-comment-input");
1316
+ const confirmBtn = bar.querySelector('[data-action="confirm"]');
1317
+ quoteBox.value = "一处引用";
1318
+ quoteBox.dispatch("input", {});
1319
+ commentBox.value = "请照着改";
1320
+ commentBox.dispatch("input", {});
1321
+ bar.dispatch("click", { target: confirmBtn });
1322
+ check(
1323
+ "toggle off: the wire carries no instruction",
1324
+ toggleInsertions.length === 1 && String(toggleInsertions[0]?.ref ?? "").includes("指令") === false,
1325
+ JSON.stringify(String(toggleInsertions[0]?.ref ?? "").trim())
1326
+ );
1327
+
1328
+ testing.startRevision("一处引用", "", "session-toggle", null, false);
1329
+ const bar2 = dom.document.getElementById("dsh-quote-bar");
1330
+ const replyCheck2 = bar2.querySelector(".dshq-reply-check");
1331
+ replyCheck2.checked = true;
1332
+ replyCheck2.dispatch("change", {});
1333
+ const quoteBox2 = bar2.querySelector(".dshq-quote-input");
1334
+ const commentBox2 = bar2.querySelector(".dshq-comment-input");
1335
+ const confirmBtn2 = bar2.querySelector('[data-action="confirm"]');
1336
+ quoteBox2.value = "一处引用";
1337
+ quoteBox2.dispatch("input", {});
1338
+ commentBox2.value = "请照着改";
1339
+ commentBox2.dispatch("input", {});
1340
+ bar2.dispatch("click", { target: confirmBtn2 });
1341
+ check(
1342
+ "toggle on: the wire carries the per-quote instruction",
1343
+ toggleInsertions.length === 2 && String(toggleInsertions[1]?.ref ?? "").includes("※ 指令:"),
1344
+ JSON.stringify(String(toggleInsertions[1]?.ref ?? "").trim().slice(-40))
1345
+ );
1346
+ }
1347
+
1050
1348
  console.log(
1051
1349
  failures.length === 0
1052
1350
  ? "\ndsh-client-ui-quote capsule: " + passed + " checks passed"
package/test/dom.mjs CHANGED
@@ -35,6 +35,12 @@ class ShimNode {
35
35
  return this.childNodes.length === 0 ? null : this.childNodes[this.childNodes.length - 1];
36
36
  }
37
37
 
38
+ get nextSibling() {
39
+ if (this.parentNode === null) return null;
40
+ const siblings = this.parentNode.childNodes;
41
+ return siblings[siblings.indexOf(this) + 1] ?? null;
42
+ }
43
+
38
44
  append(child) {
39
45
  this.insertBefore(child, null);
40
46
  }
package/test/panel.mjs CHANGED
@@ -240,6 +240,28 @@ check(
240
240
  testing.quoteRowsFromEntries(entries).every((row) => row.code === false),
241
241
  JSON.stringify(testing.quoteRowsFromEntries(entries).map((row) => row.code))
242
242
  );
243
+ const pathEntries = [
244
+ {
245
+ type: "event",
246
+ event: {
247
+ type: "user/message",
248
+ seq: 5,
249
+ time: "",
250
+ data: { id: "e6", content: [{ type: "text", text: "> ❝ ``` src/app.ts\n> const x = 1;\n> ```\n> ❞ 看一下" }] }
251
+ }
252
+ }
253
+ ];
254
+ const pathRows = testing.quoteRowsFromEntries(pathEntries);
255
+ check(
256
+ "a preview quote's file rides in the row",
257
+ pathRows.length === 1 && pathRows[0].code === true && pathRows[0].fence === "src/app.ts" && pathRows[0].comment === "看一下",
258
+ JSON.stringify(pathRows[0])
259
+ );
260
+ check(
261
+ "the export puts the file on its fence line",
262
+ testing.quoteListMarkdown(pathRows, "引用清单").includes("``` src/app.ts\nconst x = 1;\n```"),
263
+ JSON.stringify(testing.quoteListMarkdown(pathRows, "引用清单"))
264
+ );
243
265
 
244
266
  // ---- reading a session that is not on screen -------------------------------
245
267
  sessionFixture = {