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 +44 -15
- package/lib/client.js +417 -34
- package/package.json +2 -2
- package/test/capsule.mjs +298 -0
- package/test/dom.mjs +6 -0
- package/test/panel.mjs +22 -0
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
|
|
56
|
+
### 方式三:npm 包(已上架)
|
|
56
57
|
|
|
57
|
-
|
|
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
|
-
|
|
60
|
+
装:
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
```bash
|
|
63
|
+
dsh plugin --profile desktop add dsh-client-ui-quote
|
|
64
|
+
```
|
|
63
65
|
|
|
64
|
-
|
|
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. **工程化**:
|
|
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 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 +
|
|
243
|
-
test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,
|
|
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
|
-
修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共
|
|
311
|
+
修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 194 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用)。
|
|
291
312
|
`test/capsule.html` 是同一套断言的真浏览器版,用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。
|
|
292
313
|
|
|
293
|
-
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(
|
|
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
|
-
合计 **
|
|
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 =
|
|
603
|
-
let quoteLines = cut === -1 ?
|
|
604
|
-
const commentLines = cut === -1 ? [] :
|
|
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
|
-
|
|
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)
|
|
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))
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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),
|
|
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 = {
|