dsh-quote-followup 0.2.0 → 0.2.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
@@ -2,116 +2,39 @@
2
2
 
3
3
  English | [中文](README.zh.md)
4
4
 
5
- Quote **selected conversation content** into a **targeted follow-up turn** —
6
- one dsh plugin package, two faces:
5
+ A Web-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin for quoting selected conversation text into the composer and asking a focused follow-up.
7
6
 
8
- - **dsh-TUI face**: `Ctrl+Alt+Q` opens a message picker (recent user/assistant
9
- messages, newest first); each pick appends a quote block into the prompt
10
- input — repeatable, editable before sending.
11
- - **Web face (dsh web)**: **select any text span** in the transcript, click the
12
- floating "❐ 引用" button, and the quote block lands at the composer's caret.
7
+ ## Features
13
8
 
14
- The quote is a plain markdown blockquote (`> [引用 · assistant#5]`)
15
- **visible, editable, no hidden injection**: the model sees exactly what you
16
- see in the input box.
9
+ - Select text inside the Web conversation transcript to reveal a floating **Quote** button.
10
+ - Append the selection as a Markdown quote without replacing the existing draft.
11
+ - Repeat the action to collect multiple excerpts before sending.
12
+ - Dispatch DSH's live Lexical `PASTE_COMMAND` directly, keeping the editor model and rendered composer in sync across Chromium and Firefox.
17
13
 
18
- ## How it works (public seams only, zero core patches)
19
-
20
- | Stage | TUI face | Web face |
21
- | --- | --- | --- |
22
- | Message source | `session/event` firehose (the ecosystem template's sanctioned seam), buffered per session | live transcript DOM in the browser |
23
- | Entry interaction | `ctx.tuiShortcuts` (global combo) + `ctx.tuiDialogs` (picker) | native text selection + floating button |
24
- | Writing the input | the dsh.nvim injection socket contract (`~/.dsh-tui/inject/<sessionId>.sock`, `prompt.append`) | direct composer element writes (native setter + input event) |
25
- | Feedback | `ctx.tuiToast` | console warnings on failure only |
26
-
27
- Component admission / `tui/input` interception are deliberately **not** used:
28
- as of dsh-TUI 0.10.x the admission capability has no public production entry
29
- (the `plugin-host` shim does not export it; test-utils is marked test-only),
30
- and the intercept-class permission that rewrites submitted input is
31
- deny-by-default. The selection→input-box pattern needs neither, matches the
32
- Web face behavior, and stays fully transparent to the user.
14
+ TUI is intentionally unsupported.
33
15
 
34
16
  ## Install
35
17
 
36
- ```sh
37
- # TUI profile
38
- dsh plugin --profile dsh-tui add dsh-quote-followup
39
- # Web profile (the browser half loads via the dsh.client declaration;
40
- # restart dsh web)
18
+ ```bash
41
19
  dsh plugin --profile web add dsh-quote-followup
42
20
  ```
43
21
 
44
- Add `dsh-quote-followup` to `dsh.profile.bundles` and restart. No grants file
45
- (`extension-grants.json`), no TUI patches.
46
-
47
- ## Usage
48
-
49
- ### TUI
50
-
51
- **Selection quoting (recommended — matches the Web face)**: in fullscreen,
52
- dsh-TUI's copy-on-select copies any mouse selection to the clipboard and
53
- clears the highlight. After selecting, press `Ctrl+Alt+Q` — the **first
54
- picker row is exactly the text you just selected** (`📋 划选/剪贴板 · …`);
55
- Enter quotes it.
56
-
57
- **Whole-message quoting**: `Ctrl+Alt+Q` → picker lists recent messages
58
- (`#seq me/assistant · summary`, arrows + Enter); mixes freely with the
59
- selection row and repeats.
60
-
61
- Type your question below the quote blocks and send.
62
-
63
- > Combo delivery note: the TUI enables the kitty keyboard protocol, so
64
- > `Ctrl+Alt+Q` arrives reliably as CSI-u on modern terminals (iTerm2 3.5+,
65
- > kitty, WezTerm, Ghostty, …). If your terminal does not deliver the combo,
66
- > remap `shortcut` in the row config to any ctrl/alt combo it does deliver.
67
-
68
- ### Web (dsh web GUI)
69
-
70
- 1. Select any span in the transcript (cross-line, any granularity).
71
- 2. The "❐ 引用" button appears above the selection — click it.
72
- 3. The quote block is appended to the composer; keep quoting or type your
73
- question, then send.
74
-
75
- ## Configuration (optional row config override)
76
-
77
- Patch rows replace the whole `config`, so restate every key when overriding:
78
-
79
- ```yaml
80
- - id: quote-followup
81
- name: dsh-quote-followup
82
- config:
83
- shortcut: ctrl+alt+q # needs ctrl or alt; avoid reserved combos
84
- pickerLimit: 30 # max messages listed
85
- quoteMaxChars: 1600 # per-quote truncation bound (chars)
86
- clipboardReadCommand: '' # optional custom clipboard reader (/bin/sh -c;
87
- # default probes pbpaste / wl-paste / xclip / xsel)
88
- ```
22
+ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restart `dsh web` so the server rebuilds its boot-time client bundle. Reload or reopen browser tabs that stayed open across the restart; their already-loaded JavaScript cannot update itself from a new server process.
89
23
 
90
- ## Known edges
24
+ ## Use
91
25
 
92
- - **Live messages only**: `session/event` does not replay history (resume
93
- seeds do not emit), so messages from before a `/resume` or restart never
94
- appear in the picker.
95
- - **Picker target session**: the TUI face targets the session of the latest
96
- user message; pressing the combo immediately after `/resume` (before any new
97
- message) may list the previous session's buffer.
98
- - **Role detection (Web)**: best-effort heuristics; unknown roles are labeled
99
- "对话".
100
- - The Web face writes input-box text — what you see is what the model gets.
101
- Thinking / tool calls are not quotable.
26
+ 1. Select text in a conversation message.
27
+ 2. Click **❐ Quote**.
28
+ 3. Select and quote more excerpts if needed.
29
+ 4. Type the follow-up below the inserted quotes and send.
102
30
 
103
31
  ## Development
104
32
 
105
- ```sh
106
- git clone <repo> && cd dsh-quote-followup
107
- dsh plugin --profile dsh-tui add link:$(pwd)
108
- dsh plugin --profile web add link:$(pwd)
33
+ ```bash
34
+ npm test
109
35
  ```
110
36
 
111
- Pure ESM, zero runtime dependencies; `lib/index.js` (host half, Node) +
112
- `lib/client.js` (browser half, a `window.__ModuleLoader__` closure factory).
113
- A headless E2E harness mounts the real extensions row plus this plugin and
114
- drives session/event → shortcut → dialog → injection socket end to end.
37
+ The regression harness covers repeated quotes, Firefox rejecting constructor-injected `clipboardData`, Lexical reconciliation of raw DOM fallbacks, and takeover from a stale client button/singleton after a hot swap.
115
38
 
116
39
  ## License
117
40
 
package/README.zh.md CHANGED
@@ -2,102 +2,40 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- 把**选中的对话内容**引用进**针对性的下一轮追问**——同一个 dsh 插件包,两张界面:
5
+ 一个仅支持 Web 的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:选中对话文本,将其追加到输入框,再进行针对性追问。
6
6
 
7
- - **dsh-TUI 面**:`Ctrl+Alt+Q` 打开消息选择器(最近的用户/助手消息,最新在前),
8
- 选中后引用块直接落入输入框,可多次追加、发送前可编辑。
9
- - **Web 面(dsh web)**:在对话记录里**划选任意文本片段**,出现浮动「❐ 引用」按钮,
10
- 点击后引用块插入输入框光标处。
7
+ ## 功能
11
8
 
12
- 引用块是普通的 markdown blockquote(`> [引用 · assistant#5]`),**可见、可编辑、
13
- 无隐藏注入**——模型看到的就是你在输入框里看到的内容。
9
+ - Web 对话区选中文本后显示浮动的 **❐ 引用** 按钮。
10
+ - 以 Markdown 引用块追加到现有草稿,不覆盖已输入内容。
11
+ - 可连续引用多段内容,再统一编辑并发送。
12
+ - 直接派发 DSH 当前 Lexical editor 的 `PASTE_COMMAND`,在 Chromium 与 Firefox 中同步更新编辑器模型和 DOM。
14
13
 
15
- ## 工作原理(全部走公开插件缝,零核心 patch)
16
-
17
- | 环节 | TUI 面 | Web 面 |
18
- | --- | --- | --- |
19
- | 消息来源 | `session/event` 事件流(生态模板认可的标准接缝),按会话缓冲 | 浏览器实时读取 transcript DOM |
20
- | 入口交互 | `ctx.tuiShortcuts`(全局快捷键)+ `ctx.tuiDialogs`(选择弹窗) | 原生文本选区 + 浮动按钮 |
21
- | 写入输入框 | dsh.nvim 约定的注入套接字(`~/.dsh-tui/inject/<sessionId>.sock`,`prompt.append`) | 直接操作 composer 输入元素(原生 setter + input 事件) |
22
- | 提示反馈 | `ctx.tuiToast` | 控制台警告(仅失败时) |
23
-
24
- 刻意**不使用** Component admission / `tui/input` 拦截:当前(dsh-TUI 0.10.x)
25
- 准入能力没有公开的生产入口(`plugin-host` shim 不导出、test-utils 标注 test-only),
26
- 改写已提交输入的拦截类权限也默认拒绝。选区→输入框的模式不需要其中任何一个,
27
- 而且与 Web 面行为一致、对用户完全透明。
14
+ 本插件不再适配 TUI。
28
15
 
29
16
  ## 安装
30
17
 
31
- ```sh
32
- # TUI profile
33
- dsh plugin --profile dsh-tui add dsh-quote-followup
34
- # Web profile(浏览器半区由 dsh.client 声明自动加载,需重启 dsh web)
18
+ ```bash
35
19
  dsh plugin --profile web add dsh-quote-followup
36
20
  ```
37
21
 
38
- bundle 清单(`dsh.profile.bundles`)加上 `dsh-quote-followup` 后重启即生效。
39
- 无需授权文件(`extension-grants.json`)、无需任何 TUI patch。
22
+ `dsh-quote-followup` 加入 Web profile 的 `dsh.profile.bundles`,然后重启 `dsh web`,让服务端重新生成启动时缓存的 client bundle。重启后还需刷新或重新打开此前一直未关闭的浏览器页;旧页面已经加载的 JavaScript 不会从新服务进程自动更新。
40
23
 
41
24
  ## 使用
42
25
 
43
- ### TUI
44
-
45
- **划选引用(推荐,与 Web 面体验一致)**:dsh-TUI 全屏模式下鼠标划选文本会
46
- 自动复制到剪贴板并清除高亮(copy-on-select)。划选后按 `Ctrl+Alt+Q`,
47
- 选择器**第一行就是刚划选的内容**(`📋 划选/剪贴板 · …`),回车即引用。
48
-
49
- **整条消息引用**:`Ctrl+Alt+Q` → 选择器列出最近消息(`#序号 我/助手 · 摘要`,
50
- 方向键 + Enter),可与划选行混用、多次追加。
51
-
52
- 引用块落入输入框后在下方写下问题,正常发送。
53
-
54
- > 快捷键送达说明:TUI 启用了 kitty 键盘协议,现代终端(iTerm2 3.5+、kitty、
55
- > WezTerm、Ghostty 等)下 `Ctrl+Alt+Q` 以 CSI-u 编码可靠送达。若你的终端不发
56
- > 该组合,在行 config 里把 `shortcut` 换成任意可送达的 ctrl/alt 组合即可。
57
-
58
- ### Web(dsh web GUI)
59
-
60
- 1. 在对话记录中划选任意片段(支持跨行,任意粒度)。
61
- 2. 选区上方出现「❐ 引用」按钮,点击。
62
- 3. 引用块追加到输入框末尾(多次引用多次追加),继续输入问题后发送。
63
-
64
- ## 配置(可选,覆盖行 config)
65
-
66
- `cordis.patch.yml` 的行 config 整体替换语义,覆盖时需完整重述:
67
-
68
- ```yaml
69
- - id: quote-followup
70
- name: dsh-quote-followup
71
- config:
72
- shortcut: ctrl+alt+q # 需带 ctrl 或 alt;避开保留组合
73
- pickerLimit: 30 # 选择器列出的消息数上限
74
- quoteMaxChars: 1600 # 单条引用的截断上限(字符)
75
- clipboardReadCommand: '' # 可选:自定义剪贴板读取命令(/bin/sh -c;
76
- # 默认 pbpaste / wl-paste / xclip / xsel 探测)
77
- ```
78
-
79
- ## 已知边界
80
-
81
- - **只有 live 消息可引用**:`session/event` 不回放历史(resume 的种子事件不发出),
82
- `/resume` 或重启后、第一条新消息之前的旧消息不出现在选择器里。
83
- - **选择器目标会话**:TUI 面以「最近一条用户消息所在的会话」为准;`/resume` 后
84
- 立即按 `Ctrl+Alt+Q`(还没有新消息时)可能列出上一会话的缓冲。
85
- - **角色识别(Web)**:best-effort(data-role/class 启发式),识别不了标注为「对话」。
86
- - Web 面的注入走输入框文本,所见即所得; Thinking / 工具调用不在可引用范围。
26
+ 1. 在对话消息中划选任意片段。
27
+ 2. 点击 **❐ 引用**。
28
+ 3. 如需补充,可继续选中并引用其他片段。
29
+ 4. 在引用块下输入追问并发送。
87
30
 
88
- ## 开发
31
+ ## 开发验证
89
32
 
90
- ```sh
91
- git clone <repo> && cd dsh-quote-followup
92
- dsh plugin --profile dsh-tui add link:$(pwd)
93
- dsh plugin --profile web add link:$(pwd)
33
+ ```bash
34
+ npm test
94
35
  ```
95
36
 
96
- - ESM、零运行时依赖;`lib/index.js`(host 半区,Node)+ `lib/client.js`
97
- (浏览器半区,`window.__ModuleLoader__` 闭包工厂)。
98
- - 无头 E2E:挂载真实 extensions 行 + 本插件行,驱动 session/event →
99
- 快捷键 → 弹窗 → 注入套接字全链路(见仓库 docs/harness 说明)。
37
+ 回归测试覆盖连续引用、Firefox 丢弃构造参数中的 `clipboardData`、Lexical 回滚裸 DOM fallback,以及热替换后新版 client 接管旧按钮/单例状态。
100
38
 
101
- ## License
39
+ ## 许可证
102
40
 
103
41
  MIT
package/cordis.patch.yml CHANGED
@@ -1,7 +1,5 @@
1
- # dsh-quote-followup bundle patch: one row over whatever layers came before.
2
- # Zero config by design the quoting flow needs no machine-local values.
3
- # A profile may still override the row (config: {shortcut, pickerLimit,
4
- # quoteMaxChars}) by restating the whole config block, per patch semantics.
1
+ # dsh-quote-followup Web bundle patch.
2
+ # The host row is inert; it mounts the package's browser client entry.
5
3
 
6
4
  - insert:
7
5
  - id: quote-followup
package/lib/client.js CHANGED
@@ -32,7 +32,9 @@ window.__ModuleLoader__.load({
32
32
  '[data-input-scroll] textarea'
33
33
  ];
34
34
  const BUTTON_ID = "dsh-quote-followup-btn";
35
- /** Per-quote character cap, mirroring the TUI face. */
35
+ const BUTTON_VERSION_ATTR = "data-dsh-quote-followup-version";
36
+ const CLIENT_VERSION = "0.2.2";
37
+ /** Per-quote character cap for a bounded composer insertion. */
36
38
  const QUOTE_MAX_CHARS = 1600;
37
39
  //#endregion
38
40
  //#region helpers
@@ -91,7 +93,41 @@ window.__ModuleLoader__.load({
91
93
  }
92
94
  return null;
93
95
  };
94
- /** Dispatch a real paste path so stateful editors (Lexical) own the update. */
96
+ /**
97
+ * Use the live Lexical command table when DSH exposes its editor on the root.
98
+ * This bypasses browser security differences around synthetic clipboardData
99
+ * while still entering through DSH's own PASTE_COMMAND → keyboard.paste path.
100
+ */
101
+ const pasteViaLexical = (element, text) => {
102
+ const editor = element.__lexicalEditor;
103
+ if (editor === null || typeof editor !== "object" || typeof editor.dispatchCommand !== "function")
104
+ return false;
105
+ const commands = editor._commands;
106
+ if (commands === null || typeof commands !== "object" || typeof commands.keys !== "function")
107
+ return false;
108
+ const command = [...commands.keys()].find((candidate) => candidate?.type === "PASTE_COMMAND");
109
+ if (command === undefined)
110
+ return false;
111
+ const clipboardData = {
112
+ files: [],
113
+ items: [],
114
+ types: ["text/plain"],
115
+ getData: (type) => type === "text/plain" ? text : ""
116
+ };
117
+ const event = {
118
+ clipboardData,
119
+ defaultPrevented: false,
120
+ preventDefault() { this.defaultPrevented = true; },
121
+ stopPropagation() {}
122
+ };
123
+ try {
124
+ return editor.dispatchCommand(command, event) === true;
125
+ } catch (error) {
126
+ console.warn("[dsh-quote-followup] Lexical paste command failed; using DOM event fallback", error);
127
+ return false;
128
+ }
129
+ };
130
+ /** Dispatch a DOM paste fallback for non-DSH stateful editors. */
95
131
  const pasteIntoEditor = (element, text) => {
96
132
  let clipboardData = null;
97
133
  try {
@@ -139,10 +175,18 @@ window.__ModuleLoader__.load({
139
175
  }
140
176
  const selection = document.getSelection();
141
177
  const range = document.createRange();
142
- range.selectNodeContents(element);
178
+ // A Lexical root with existing block children does not admit a caret at
179
+ // `root.childNodes.length`: its paste listener ignores that DOM point,
180
+ // then mutation reconciliation deletes the raw-text fallback. Put the
181
+ // caret INSIDE the last block instead. The empty-editor case still uses
182
+ // the root because it has no block yet.
183
+ const caretContainer = element.lastElementChild ?? element;
184
+ range.selectNodeContents(caretContainer);
143
185
  range.collapse(false);
144
186
  selection?.removeAllRanges();
145
187
  selection?.addRange(range);
188
+ if (pasteViaLexical(element, insertText))
189
+ return;
146
190
  if (pasteIntoEditor(element, insertText))
147
191
  return;
148
192
  if (typeof document.execCommand === "function" && document.execCommand("insertText", false, insertText))
@@ -156,10 +200,12 @@ window.__ModuleLoader__.load({
156
200
  let pendingQuote = null;
157
201
  const ensureButton = () => {
158
202
  const existing = document.getElementById(BUTTON_ID);
159
- if (existing !== null)
203
+ if (existing?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
160
204
  return existing;
205
+ existing?.remove();
161
206
  const button = document.createElement("button");
162
207
  button.id = BUTTON_ID;
208
+ button.setAttribute(BUTTON_VERSION_ATTR, CLIENT_VERSION);
163
209
  button.type = "button";
164
210
  button.textContent = "❐ 引用";
165
211
  button.title = "引用选中内容到输入框(可多次引用,发送前可编辑)";
@@ -245,11 +291,13 @@ window.__ModuleLoader__.load({
245
291
  };
246
292
  //#endregion
247
293
  //#region apply
248
- const MOUNTED = Symbol.for("dsh-quote-followup.mounted");
294
+ const STATE = Symbol.for("dsh-quote-followup.state");
249
295
  function apply(ctx) {
250
- if (globalThis[MOUNTED] === true)
296
+ const previous = globalThis[STATE];
297
+ if (previous?.version === CLIENT_VERSION)
251
298
  return;
252
- globalThis[MOUNTED] = true;
299
+ if (typeof previous?.dispose === "function")
300
+ previous.dispose();
253
301
  const onScroll = () => hideButton();
254
302
  const onKeyDown = (event) => {
255
303
  if (event.key === "Escape")
@@ -259,19 +307,31 @@ window.__ModuleLoader__.load({
259
307
  if (event.target instanceof Element && event.target.id !== BUTTON_ID)
260
308
  hideButton();
261
309
  };
262
- document.addEventListener("selectionchange", onSelectionChange);
263
- window.addEventListener("scroll", onScroll, true);
264
- document.addEventListener("keydown", onKeyDown, true);
265
- document.addEventListener("mousedown", onMouseDown, true);
310
+ let disposed = false;
311
+ let state;
266
312
  const cleanup = () => {
267
- globalThis[MOUNTED] = false;
313
+ if (disposed)
314
+ return;
315
+ disposed = true;
268
316
  document.removeEventListener("selectionchange", onSelectionChange);
269
317
  window.removeEventListener("scroll", onScroll, true);
270
318
  document.removeEventListener("keydown", onKeyDown, true);
271
319
  document.removeEventListener("mousedown", onMouseDown, true);
272
- hideButton();
273
- document.getElementById(BUTTON_ID)?.remove();
320
+ const button = document.getElementById(BUTTON_ID);
321
+ if (button?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
322
+ button.remove();
323
+ if (globalThis[STATE] === state)
324
+ delete globalThis[STATE];
274
325
  };
326
+ state = { version: CLIENT_VERSION, dispose: cleanup };
327
+ globalThis[STATE] = state;
328
+ document.addEventListener("selectionchange", onSelectionChange);
329
+ window.addEventListener("scroll", onScroll, true);
330
+ document.addEventListener("keydown", onKeyDown, true);
331
+ document.addEventListener("mousedown", onMouseDown, true);
332
+ // Claim the shared id immediately so an older listener in a long-lived tab
333
+ // cannot keep its obsolete click handler after a client hot-swap.
334
+ ensureButton();
275
335
  if (ctx !== undefined && typeof ctx.effect === "function")
276
336
  ctx.effect(() => cleanup, "dsh-quote-followup: selection quote ui");
277
337
  }
package/lib/index.d.ts CHANGED
@@ -1,46 +1,4 @@
1
- /**
2
- * dsh-quote-followup host half (TUI face) — type surface.
3
- * @module dsh-quote-followup
4
- */
5
- import type { Context } from '@deepseek-ai/cordis';
6
-
7
- /** One quoted message (`seq: null` marks a mouse-selection/clipboard quote). */
8
- export interface QuoteEntry {
9
- seq: number | null;
10
- role: 'user' | 'assistant' | '划选';
11
- text: string;
12
- }
13
-
14
- /** Row config (all optional; see cordis.patch.yml). */
15
- export interface QuoteFollowupConfig {
16
- /** Global combo for the picker (needs ctrl or alt). Default 'ctrl+alt+q'. */
17
- shortcut?: string;
18
- /** Picker rows shown, newest first. Default 30. */
19
- pickerLimit?: number;
20
- /** Per-quote character cap. Default 1600. */
21
- quoteMaxChars?: number;
22
- /** Custom clipboard reader (`/bin/sh -c`; defaults to pbpaste / wl-paste /
23
- * xclip / xsel probing). */
24
- clipboardReadCommand?: string;
25
- }
26
-
27
- /** Clip a string to `max` cells with an ellipsis marker. */
28
- export declare function clip(text: string, max: number): string;
29
-
30
- /** Project one `session/event` record onto a bufferable message (or null). */
31
- export declare function extractMessage(session: { id: string } | null, event: {
32
- type?: string;
33
- seq?: number;
34
- data?: unknown;
35
- }): (QuoteEntry & { sessionId: string }) | null;
36
-
37
- /** Compose the quote block appended into the prompt input. */
38
- export declare function frameQuotes(quotes: QuoteEntry[], quoteMaxChars?: number): string;
39
-
40
- /** Read the system clipboard ('' on failure / unsupported platform). */
41
- export declare function readClipboard(overrideCommand?: string): Promise<string>;
42
-
43
- export declare const name: string;
44
- export declare const inject: string[];
45
-
46
- export declare function apply(ctx: Context, config?: QuoteFollowupConfig): void;
1
+ /** Web-only host stub; the feature is implemented by `lib/client.js`. */
2
+ export declare const name = "quote-followup";
3
+ export declare const inject: readonly [];
4
+ export declare function apply(): void;
package/lib/index.js CHANGED
@@ -1,381 +1,11 @@
1
1
  /**
2
- * dsh-quote-followup host half (TUI face).
2
+ * dsh-quote-followup host face.
3
3
  *
4
- * Quote selected conversation content into a targeted follow-up turn,
5
- * using only public, activation-gated seams (no Component admission, no
6
- * grants file, no TUI patches):
7
- *
8
- * - `session/event` (the ecosystem template's sanctioned firehose) buffers
9
- * user/assistant messages per session id.
10
- * - `ctx.tuiShortcuts.register` binds Ctrl+Alt+Q; `ctx.tuiDialogs.select`
11
- * lists recent messages (newest first, single pick, repeatable).
12
- * - The per-session injection socket (`~/.dsh-tui/inject/<sessionId>.sock`,
13
- * the dsh.nvim contract) appends the quote block into the live prompt
14
- * input — visible and editable before sending, exactly like the Web face.
15
- * - `ctx.tuiToast` reports outcomes.
16
- *
17
- * On profiles without the TUI seams (dsh web) this row is inert: the browser
18
- * half in ./client.js implements the Web face there.
19
- *
20
- * Known edge: the picker targets the session that received the most recent
21
- * user message; right after `/resume` (before the first new message) it may
22
- * briefly show the previous session's buffer. Documented, harmless.
23
- *
24
- * @module dsh-quote-followup
4
+ * The feature is Web-only. The host entry intentionally stays inert; its sole
5
+ * purpose is to let DSH mount the package's `dsh.client` browser entry.
25
6
  */
26
- import { createConnection } from 'node:net';
27
- import { execFile } from 'node:child_process';
28
- import { readFileSync } from 'node:fs';
29
- import { homedir } from 'node:os';
30
- import { join } from 'node:path';
31
7
 
32
- /** Stable Cordis plugin name (row id lives in cordis.patch.yml). */
33
8
  export const name = 'quote-followup';
34
- /** Services are resolved lazily via ctx.get so a bare mount degrades softly. */
35
9
  export const inject = [];
36
10
 
37
- /** Default global combo (ctrl/alt mandatory; ctrl+q is the todo-fold action). */
38
- const DEFAULT_SHORTCUT = 'ctrl+alt+q';
39
- /** Messages kept per session (session/event has no replay; live only). */
40
- const BUFFER_PER_SESSION = 200;
41
- /** Picker rows shown (newest first). */
42
- const DEFAULT_PICKER_LIMIT = 30;
43
- /** Per-quote character cap before truncation marker. */
44
- const DEFAULT_QUOTE_MAX_CHARS = 1600;
45
- /** Dialog auto-cancel bound (guards a wedged flow on headless embedders). */
46
- const PICKER_TIMEOUT_MS = 180000;
47
- /** How long to wait for sibling TUI services before going inert (ms). */
48
- const SERVICE_WAIT_MS = 3000;
49
- /** Picker row id for the clipboard quote (dsh-TUI copies mouse selections to
50
- * the clipboard automatically — copy-on-select — so the freshest mouse
51
- * selection is always quotable through this row). */
52
- const CLIPBOARD_ROW_ID = '__clipboard__';
53
- /** Injection-channel discovery file (see dsh-tui's inject-channel module). */
54
- const INJECT_SERVERS_FILE = join(homedir(), '.dsh-tui', 'inject', 'servers.json');
55
-
56
- /** One quoted message. */
57
- /** @typedef {{seq: number, role: 'user'|'assistant', text: string}} QuoteEntry */
58
-
59
- /** Clip a string to `max` cells with an ellipsis marker. Exported for tests. */
60
- export function clip(text, max) {
61
- const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
62
- return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 1))}…`;
63
- }
64
-
65
- /** Join the text blocks of an MCP content array. */
66
- function textOfContent(content) {
67
- if (!Array.isArray(content))
68
- return '';
69
- return content
70
- .filter(block => block !== null && typeof block === 'object' && block.type === 'text')
71
- .map(block => String(block.text ?? ''))
72
- .join('\n')
73
- .trim();
74
- }
75
-
76
- /**
77
- * Project one `session/event` record onto a bufferable message.
78
- * Returns null for anything that is not a user/assistant text message.
79
- * Exported for tests.
80
- */
81
- export function extractMessage(session, event) {
82
- if (event === null || typeof event !== 'object')
83
- return null;
84
- const type = event.type;
85
- if (type !== 'user/message' && type !== 'assistant/message')
86
- return null;
87
- const id = session !== null && typeof session === 'object' && typeof session.id === 'string' ? session.id : null;
88
- if (id === null)
89
- return null;
90
- const data = event.data;
91
- const record = data !== null && typeof data === 'object' ? data : {};
92
- const message = type === 'assistant/message' && record.message !== null && typeof record.message === 'object'
93
- ? record.message
94
- : record;
95
- const text = textOfContent(message.content);
96
- if (text === '')
97
- return null;
98
- return {
99
- sessionId: id,
100
- seq: typeof event.seq === 'number' ? event.seq : -1,
101
- role: type === 'user/message' ? 'user' : 'assistant',
102
- text,
103
- };
104
- }
105
-
106
- /** Quote lines as a markdown blockquote, capped at `quoteMaxChars`. */
107
- function quoteBlock(entry, index, total, quoteMaxChars) {
108
- let body = entry.text;
109
- let truncated = false;
110
- if (body.length > quoteMaxChars) {
111
- body = body.slice(0, quoteMaxChars);
112
- truncated = true;
113
- }
114
- const tail = entry.seq === null || entry.seq === undefined ? '' : `#${entry.seq}`;
115
- const header = `> [引用 ${index}/${total} · ${entry.role}${tail}${truncated ? ',已截断' : ''}]`;
116
- const lines = body.split('\n').map(line => `> ${line}`.trimEnd());
117
- return `${header}\n${lines.join('\n')}`;
118
- }
119
-
120
- /**
121
- * Compose the quote block appended into the prompt input. Exported for tests.
122
- */
123
- export function frameQuotes(quotes, quoteMaxChars = DEFAULT_QUOTE_MAX_CHARS) {
124
- const blocks = quotes.map((entry, i) => quoteBlock(entry, i + 1, quotes.length, quoteMaxChars));
125
- return `\n${blocks.join('\n\n')}\n\n`;
126
- }
127
-
128
- /** Read the injection-channel discovery file; [] when absent/unreadable. */
129
- function readInjectServers() {
130
- try {
131
- const parsed = JSON.parse(readFileSync(INJECT_SERVERS_FILE, 'utf8'));
132
- if (!Array.isArray(parsed))
133
- return [];
134
- return parsed.filter(entry => entry !== null && typeof entry === 'object'
135
- && typeof entry.pid === 'number' && typeof entry.socketPath === 'string');
136
- }
137
- catch {
138
- return [];
139
- }
140
- }
141
-
142
- /** One-shot line write to a local injection socket; resolves true on delivery. */
143
- function injectAppend(socketPath, text) {
144
- return new Promise(resolve => {
145
- let socket;
146
- let settled = false;
147
- const finish = (ok) => {
148
- if (settled)
149
- return;
150
- settled = true;
151
- socket?.destroy();
152
- resolve(ok);
153
- };
154
- try {
155
- socket = createConnection(socketPath, () => {
156
- try {
157
- socket.write(`${JSON.stringify({ type: 'prompt.append', text })}\n`);
158
- socket.end();
159
- finish(true);
160
- }
161
- catch {
162
- finish(false);
163
- }
164
- });
165
- socket.on('error', () => finish(false));
166
- setTimeout(() => finish(false), 1500).unref?.();
167
- }
168
- catch {
169
- finish(false);
170
- }
171
- });
172
- }
173
-
174
- /**
175
- * Read the system clipboard. dsh-TUI's copy-on-select already places every
176
- * mouse selection there, so this is the bridge between terminal text
177
- * selection and the quote picker. `overrideCommand` (row config
178
- * `clipboardReadCommand`) runs via /bin/sh — mainly a deterministic seam
179
- * for tests and exotic setups. Resolves '' on any failure.
180
- */
181
- export function readClipboard(overrideCommand) {
182
- const run = (file, args) => new Promise(resolve => {
183
- try {
184
- execFile(file, args, { timeout: 1200, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' }, (error, stdout) => {
185
- resolve(error === null ? String(stdout ?? '') : '');
186
- });
187
- }
188
- catch {
189
- resolve('');
190
- }
191
- });
192
- return (async () => {
193
- if (typeof overrideCommand === 'string' && overrideCommand.trim() !== '')
194
- return run('/bin/sh', ['-c', overrideCommand]);
195
- if (process.platform === 'darwin')
196
- return run('pbpaste', []);
197
- if (process.platform === 'linux') {
198
- for (const probe of [['wl-paste', []], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
199
- const text = await run(probe[0], probe[1]);
200
- if (text !== '')
201
- return text;
202
- }
203
- }
204
- return '';
205
- })();
206
- }
207
-
208
- /**
209
- * Apply: wire the TUI face when (and only when) the TUI extension seams are
210
- * mounted. Every registration is scoped with ctx.effect so a profile that
211
- * drops this row leaves nothing behind.
212
- */
213
- export function apply(ctx, config) {
214
- const shortcut = typeof config?.shortcut === 'string' && config.shortcut.trim() !== ''
215
- ? config.shortcut.trim()
216
- : DEFAULT_SHORTCUT;
217
- const pickerLimit = Number.isInteger(config?.pickerLimit) && config.pickerLimit > 0
218
- ? config.pickerLimit
219
- : DEFAULT_PICKER_LIMIT;
220
- const quoteMaxChars = Number.isInteger(config?.quoteMaxChars) && config.quoteMaxChars > 0
221
- ? config.quoteMaxChars
222
- : DEFAULT_QUOTE_MAX_CHARS;
223
- const clipboardCommand = typeof config?.clipboardReadCommand === 'string'
224
- ? config.clipboardReadCommand
225
- : undefined;
226
-
227
- /** Per-session ring buffer of observed messages. */
228
- const buffers = new Map();
229
- /** Session the picker targets: the one with the latest user message. */
230
- let currentSessionId = null;
231
- let wired = false;
232
- /** Disposers accumulated by wire(); drained by the keep-alive effect. */
233
- const disposers = [];
234
- const keep = (dispose) => {
235
- if (typeof dispose === 'function' || typeof dispose === 'boolean')
236
- disposers.push(dispose);
237
- };
238
-
239
- const bufferOf = (sessionId) => {
240
- let list = buffers.get(sessionId);
241
- if (list === undefined) {
242
- list = [];
243
- buffers.set(sessionId, list);
244
- }
245
- return list;
246
- };
247
- const toast = (text) => {
248
- try {
249
- ctx.get('tuiToast')?.show(text);
250
- }
251
- catch {
252
- /* toast-less hosts: the dialog/keyboard flow still works */
253
- }
254
- };
255
-
256
- const onSessionEvent = (session, event) => {
257
- try {
258
- const message = extractMessage(session, event);
259
- if (message === null)
260
- return;
261
- const list = bufferOf(message.sessionId);
262
- list.push({ seq: message.seq, role: message.role, text: message.text });
263
- if (list.length > BUFFER_PER_SESSION)
264
- list.splice(0, list.length - BUFFER_PER_SESSION);
265
- if (message.role === 'user')
266
- currentSessionId = message.sessionId;
267
- }
268
- catch (error) {
269
- ctx.logger.warn(`dsh-quote-followup: session event dropped: ${error instanceof Error ? error.message : String(error)}`);
270
- }
271
- };
272
- const onSessionDisposed = (session) => {
273
- const id = session !== null && typeof session === 'object' && typeof session.id === 'string' ? session.id : null;
274
- if (id !== null)
275
- buffers.delete(id);
276
- };
277
-
278
- /** Ctrl+Alt+Q → picker; each pick appends one quote block to the input. */
279
- const openPicker = async () => {
280
- const dialogs = ctx.get('tuiDialogs');
281
- if (dialogs === undefined) {
282
- toast('对话框服务不可用(需要 dsh-TUI 扩展行)');
283
- return;
284
- }
285
- let list = [];
286
- if (currentSessionId !== null)
287
- list = buffers.get(currentSessionId) ?? [];
288
- // dsh-TUI copies every mouse selection to the clipboard (copy-on-select,
289
- // then clears the highlight), so the clipboard holds exactly what the
290
- // user just selected. Offer it as the FIRST picker row.
291
- const clipboardText = await readClipboard(clipboardCommand);
292
- const options = [];
293
- if (clipboardText.trim() !== '') {
294
- options.push({
295
- id: CLIPBOARD_ROW_ID,
296
- label: `📋 划选/剪贴板 · ${clip(clipboardText, 40)}`,
297
- description: clip(clipboardText, 160),
298
- });
299
- }
300
- options.push(...list.slice(-pickerLimit).reverse().map(message => ({
301
- id: String(message.seq),
302
- label: `#${message.seq} ${message.role === 'user' ? '我' : '助手'} · ${clip(message.text, 44)}`,
303
- description: clip(message.text, 160),
304
- })));
305
- if (options.length === 0) {
306
- toast('没有可引用内容:划选一段文本(自动复制)或先对话后重试');
307
- return;
308
- }
309
- const title = '选择要引用的对话内容(可多次引用,Esc 取消)';
310
- let picked;
311
- try {
312
- picked = await dialogs.select({ title, options, timeoutMs: PICKER_TIMEOUT_MS });
313
- }
314
- catch {
315
- return;
316
- }
317
- if (picked === undefined)
318
- return;
319
- const entry = picked === CLIPBOARD_ROW_ID
320
- ? { seq: null, role: '划选', text: clipboardText }
321
- : list.find(message => String(message.seq) === picked);
322
- if (entry === undefined)
323
- return;
324
- const record = readInjectServers().find(server => server.pid === process.pid);
325
- if (record === undefined) {
326
- toast('注入通道不可用(~/.dsh-tui/inject/ 无本进程套接字)');
327
- return;
328
- }
329
- const delivered = await injectAppend(record.socketPath, frameQuotes([entry], quoteMaxChars));
330
- toast(delivered
331
- ? entry.seq === null
332
- ? '已引用划选内容 → 输入框(可继续追加,编辑后发送)'
333
- : `已引用 #${entry.seq} → 输入框(可继续 ${shortcut} 追加,编辑后发送)`
334
- : '引用写入输入框失败');
335
- };
336
-
337
- const wire = async () => {
338
- if (wired)
339
- return;
340
- wired = true;
341
- // Sibling rows ahead of us mount these during the same boot pass; a
342
- // short poll keeps an out-of-order embedder from silently disabling
343
- // the feature, while a headless/web host exits fast and inert.
344
- const deadline = Date.now() + SERVICE_WAIT_MS;
345
- let shortcuts = ctx.get('tuiShortcuts');
346
- while ((shortcuts === undefined || ctx.get('tuiDialogs') === undefined) && Date.now() < deadline) {
347
- await new Promise(resolve => setTimeout(resolve, 50));
348
- shortcuts = ctx.get('tuiShortcuts');
349
- }
350
- if (shortcuts === undefined) {
351
- ctx.logger.info('dsh-quote-followup: no dsh-TUI extension seams on this profile — row stays inert (web face lives in the browser half)');
352
- return;
353
- }
354
- keep(ctx.on('session/event', onSessionEvent));
355
- keep(ctx.on('session/disposed', onSessionDisposed));
356
- keep(shortcuts.register(shortcut, {
357
- description: '引用对话内容,针对性追问 (dsh-quote-followup)',
358
- handler: () => {
359
- void openPicker();
360
- },
361
- }, ctx));
362
- ctx.logger.info(`dsh-quote-followup: TUI face armed — ${shortcut} opens the quote picker`);
363
- };
364
- // The keep-alive effect is registered SYNCHRONOUSLY so the row's fiber
365
- // outlives apply(): wire() registers the shortcut after an await, and an
366
- // activation-scoped registration against an already-settled fiber is
367
- // released immediately (observed: register() succeeds, dispatch never
368
- // matches). This effect's disposer drains everything wire() accumulated.
369
- ctx.effect(() => () => {
370
- for (const dispose of disposers.splice(0)) {
371
- try {
372
- if (typeof dispose === 'function')
373
- dispose();
374
- }
375
- catch {
376
- /* idempotent disposers; a double release is fine */
377
- }
378
- }
379
- });
380
- void wire();
381
- }
11
+ export function apply() {}
package/package.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.2.0",
4
- "description": "Quote selected conversation content into a targeted follow-up turn for DeepSeek Harness a TUI face (message picker + next-input rewrite via the official plugin seams) and a Web face (text-selection quoting into the composer).",
3
+ "version": "0.2.2",
4
+ "description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
8
8
  "deepseek-harness",
9
- "dsh-tui",
9
+ "web",
10
10
  "quote",
11
11
  "follow-up"
12
12
  ],
13
+ "scripts": {
14
+ "test": "node --check lib/index.js && node --check lib/client.js && node test/web-repeat-harness.mjs"
15
+ },
13
16
  "type": "module",
14
17
  "main": "lib/index.js",
15
18
  "types": "lib/index.d.ts",