dsh-client-ui-quote 0.11.0 → 0.11.2

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
@@ -18,7 +18,7 @@
18
18
  | **改这条引用** | 悬停卡片里的 `改这条引用`:把那条引用当初的**引用与评论都原样放回评论框**(引用正文、评论各占一个输入框,都能改),你改完按 `⏎` 就发出一条**新的**引用(DSH 没有编辑已发送消息的 API,这是唯一诚实的做法)。旧胶囊随即挂上 **`已修订`** 标记,卡片里直接显示改后的评论;**连正文一起改过的**,卡片里也把改后的原文亮出来。标记按引用原文记在本机,跨刷新仍在。 |
19
19
  | **逐条回应** | 评论框里的一格**勾选项**(默认关):勾上,这条引用的块尾就多一行**只有模型读得到的指令**——请按出现顺序把引用记为「引用1」「引用2」…并在回复中逐条对应。模型的回复里每个 `「引用N」` 会变成一枚**小徽章**:悬停就是那条引用的完整卡片(含 `跳回原文` 与 `改这条引用`),点了就跳;编号超出这条消息引用数的标签保持纯文本。指令行与围栏一样只是**线上形态**,胶囊、卡片、导出里都不出现。 |
20
20
  - 引用以 DSH 原生行内原子节点 `reference-chip` 插入(带图标的胶囊);走「评论」时评论一起放进这颗芯片(芯片标签显示为 `引用摘要 · 评论摘要`),引用与评论始终是一体,草稿里不会散成两段。
21
- - **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~` 围栏。**文件预览的引用还带着它出自哪个文件**:宿主在预览 DOM 上标了路径时,路径就写进围栏的信息位(`> ❝ ``` src/app.ts`)——模型由此可以直接打开这个文件动手,清单导出的围栏行同样带着它;探测不到路径时就是原来的样子,什么也不损失。
21
+ - **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~` 围栏。**文件预览的引用还带着它出自哪个文件、哪些行**:宿主标了路径(预览头部的路径标签,或 DOM 上的路径属性)时就写进围栏的信息位——`> ❝ ``` src/app.ts#L12-L48`;预览按行渲染时连行号一起带上(GitHub 的 `#L` 写法)。模型由此可以直接打开这个文件、翻到这几行动手,清单导出的围栏行同样带着它;探测不到就维持原样,什么也不损失。
22
22
  - **引用正文可以编辑**:评论框上半格是引用自己的输入框(评论在下半格),划选带进了提示符噪音或多选了几行,当场裁掉再发;「改这条引用」时旧正文回填同一格,改完随新引用发出。
23
23
  - 发送时由 DSH 回调本插件取「模型形态」:**`> ❝ …` 引用行 + 同一个块里的 `> ❞ …` 评论行**。消息里那段块会被就地换成 Kimi 那样的一颗胶囊
24
24
  (`❝ 引用摘要 │ 评论摘要`,鼠标移上去就能读到引用与评论的全文、点开可展开、悬停出现 `复制`),你自己写的话保持原样,模型收到的仍是完整引用文本。
@@ -55,7 +55,7 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
55
55
 
56
56
  ### 方式三:npm 包(已上架)
57
57
 
58
- 已发布到 npm:`dsh-client-ui-quote@0.10.0`。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 285 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
58
+ 已发布到 npm:`dsh-client-ui-quote`(0.10.0 起上架;每个 `v*` 标签由下面的 workflow 自动发布,本仓库当前 0.11.2)。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 293 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
59
59
 
60
60
  装:
61
61
 
@@ -135,8 +135,10 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
135
135
  模型读到块引用里一个真正的围栏代码块;`splitQuoteRuns` 解析时把首行的围栏(和收尾那一行)剥掉,胶囊拿到的仍是原始代码文本,
136
136
  渲染加 `data-code="true"`(等宽、`pre`、展开时横向滚动),卡片加 `dshq-pop-code`,侧边对话落进子线程草稿的则是不带标记的裸围栏;
137
137
  正文本身含 ``` 时序列化自动换 `~~~` 围栏。没有围栏的块照旧按普通引用解析,新旧消息互不影响;
138
- **预览引用的文件路径**走的是围栏自带的信息位(`> ❝ ``` src/app.ts`)——Markdown 里这本来就是「渲染器不显示、模型读得到」的位置,
139
- `sanitizePath` 把带空白/围栏字符的伪路径挡掉,`previewPathOf` 在预览 DOM 与其几层祖先上探测一组候选属性,探测不到就原样发;
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` 写法,起止顺序自动归一)。两种都没命中就原样发;
140
142
  - **逐条回应的指令行**同样只是线上形态:勾了评论框里的开关,块尾多一行 `> ※ 指令:…`,`splitQuoteRuns` 按 `※ 指令` 前缀把它从块里剥掉,
141
143
  所以胶囊、卡片、清单导出全都看不到它(用户自己的评论/引用恰好以 `※ 指令` 开头的概率,比它带来的清晰收益小得多,已记入已知限制);
142
144
  - 客户端插件用 `MutationObserver` 找「只含一个文本子节点、内部有 `❝`、位于 `[data-conversation-content]` 里」的那个 run,
@@ -225,7 +227,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
225
227
  1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
226
228
  2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标不随引用文本发给模型(不污染 prompt)。
227
229
  3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在 4471 条里没有对应物。
228
- 4. **工程化**:285 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
230
+ 4. **工程化**:293 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
229
231
 
230
232
  反过来也要说清楚:**本插件不做的**——不做翻译/解释类选区动作(81 条同类在做)、不做多选合并引用(7 条有)、不做选区折叠/行内 diff 这类渲染花样。它只把「引用」这一件事做穿。
231
233
 
@@ -258,7 +260,7 @@ locale/zh.json 插件页卡片文案(中文)
258
260
  locale/en.json 插件页卡片文案(英文)
259
261
  test/check.mjs 离线自检(清单 + 组件契约)
260
262
  test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
261
- test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章,189 项断言)
263
+ test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章,197 项断言)
262
264
  test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,96 项断言)
263
265
  test/capsule.html 真浏览器里跑同一套胶囊断言(可选)
264
266
  ```
@@ -306,7 +308,7 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
306
308
  卡片给出 `跳回原文` 与 `改这条引用` 两个动作、点后者把栏切到评论模式并**预填旧评论与旧正文**、
307
309
  按确认后 `insertReference` 恰好一次(新芯片的 `ref` 带改后的评论)、旧引用被记为「已修订」并落进 `localStorage`、
308
310
  旧胶囊挂上 `已修订` 徽标、它的卡片显示改后的评论(连正文改过的连改后原文一起显示)、空修订不算修订、
309
- 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 189 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用)。
311
+ 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 197 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
310
312
  `test/capsule.html` 是同一套断言的真浏览器版,用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。
311
313
 
312
314
  `test/panel.mjs` 单独跑引用清单页与侧边线程回流(96 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
@@ -325,7 +327,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
325
327
  `bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
326
328
  `exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
327
329
 
328
- 合计 **285 项**(check.mjs 的契约断言 + capsule 189 + panel 96),`npm test` 全绿。
330
+ 合计 **293 项**(check.mjs 的契约断言 + capsule 197 + panel 96),`npm test` 全绿。
329
331
 
330
332
  ## 已知限制
331
333
 
@@ -338,7 +340,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
338
340
  - **「改这条引用」是追加,不是原地编辑**:DSH 的 transcript 只追加(侦察确认全树没有 `editMessage` / `updateMessage` / `rewriteMessage` / 回收 API,只有**尚未进入会话**的队列条目可改)。所以修订发出一条新引用(评论、引用正文都可改),旧胶囊只挂 `已修订` 标记——标记存在**本机** `localStorage`(`dsh-client-ui-quote:v1:quote-revised`,按引用原文归一键、上限 60 条),因此不会跨标签页、跨设备同步,也不进入模型上下文;模型看到的是那条改后的新引用。正文改过后键仍是旧原文:`已修订` 挂在**旧**胶囊上(它的卡片里显示改后的原文与评论),改后的新胶囊不挂标。
339
341
  - **代码引用的识别靠首行围栏**:`> ❝ ``` ` 开头的块按代码解析(围栏剥掉、胶囊等宽)。因此一条**普通**引用如果正文恰好以 ``` 或 `~~~` 开头(比如在 Markdown 文件预览里引用了源码),回读时也会被当成代码引用——显示上是等宽排版,文字不丢。
340
342
  - **逐条回应的指令行按 `※ 指令` 前缀剥离**:剥离规则收得很窄,但一条引用或评论如果恰好以 `※ 指令` 开头,回读时那一行会被当成指令剥掉(胶囊里看不到它)。要让这样的内容活着,别勾逐条回应即可——指令行只在勾选时才写入。
341
- - **文件预览的路径是探测来的**:宿主没有文档承诺预览 DOM 的路径属性,插件按一组候选属性在预览节点及其几层祖先上探测,拿不到就不带路径(引用形态与从前完全一样);终端来源的引用没有路径可言。路径写进围栏信息位,`sanitizePath` 把带空白或围栏字符的值挡掉。
343
+ - **文件预览的路径与行号是探测来的**:路径优先读宿主预览头部的路径标签(`[data-textpreview-path]` 的 `title`,实测 0.2.0-rc.2 的侧栏文档预览就是这个形状),其次探一组候选属性;行号只在预览按行渲染时可得(`data-textpreview-line`,纯文本视图有、高亮代码视图没有)。两者都拿不到就不带(引用形态与从前完全一样);终端来源的引用没有路径可言。路径与行号写进围栏信息位,`sanitizePath` 把带空白或围栏字符的值挡掉。
342
344
  - **回复徽章依赖模型守格式**:模型没按指令输出 `「引用N」` 就没有徽章——无损,只是少了联动;输出越界的编号(数错了)保持纯文本。徽章的编号以回复**之前最近的一条带胶囊的消息**为准;切分的是助手消息的文本节点,DSH 若改了助手消息的渲染形态,徽章增强器要跟着调(与胶囊的 `isQuoteRun` 同一条既有限制)。
343
345
  - 依赖 DSH 客户端的内部接口(`conversation.input.overlay` 槽位、`inputTriggers.registerSource`、`conversation.input.shell(id).insertReference`、
344
346
  `reference-chip` 节点),DSH 升级有可能失效。
package/lib/client.js CHANGED
@@ -504,12 +504,51 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
504
504
  }
505
505
 
506
506
  /**
507
- * The file a preview selection came from, when the host exposes it. The
508
- * probe walks a few ancestors and takes the first path-looking attribute;
509
- * the preview DOM belongs to the host, so a miss is the normal case and
510
- * costs nothing — the quote then travels exactly as before.
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.
511
536
  */
512
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
+ }
513
552
  let node = host;
514
553
  for (let hop = 0; node !== null && node !== undefined && hop < 6; hop += 1, node = node.parentElement) {
515
554
  if (typeof node.getAttribute !== "function") continue;
@@ -562,9 +601,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
562
601
  const rect = range.getBoundingClientRect();
563
602
  if (rect.width === 0 && rect.height === 0) return null;
564
603
 
565
- // A preview selection may carry the file it came from: the path rides
566
- // in the fence's info string so the model can open the file itself.
567
- const path = hostKind === "preview" ? previewPathOf(host) : null;
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
+ }
568
617
 
569
618
  return {
570
619
  text,
@@ -2111,6 +2160,29 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2111
2160
  * its wording, not just its comment. Both halves map onto the wire form's
2112
2161
  * two marks, top to bottom.
2113
2162
  */
2163
+ /**
2164
+ * Grow one field to its content. A textarea that is not laid out yet
2165
+ * reports a scrollHeight of 0 — writing that back as `0px` collapsed the
2166
+ * quote half of the box and clipped its first line, so an unmeasurable
2167
+ * field is left on `auto` until the bar is on screen.
2168
+ */
2169
+ function growField(node) {
2170
+ node.style.height = "auto";
2171
+ const next = node.scrollHeight;
2172
+ if (!Number.isFinite(next) || next <= 0) return;
2173
+ node.style.height = Math.min(next, COMMENT_MAX_HEIGHT) + "px";
2174
+ }
2175
+
2176
+ /** Every field the bar is currently showing, re-measured. */
2177
+ function growInputs() {
2178
+ const bar = state.bar;
2179
+ if (bar === null || bar === undefined || typeof bar.querySelectorAll !== "function") return;
2180
+ for (const field of Array.from(bar.querySelectorAll(".dshq-input"))) {
2181
+ if (String(field.tagName).toLowerCase() !== "textarea") continue;
2182
+ growField(field);
2183
+ }
2184
+ }
2185
+
2114
2186
  function renderComment(bar) {
2115
2187
  const doc = bar.ownerDocument;
2116
2188
  bar.setAttribute("role", "dialog");
@@ -2137,10 +2209,6 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2137
2209
  "data-action": "confirm"
2138
2210
  }, [el(doc, "span", { text: translate("addToChat") }), el(doc, "span", { class: "dshq-enter", text: "⏎" })]);
2139
2211
 
2140
- const grow = (node) => {
2141
- node.style.height = "auto";
2142
- node.style.height = Math.min(node.scrollHeight, COMMENT_MAX_HEIGHT) + "px";
2143
- };
2144
2212
  // The quote half is mandatory now (an empty quote is nothing to send);
2145
2213
  // the comment keeps Kimi's rule of being required in this box.
2146
2214
  const syncConfirm = () => {
@@ -2148,12 +2216,12 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2148
2216
  };
2149
2217
  quoteInput.addEventListener("input", () => {
2150
2218
  state.quote = quoteInput.value;
2151
- grow(quoteInput);
2219
+ growField(quoteInput);
2152
2220
  syncConfirm();
2153
2221
  });
2154
2222
  input.addEventListener("input", () => {
2155
2223
  state.comment = input.value;
2156
- grow(input);
2224
+ growField(input);
2157
2225
  syncConfirm();
2158
2226
  });
2159
2227
  const onKey = (event) => {
@@ -2185,7 +2253,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2185
2253
  el(doc, "span", { text: translate("replyToggle") })
2186
2254
  ]);
2187
2255
 
2188
- grow(quoteInput);
2256
+ // No grow here: the fields are measured in `position()`, once the bar
2257
+ // takes part in layout. Reading them while detached returned 0.
2189
2258
  syncConfirm();
2190
2259
 
2191
2260
  bar.replaceChildren(
@@ -2209,6 +2278,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2209
2278
 
2210
2279
  bar.style.visibility = "hidden";
2211
2280
  bar.dataset.visible = "true";
2281
+ // The bar is in layout now (merely invisible), which is the first
2282
+ // moment its fields can be measured — and it has to happen before the
2283
+ // height below is read, or the box would be placed at the wrong place.
2284
+ growInputs();
2212
2285
 
2213
2286
  const viewport = window.visualViewport;
2214
2287
  const width = viewport === undefined || viewport === null ? window.innerWidth : viewport.width;
@@ -3717,6 +3790,7 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
3717
3790
  chipLabel,
3718
3791
  sanitizePath,
3719
3792
  previewPathOf,
3793
+ withLineRange,
3720
3794
  rememberChip,
3721
3795
  normalizeLabel,
3722
3796
  barTop,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.11.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), a selection from the file preview or the terminal travels to the model as a fenced code block carrying its file path, 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.",
3
+ "version": "0.11.2",
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
@@ -914,6 +914,21 @@ try {
914
914
  // this block is about what a mounted enhancer does with a revision.
915
915
  capsuleEffect.fn();
916
916
 
917
+ // A textarea measures itself from its own text — but only while it is in the
918
+ // document, the way the real one reads 0 before the bar is laid out. That is
919
+ // what made the auto-grow collapse the quote field.
920
+ const makeTextarea = dom.document.createElement.bind(dom.document);
921
+ dom.document.createElement = (tag) => {
922
+ const node = makeTextarea(tag);
923
+ if (String(tag).toLowerCase() === "textarea") {
924
+ Object.defineProperty(node, "scrollHeight", {
925
+ configurable: true,
926
+ get: () => (node.isConnected ? 12 + 12 * String(node.value ?? "").split("\n").length : 0)
927
+ });
928
+ }
929
+ return node;
930
+ };
931
+
917
932
  // A transcript of its own, with a capsule that has a comment.
918
933
  const host2 = dom.document.createElement("div");
919
934
  host2.setAttribute("data-conversation-content", "");
@@ -948,11 +963,16 @@ try {
948
963
  const input = bar?.querySelector(".dshq-comment-input");
949
964
  check("the box is prefilled with the old quote for editing", quoteBox?.value === original, JSON.stringify(quoteBox?.value));
950
965
  check("the box is prefilled with the old comment", input?.value === oldComment, JSON.stringify(input?.value));
966
+ // Regression: the quote half used to be measured before it was attached, so
967
+ // it was written `height: 0px` and its first line showed up clipped.
968
+ check("the quote field is grown to its text, not collapsed", quoteBox?.style?.height === "24px", JSON.stringify(quoteBox?.style?.height));
969
+ check("the comment field is grown too", input?.style?.height === "24px", JSON.stringify(input?.style?.height));
951
970
 
952
971
  // Correcting it writes a NEW quote (nothing is edited in place) and the old
953
972
  // capsule learns it was superseded.
954
973
  input.value = newComment;
955
974
  input.dispatch("input", {});
975
+ check("typing in a field re-measures it", input?.style?.height === "24px", JSON.stringify(input?.style?.height));
956
976
  // The bar handles clicks by delegation, so the click is dispatched on the bar
957
977
  // with the button as its target — the shim has no bubbling of its own.
958
978
  const confirm = bar.querySelector('[data-action="confirm"]');
@@ -1134,6 +1154,57 @@ try {
1134
1154
  JSON.stringify(testing.previewPathOf(mid))
1135
1155
  );
1136
1156
  check("previewPathOf misses cleanly on a bare host", testing.previewPathOf(dom.document.createElement("div")) === null);
1157
+
1158
+ // The real host shape: the path label lives in a header SIBLING of the body
1159
+ // the selection is in — the probe has to find it through the preview root,
1160
+ // reading the full path from the label's title.
1161
+ const previewRoot = dom.document.createElement("div");
1162
+ previewRoot.setAttribute("data-document-preview", "doc-1");
1163
+ const header = dom.document.createElement("div");
1164
+ const label = dom.document.createElement("span");
1165
+ label.setAttribute("data-textpreview-path", "");
1166
+ label.setAttribute("title", "src/app.ts");
1167
+ header.append(label);
1168
+ const previewBody = dom.document.createElement("div");
1169
+ previewBody.setAttribute("data-textpreview-body", "");
1170
+ previewRoot.append(header);
1171
+ previewRoot.append(previewBody);
1172
+ check(
1173
+ "previewPathOf reads the header's path label through the preview root",
1174
+ testing.previewPathOf(previewBody) === "src/app.ts",
1175
+ JSON.stringify(testing.previewPathOf(previewBody))
1176
+ );
1177
+ const clippedLabel = dom.document.createElement("span");
1178
+ clippedLabel.setAttribute("data-textpreview-path", "");
1179
+ clippedLabel.setAttribute("title", "my file.ts");
1180
+ const clippedRoot = dom.document.createElement("div");
1181
+ clippedRoot.setAttribute("data-document-preview", "doc-2");
1182
+ clippedRoot.append(clippedLabel, dom.document.createElement("div"));
1183
+ check(
1184
+ "a label whose path cannot be a token costs nothing",
1185
+ testing.previewPathOf(clippedRoot.lastChild) === null,
1186
+ JSON.stringify(testing.previewPathOf(clippedRoot.lastChild))
1187
+ );
1188
+
1189
+ // withLineRange: GitHub's #L spelling, normalized, or nothing.
1190
+ check(
1191
+ "withLineRange composes the GitHub line range",
1192
+ testing.withLineRange("src/app.ts", 12, 48) === "src/app.ts#L12-L48" &&
1193
+ testing.withLineRange("src/app.ts", 7, 7) === "src/app.ts#L7" &&
1194
+ testing.withLineRange("src/app.ts", 48, 12) === "src/app.ts#L12-L48",
1195
+ JSON.stringify([testing.withLineRange("src/app.ts", 12, 48), testing.withLineRange("src/app.ts", 7, 7), testing.withLineRange("src/app.ts", 48, 12)])
1196
+ );
1197
+ check(
1198
+ "withLineRange degrades honestly",
1199
+ 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",
1200
+ JSON.stringify([testing.withLineRange("my file.ts", 1, 2), testing.withLineRange("src/a.ts", null, 4), testing.withLineRange("src/a.ts", 0, 4)])
1201
+ );
1202
+ check(
1203
+ "a path with a range still parses back as the fence info string",
1204
+ testing.splitQuoteRuns(testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts#L12-L48" }))?.[0]?.code === "src/app.ts#L12-L48",
1205
+ JSON.stringify(testing.splitQuoteRuns(testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts#L12-L48" }))
1206
+ )
1207
+ );
1137
1208
  }
1138
1209
 
1139
1210
  /* ------------------------------------------------------------------ *