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 +17 -94
- package/README.zh.md +18 -80
- package/cordis.patch.yml +2 -4
- package/lib/client.js +74 -14
- package/lib/index.d.ts +4 -46
- package/lib/index.js +4 -374
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -2,116 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
English | [中文](README.zh.md)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
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`
|
|
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
|
-
##
|
|
24
|
+
## Use
|
|
91
25
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
```
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
+
一个仅支持 Web 的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:选中对话文本,将其追加到输入框,再进行针对性追问。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
选中后引用块直接落入输入框,可多次追加、发送前可编辑。
|
|
9
|
-
- **Web 面(dsh web)**:在对话记录里**划选任意文本片段**,出现浮动「❐ 引用」按钮,
|
|
10
|
-
点击后引用块插入输入框光标处。
|
|
7
|
+
## 功能
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
- 在 Web 对话区选中文本后显示浮动的 **❐ 引用** 按钮。
|
|
10
|
+
- 以 Markdown 引用块追加到现有草稿,不覆盖已输入内容。
|
|
11
|
+
- 可连续引用多段内容,再统一编辑并发送。
|
|
12
|
+
- 直接派发 DSH 当前 Lexical editor 的 `PASTE_COMMAND`,在 Chromium 与 Firefox 中同步更新编辑器模型和 DOM。
|
|
14
13
|
|
|
15
|
-
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
```
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
(浏览器半区,`window.__ModuleLoader__` 闭包工厂)。
|
|
98
|
-
- 无头 E2E:挂载真实 extensions 行 + 本插件行,驱动 session/event →
|
|
99
|
-
快捷键 → 弹窗 → 注入套接字全链路(见仓库 docs/harness 说明)。
|
|
37
|
+
回归测试覆盖连续引用、Firefox 丢弃构造参数中的 `clipboardData`、Lexical 回滚裸 DOM fallback,以及热替换后新版 client 接管旧按钮/单例状态。
|
|
100
38
|
|
|
101
|
-
##
|
|
39
|
+
## 许可证
|
|
102
40
|
|
|
103
41
|
MIT
|
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
# dsh-quote-followup bundle patch
|
|
2
|
-
#
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
294
|
+
const STATE = Symbol.for("dsh-quote-followup.state");
|
|
249
295
|
function apply(ctx) {
|
|
250
|
-
|
|
296
|
+
const previous = globalThis[STATE];
|
|
297
|
+
if (previous?.version === CLIENT_VERSION)
|
|
251
298
|
return;
|
|
252
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
document.addEventListener("keydown", onKeyDown, true);
|
|
265
|
-
document.addEventListener("mousedown", onMouseDown, true);
|
|
310
|
+
let disposed = false;
|
|
311
|
+
let state;
|
|
266
312
|
const cleanup = () => {
|
|
267
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
2
|
+
* dsh-quote-followup host face.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Quote selected conversation
|
|
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
|
-
"
|
|
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",
|