dsh-quote-followup 0.1.0
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/LICENSE +21 -0
- package/README.md +105 -0
- package/README.zh.md +93 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +252 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +322 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tr1v3r
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# dsh-quote-followup
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Quote **selected conversation content** into a **targeted follow-up turn** —
|
|
6
|
+
one dsh plugin package, two faces:
|
|
7
|
+
|
|
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.
|
|
13
|
+
|
|
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.
|
|
17
|
+
|
|
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.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
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)
|
|
41
|
+
dsh plugin --profile web add dsh-quote-followup
|
|
42
|
+
```
|
|
43
|
+
|
|
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
|
+
1. Converse normally (messages enter the buffer).
|
|
52
|
+
2. `Ctrl+Alt+Q` → picker lists recent messages (`#seq me/assistant · summary`,
|
|
53
|
+
arrows + Enter).
|
|
54
|
+
3. The quote block lands in the input; press the combo again to append more.
|
|
55
|
+
4. Type your question below the quotes and send.
|
|
56
|
+
|
|
57
|
+
### Web (dsh web GUI)
|
|
58
|
+
|
|
59
|
+
1. Select any span in the transcript (cross-line, any granularity).
|
|
60
|
+
2. The "❐ 引用" button appears above the selection — click it.
|
|
61
|
+
3. The quote block is appended to the composer; keep quoting or type your
|
|
62
|
+
question, then send.
|
|
63
|
+
|
|
64
|
+
## Configuration (optional row config override)
|
|
65
|
+
|
|
66
|
+
Patch rows replace the whole `config`, so restate every key when overriding:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
- id: quote-followup
|
|
70
|
+
name: dsh-quote-followup
|
|
71
|
+
config:
|
|
72
|
+
shortcut: ctrl+alt+q # needs ctrl or alt; avoid reserved combos
|
|
73
|
+
pickerLimit: 30 # max messages listed
|
|
74
|
+
quoteMaxChars: 1600 # per-quote truncation bound (chars)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Known edges
|
|
78
|
+
|
|
79
|
+
- **Live messages only**: `session/event` does not replay history (resume
|
|
80
|
+
seeds do not emit), so messages from before a `/resume` or restart never
|
|
81
|
+
appear in the picker.
|
|
82
|
+
- **Picker target session**: the TUI face targets the session of the latest
|
|
83
|
+
user message; pressing the combo immediately after `/resume` (before any new
|
|
84
|
+
message) may list the previous session's buffer.
|
|
85
|
+
- **Role detection (Web)**: best-effort heuristics; unknown roles are labeled
|
|
86
|
+
"对话".
|
|
87
|
+
- The Web face writes input-box text — what you see is what the model gets.
|
|
88
|
+
Thinking / tool calls are not quotable.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
git clone <repo> && cd dsh-quote-followup
|
|
94
|
+
dsh plugin --profile dsh-tui add link:$(pwd)
|
|
95
|
+
dsh plugin --profile web add link:$(pwd)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Pure ESM, zero runtime dependencies; `lib/index.js` (host half, Node) +
|
|
99
|
+
`lib/client.js` (browser half, a `window.__ModuleLoader__` closure factory).
|
|
100
|
+
A headless E2E harness mounts the real extensions row plus this plugin and
|
|
101
|
+
drives session/event → shortcut → dialog → injection socket end to end.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# dsh-quote-followup
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
把**选中的对话内容**引用进**针对性的下一轮追问**——同一个 dsh 插件包,两张界面:
|
|
6
|
+
|
|
7
|
+
- **dsh-TUI 面**:`Ctrl+Alt+Q` 打开消息选择器(最近的用户/助手消息,最新在前),
|
|
8
|
+
选中后引用块直接落入输入框,可多次追加、发送前可编辑。
|
|
9
|
+
- **Web 面(dsh web)**:在对话记录里**划选任意文本片段**,出现浮动「❐ 引用」按钮,
|
|
10
|
+
点击后引用块插入输入框光标处。
|
|
11
|
+
|
|
12
|
+
引用块是普通的 markdown blockquote(`> [引用 · assistant#5]`),**可见、可编辑、
|
|
13
|
+
无隐藏注入**——模型看到的就是你在输入框里看到的内容。
|
|
14
|
+
|
|
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 面行为一致、对用户完全透明。
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# TUI profile
|
|
33
|
+
dsh plugin --profile dsh-tui add dsh-quote-followup
|
|
34
|
+
# Web profile(浏览器半区由 dsh.client 声明自动加载,需重启 dsh web)
|
|
35
|
+
dsh plugin --profile web add dsh-quote-followup
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
bundle 清单(`dsh.profile.bundles`)加上 `dsh-quote-followup` 后重启即生效。
|
|
39
|
+
无需授权文件(`extension-grants.json`)、无需任何 TUI patch。
|
|
40
|
+
|
|
41
|
+
## 使用
|
|
42
|
+
|
|
43
|
+
### TUI
|
|
44
|
+
|
|
45
|
+
1. 正常对话(消息进入缓冲)。
|
|
46
|
+
2. `Ctrl+Alt+Q` → 选择器列出最近消息(`#序号 我/助手 · 摘要`,方向键 + Enter)。
|
|
47
|
+
3. 引用块落入输入框;可再次 `Ctrl+Alt+Q` 追加多条。
|
|
48
|
+
4. 在引用块下方写下问题,正常发送。
|
|
49
|
+
|
|
50
|
+
### Web(dsh web GUI)
|
|
51
|
+
|
|
52
|
+
1. 在对话记录中划选任意片段(支持跨行,任意粒度)。
|
|
53
|
+
2. 选区上方出现「❐ 引用」按钮,点击。
|
|
54
|
+
3. 引用块追加到输入框末尾(多次引用多次追加),继续输入问题后发送。
|
|
55
|
+
|
|
56
|
+
## 配置(可选,覆盖行 config)
|
|
57
|
+
|
|
58
|
+
`cordis.patch.yml` 的行 config 整体替换语义,覆盖时需完整重述:
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
- id: quote-followup
|
|
62
|
+
name: dsh-quote-followup
|
|
63
|
+
config:
|
|
64
|
+
shortcut: ctrl+alt+q # 需带 ctrl 或 alt;避开保留组合
|
|
65
|
+
pickerLimit: 30 # 选择器列出的消息数上限
|
|
66
|
+
quoteMaxChars: 1600 # 单条引用的截断上限(字符)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 已知边界
|
|
70
|
+
|
|
71
|
+
- **只有 live 消息可引用**:`session/event` 不回放历史(resume 的种子事件不发出),
|
|
72
|
+
`/resume` 或重启后、第一条新消息之前的旧消息不出现在选择器里。
|
|
73
|
+
- **选择器目标会话**:TUI 面以「最近一条用户消息所在的会话」为准;`/resume` 后
|
|
74
|
+
立即按 `Ctrl+Alt+Q`(还没有新消息时)可能列出上一会话的缓冲。
|
|
75
|
+
- **角色识别(Web)**:best-effort(data-role/class 启发式),识别不了标注为「对话」。
|
|
76
|
+
- Web 面的注入走输入框文本,所见即所得; Thinking / 工具调用不在可引用范围。
|
|
77
|
+
|
|
78
|
+
## 开发
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
git clone <repo> && cd dsh-quote-followup
|
|
82
|
+
dsh plugin --profile dsh-tui add link:$(pwd)
|
|
83
|
+
dsh plugin --profile web add link:$(pwd)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- 纯 ESM、零运行时依赖;`lib/index.js`(host 半区,Node)+ `lib/client.js`
|
|
87
|
+
(浏览器半区,`window.__ModuleLoader__` 闭包工厂)。
|
|
88
|
+
- 无头 E2E:挂载真实 extensions 行 + 本插件行,驱动 session/event →
|
|
89
|
+
快捷键 → 弹窗 → 注入套接字全链路(见仓库 docs/harness 说明)。
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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.
|
|
5
|
+
|
|
6
|
+
- insert:
|
|
7
|
+
- id: quote-followup
|
|
8
|
+
name: dsh-quote-followup
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-quote-followup — browser half (Web face).
|
|
3
|
+
*
|
|
4
|
+
* Select text anywhere inside the conversation transcript
|
|
5
|
+
* (`[data-slot="conversation.session"]`), click the floating ❐ 引用 button,
|
|
6
|
+
* and the selection lands in the composer as a markdown blockquote the user
|
|
7
|
+
* can edit before sending — a visible, targeted follow-up with zero hidden
|
|
8
|
+
* state and zero host-side privileges.
|
|
9
|
+
*
|
|
10
|
+
* Pure DOM (no React), so the module carries no platform dependencies:
|
|
11
|
+
* `inject: []`. Mounted by the web client runtime through the package's
|
|
12
|
+
* `dsh.client` declaration (`platform: "web"`).
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-quote-followup/client
|
|
15
|
+
*/
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: "dsh-quote-followup",
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
var module = { exports: {} };
|
|
20
|
+
var exports = module.exports;
|
|
21
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
22
|
+
//#region constants
|
|
23
|
+
/** Official slot wrapper owning the chat transcript (composer excluded). */
|
|
24
|
+
const TRANSCRIPT_SELECTOR = '[data-slot="conversation.session"]';
|
|
25
|
+
/** Composer input candidates, most specific first (official slot → legacy hints). */
|
|
26
|
+
const COMPOSER_SELECTORS = [
|
|
27
|
+
'[data-slot="conversation.composer"] textarea',
|
|
28
|
+
'[data-slot="conversation.composer"] [contenteditable="true"]',
|
|
29
|
+
'textarea[data-phase]',
|
|
30
|
+
'[data-composer-card] textarea',
|
|
31
|
+
'[data-composer-card] [contenteditable="true"]',
|
|
32
|
+
'[data-input-scroll] textarea'
|
|
33
|
+
];
|
|
34
|
+
const BUTTON_ID = "dsh-quote-followup-btn";
|
|
35
|
+
/** Per-quote character cap, mirroring the TUI face. */
|
|
36
|
+
const QUOTE_MAX_CHARS = 1600;
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region helpers
|
|
39
|
+
const elementOf = (node) => (node === null ? null : node.nodeType === 1 ? node : node.parentElement);
|
|
40
|
+
const transcriptOf = (node) => {
|
|
41
|
+
const element = elementOf(node);
|
|
42
|
+
return element === null ? null : element.closest(TRANSCRIPT_SELECTOR);
|
|
43
|
+
};
|
|
44
|
+
/** The live selection when BOTH ends sit inside the same transcript. */
|
|
45
|
+
const selectionInTranscript = (selection) => {
|
|
46
|
+
if (selection === null || selection.isCollapsed || selection.rangeCount === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const anchor = transcriptOf(selection.anchorNode);
|
|
49
|
+
const focus = transcriptOf(selection.focusNode);
|
|
50
|
+
if (anchor === null || anchor !== focus)
|
|
51
|
+
return null;
|
|
52
|
+
return anchor;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Best-effort role for the quoted fragment: walk up from the range start
|
|
56
|
+
* looking for role markers (data attributes, then class-name hints).
|
|
57
|
+
* Null → the generic "对话" label; this is display-only metadata.
|
|
58
|
+
*/
|
|
59
|
+
const detectRole = (range, transcript) => {
|
|
60
|
+
let element = elementOf(range.startContainer);
|
|
61
|
+
while (element !== null && element !== transcript) {
|
|
62
|
+
const role = element.getAttribute?.("data-role") ?? element.getAttribute?.("data-message-role");
|
|
63
|
+
if (typeof role === "string" && role !== "") {
|
|
64
|
+
if (/user|human/i.test(role)) return "user";
|
|
65
|
+
if (/assistant|model|ai\b/i.test(role)) return "assistant";
|
|
66
|
+
}
|
|
67
|
+
const cls = String(element.className ?? "");
|
|
68
|
+
if (/(^|[\s_-])user([\s_-]|$)/i.test(cls)) return "user";
|
|
69
|
+
if (/(^|[\s_-])assistant([\s_-]|$)/i.test(cls)) return "assistant";
|
|
70
|
+
element = element.parentElement;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
/** Markdown blockquote frame for one quoted fragment. */
|
|
75
|
+
const quoteFrame = (text, role) => {
|
|
76
|
+
let body = String(text ?? "");
|
|
77
|
+
let truncated = false;
|
|
78
|
+
if (body.length > QUOTE_MAX_CHARS) {
|
|
79
|
+
body = body.slice(0, QUOTE_MAX_CHARS);
|
|
80
|
+
truncated = true;
|
|
81
|
+
}
|
|
82
|
+
const who = role === "user" ? "user" : role === "assistant" ? "assistant" : "对话";
|
|
83
|
+
const lines = body.split("\n").map((line) => `> ${line}`.trimEnd());
|
|
84
|
+
return `> [引用 · ${who}${truncated ? ",已截断" : ""}]\n${lines.join("\n")}\n\n`;
|
|
85
|
+
};
|
|
86
|
+
const findComposer = () => {
|
|
87
|
+
for (const selector of COMPOSER_SELECTORS) {
|
|
88
|
+
const element = document.querySelector(selector);
|
|
89
|
+
if (element !== null)
|
|
90
|
+
return element;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Append the quote at the END of the composer text (classic quote-reply
|
|
96
|
+
* reading order) and leave the caret on the blank line for the question.
|
|
97
|
+
* React-controlled inputs need the native setter + input event.
|
|
98
|
+
*/
|
|
99
|
+
const appendToComposer = (element, insertText) => {
|
|
100
|
+
element.focus();
|
|
101
|
+
if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
|
|
102
|
+
const value = element.value ?? "";
|
|
103
|
+
const lead = value !== "" && !value.endsWith("\n") ? "\n" : "";
|
|
104
|
+
const next = `${value}${lead}${insertText}`;
|
|
105
|
+
const descriptor = element.tagName === "TEXTAREA"
|
|
106
|
+
? Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")
|
|
107
|
+
: Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
|
|
108
|
+
(descriptor?.set ?? ((v) => { element.value = v; })).call(element, next);
|
|
109
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
110
|
+
const caret = next.length;
|
|
111
|
+
element.setSelectionRange(caret, caret);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const selection = document.getSelection();
|
|
115
|
+
const range = document.createRange();
|
|
116
|
+
range.selectNodeContents(element);
|
|
117
|
+
range.collapse(false);
|
|
118
|
+
selection?.removeAllRanges();
|
|
119
|
+
selection?.addRange(range);
|
|
120
|
+
range.insertNode(document.createTextNode(insertText));
|
|
121
|
+
range.collapse(false);
|
|
122
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: insertText }));
|
|
123
|
+
};
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region floating button
|
|
126
|
+
let pendingQuote = null;
|
|
127
|
+
const ensureButton = () => {
|
|
128
|
+
const existing = document.getElementById(BUTTON_ID);
|
|
129
|
+
if (existing !== null)
|
|
130
|
+
return existing;
|
|
131
|
+
const button = document.createElement("button");
|
|
132
|
+
button.id = BUTTON_ID;
|
|
133
|
+
button.type = "button";
|
|
134
|
+
button.textContent = "❐ 引用";
|
|
135
|
+
button.title = "引用选中内容到输入框(可多次引用,发送前可编辑)";
|
|
136
|
+
Object.assign(button.style, {
|
|
137
|
+
position: "fixed",
|
|
138
|
+
zIndex: "2147483600",
|
|
139
|
+
padding: "3px 10px",
|
|
140
|
+
fontSize: "12px",
|
|
141
|
+
lineHeight: "20px",
|
|
142
|
+
borderRadius: "6px",
|
|
143
|
+
border: "1px solid rgba(140,140,150,.45)",
|
|
144
|
+
background: "rgba(56,56,62,.94)",
|
|
145
|
+
color: "#f5f5f7",
|
|
146
|
+
cursor: "pointer",
|
|
147
|
+
boxShadow: "0 2px 10px rgba(0,0,0,.28)",
|
|
148
|
+
userSelect: "none",
|
|
149
|
+
display: "none"
|
|
150
|
+
});
|
|
151
|
+
button.addEventListener("mousedown", (event) => {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
event.stopPropagation();
|
|
154
|
+
});
|
|
155
|
+
button.addEventListener("click", (event) => {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
event.stopPropagation();
|
|
158
|
+
onQuoteClick();
|
|
159
|
+
});
|
|
160
|
+
document.body.appendChild(button);
|
|
161
|
+
return button;
|
|
162
|
+
};
|
|
163
|
+
const placeButton = (button, rect) => {
|
|
164
|
+
let x = rect.left + rect.width / 2 - 30;
|
|
165
|
+
let y = rect.top - 28;
|
|
166
|
+
if (y < 4)
|
|
167
|
+
y = rect.bottom + 6;
|
|
168
|
+
x = Math.max(4, Math.min(x, window.innerWidth - 70));
|
|
169
|
+
button.style.left = `${Math.round(x)}px`;
|
|
170
|
+
button.style.top = `${Math.round(y)}px`;
|
|
171
|
+
button.style.display = "block";
|
|
172
|
+
};
|
|
173
|
+
const hideButton = () => {
|
|
174
|
+
const button = document.getElementById(BUTTON_ID);
|
|
175
|
+
if (button !== null)
|
|
176
|
+
button.style.display = "none";
|
|
177
|
+
};
|
|
178
|
+
const onSelectionChange = () => {
|
|
179
|
+
const selection = document.getSelection();
|
|
180
|
+
const transcript = selectionInTranscript(selection);
|
|
181
|
+
if (transcript === null) {
|
|
182
|
+
pendingQuote = null;
|
|
183
|
+
hideButton();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const text = selection.toString();
|
|
187
|
+
if (text === null || text.trim() === "") {
|
|
188
|
+
pendingQuote = null;
|
|
189
|
+
hideButton();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
|
193
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
194
|
+
hideButton();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
pendingQuote = { text: text.trim(), role: detectRole(selection.getRangeAt(0), transcript) };
|
|
198
|
+
placeButton(ensureButton(), rect);
|
|
199
|
+
};
|
|
200
|
+
const onQuoteClick = () => {
|
|
201
|
+
if (pendingQuote === null)
|
|
202
|
+
return;
|
|
203
|
+
const quote = pendingQuote;
|
|
204
|
+
pendingQuote = null;
|
|
205
|
+
hideButton();
|
|
206
|
+
const composer = findComposer();
|
|
207
|
+
if (composer === null) {
|
|
208
|
+
console.warn("[dsh-quote-followup] composer input not found — quote dropped");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
appendToComposer(composer, quoteFrame(quote.text, quote.role));
|
|
212
|
+
const selection = document.getSelection();
|
|
213
|
+
selection?.removeAllRanges();
|
|
214
|
+
};
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region apply
|
|
217
|
+
const MOUNTED = Symbol.for("dsh-quote-followup.mounted");
|
|
218
|
+
function apply(ctx) {
|
|
219
|
+
if (globalThis[MOUNTED] === true)
|
|
220
|
+
return;
|
|
221
|
+
globalThis[MOUNTED] = true;
|
|
222
|
+
const onScroll = () => hideButton();
|
|
223
|
+
const onKeyDown = (event) => {
|
|
224
|
+
if (event.key === "Escape")
|
|
225
|
+
hideButton();
|
|
226
|
+
};
|
|
227
|
+
const onMouseDown = (event) => {
|
|
228
|
+
if (event.target instanceof Element && event.target.id !== BUTTON_ID)
|
|
229
|
+
hideButton();
|
|
230
|
+
};
|
|
231
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
232
|
+
window.addEventListener("scroll", onScroll, true);
|
|
233
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
234
|
+
document.addEventListener("mousedown", onMouseDown, true);
|
|
235
|
+
const cleanup = () => {
|
|
236
|
+
globalThis[MOUNTED] = false;
|
|
237
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
238
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
239
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
240
|
+
document.removeEventListener("mousedown", onMouseDown, true);
|
|
241
|
+
hideButton();
|
|
242
|
+
document.getElementById(BUTTON_ID)?.remove();
|
|
243
|
+
};
|
|
244
|
+
if (ctx !== undefined && typeof ctx.effect === "function")
|
|
245
|
+
ctx.effect(() => cleanup, "dsh-quote-followup: selection quote ui");
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
exports.inject = [];
|
|
249
|
+
exports.apply = apply;
|
|
250
|
+
return module.exports;
|
|
251
|
+
}
|
|
252
|
+
});
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
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. */
|
|
8
|
+
export interface QuoteEntry {
|
|
9
|
+
seq: number;
|
|
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
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Clip a string to `max` cells with an ellipsis marker. */
|
|
25
|
+
export declare function clip(text: string, max: number): string;
|
|
26
|
+
|
|
27
|
+
/** Project one `session/event` record onto a bufferable message (or null). */
|
|
28
|
+
export declare function extractMessage(session: { id: string } | null, event: {
|
|
29
|
+
type?: string;
|
|
30
|
+
seq?: number;
|
|
31
|
+
data?: unknown;
|
|
32
|
+
}): (QuoteEntry & { sessionId: string }) | null;
|
|
33
|
+
|
|
34
|
+
/** Compose the quote block appended into the prompt input. */
|
|
35
|
+
export declare function frameQuotes(quotes: QuoteEntry[], quoteMaxChars?: number): string;
|
|
36
|
+
|
|
37
|
+
export declare const name: string;
|
|
38
|
+
export declare const inject: string[];
|
|
39
|
+
|
|
40
|
+
export declare function apply(ctx: Context, config?: QuoteFollowupConfig): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-quote-followup — host half (TUI face).
|
|
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
|
|
25
|
+
*/
|
|
26
|
+
import { createConnection } from 'node:net';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
/** Stable Cordis plugin name (row id lives in cordis.patch.yml). */
|
|
32
|
+
export const name = 'quote-followup';
|
|
33
|
+
/** Services are resolved lazily via ctx.get so a bare mount degrades softly. */
|
|
34
|
+
export const inject = [];
|
|
35
|
+
|
|
36
|
+
/** Default global combo (ctrl/alt mandatory; ctrl+q is the todo-fold action). */
|
|
37
|
+
const DEFAULT_SHORTCUT = 'ctrl+alt+q';
|
|
38
|
+
/** Messages kept per session (session/event has no replay; live only). */
|
|
39
|
+
const BUFFER_PER_SESSION = 200;
|
|
40
|
+
/** Picker rows shown (newest first). */
|
|
41
|
+
const DEFAULT_PICKER_LIMIT = 30;
|
|
42
|
+
/** Per-quote character cap before truncation marker. */
|
|
43
|
+
const DEFAULT_QUOTE_MAX_CHARS = 1600;
|
|
44
|
+
/** Dialog auto-cancel bound (guards a wedged flow on headless embedders). */
|
|
45
|
+
const PICKER_TIMEOUT_MS = 180000;
|
|
46
|
+
/** How long to wait for sibling TUI services before going inert (ms). */
|
|
47
|
+
const SERVICE_WAIT_MS = 3000;
|
|
48
|
+
/** Injection-channel discovery file (see dsh-tui's inject-channel module). */
|
|
49
|
+
const INJECT_SERVERS_FILE = join(homedir(), '.dsh-tui', 'inject', 'servers.json');
|
|
50
|
+
|
|
51
|
+
/** One quoted message. */
|
|
52
|
+
/** @typedef {{seq: number, role: 'user'|'assistant', text: string}} QuoteEntry */
|
|
53
|
+
|
|
54
|
+
/** Clip a string to `max` cells with an ellipsis marker. Exported for tests. */
|
|
55
|
+
export function clip(text, max) {
|
|
56
|
+
const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
57
|
+
return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 1))}…`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Join the text blocks of an MCP content array. */
|
|
61
|
+
function textOfContent(content) {
|
|
62
|
+
if (!Array.isArray(content))
|
|
63
|
+
return '';
|
|
64
|
+
return content
|
|
65
|
+
.filter(block => block !== null && typeof block === 'object' && block.type === 'text')
|
|
66
|
+
.map(block => String(block.text ?? ''))
|
|
67
|
+
.join('\n')
|
|
68
|
+
.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Project one `session/event` record onto a bufferable message.
|
|
73
|
+
* Returns null for anything that is not a user/assistant text message.
|
|
74
|
+
* Exported for tests.
|
|
75
|
+
*/
|
|
76
|
+
export function extractMessage(session, event) {
|
|
77
|
+
if (event === null || typeof event !== 'object')
|
|
78
|
+
return null;
|
|
79
|
+
const type = event.type;
|
|
80
|
+
if (type !== 'user/message' && type !== 'assistant/message')
|
|
81
|
+
return null;
|
|
82
|
+
const id = session !== null && typeof session === 'object' && typeof session.id === 'string' ? session.id : null;
|
|
83
|
+
if (id === null)
|
|
84
|
+
return null;
|
|
85
|
+
const data = event.data;
|
|
86
|
+
const record = data !== null && typeof data === 'object' ? data : {};
|
|
87
|
+
const message = type === 'assistant/message' && record.message !== null && typeof record.message === 'object'
|
|
88
|
+
? record.message
|
|
89
|
+
: record;
|
|
90
|
+
const text = textOfContent(message.content);
|
|
91
|
+
if (text === '')
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
sessionId: id,
|
|
95
|
+
seq: typeof event.seq === 'number' ? event.seq : -1,
|
|
96
|
+
role: type === 'user/message' ? 'user' : 'assistant',
|
|
97
|
+
text,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Quote lines as a markdown blockquote, capped at `quoteMaxChars`. */
|
|
102
|
+
function quoteBlock(entry, index, total, quoteMaxChars) {
|
|
103
|
+
let body = entry.text;
|
|
104
|
+
let truncated = false;
|
|
105
|
+
if (body.length > quoteMaxChars) {
|
|
106
|
+
body = body.slice(0, quoteMaxChars);
|
|
107
|
+
truncated = true;
|
|
108
|
+
}
|
|
109
|
+
const header = `> [引用 ${index}/${total} · ${entry.role}#${entry.seq}${truncated ? ',已截断' : ''}]`;
|
|
110
|
+
const lines = body.split('\n').map(line => `> ${line}`.trimEnd());
|
|
111
|
+
return `${header}\n${lines.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compose the quote block appended into the prompt input. Exported for tests.
|
|
116
|
+
*/
|
|
117
|
+
export function frameQuotes(quotes, quoteMaxChars = DEFAULT_QUOTE_MAX_CHARS) {
|
|
118
|
+
const blocks = quotes.map((entry, i) => quoteBlock(entry, i + 1, quotes.length, quoteMaxChars));
|
|
119
|
+
return `\n${blocks.join('\n\n')}\n\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read the injection-channel discovery file; [] when absent/unreadable. */
|
|
123
|
+
function readInjectServers() {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(readFileSync(INJECT_SERVERS_FILE, 'utf8'));
|
|
126
|
+
if (!Array.isArray(parsed))
|
|
127
|
+
return [];
|
|
128
|
+
return parsed.filter(entry => entry !== null && typeof entry === 'object'
|
|
129
|
+
&& typeof entry.pid === 'number' && typeof entry.socketPath === 'string');
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One-shot line write to a local injection socket; resolves true on delivery. */
|
|
137
|
+
function injectAppend(socketPath, text) {
|
|
138
|
+
return new Promise(resolve => {
|
|
139
|
+
let socket;
|
|
140
|
+
let settled = false;
|
|
141
|
+
const finish = (ok) => {
|
|
142
|
+
if (settled)
|
|
143
|
+
return;
|
|
144
|
+
settled = true;
|
|
145
|
+
socket?.destroy();
|
|
146
|
+
resolve(ok);
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
socket = createConnection(socketPath, () => {
|
|
150
|
+
try {
|
|
151
|
+
socket.write(`${JSON.stringify({ type: 'prompt.append', text })}\n`);
|
|
152
|
+
socket.end();
|
|
153
|
+
finish(true);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
finish(false);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
socket.on('error', () => finish(false));
|
|
160
|
+
setTimeout(() => finish(false), 1500).unref?.();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
finish(false);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Apply: wire the TUI face when (and only when) the TUI extension seams are
|
|
170
|
+
* mounted. Every registration is scoped with ctx.effect so a profile that
|
|
171
|
+
* drops this row leaves nothing behind.
|
|
172
|
+
*/
|
|
173
|
+
export function apply(ctx, config) {
|
|
174
|
+
const shortcut = typeof config?.shortcut === 'string' && config.shortcut.trim() !== ''
|
|
175
|
+
? config.shortcut.trim()
|
|
176
|
+
: DEFAULT_SHORTCUT;
|
|
177
|
+
const pickerLimit = Number.isInteger(config?.pickerLimit) && config.pickerLimit > 0
|
|
178
|
+
? config.pickerLimit
|
|
179
|
+
: DEFAULT_PICKER_LIMIT;
|
|
180
|
+
const quoteMaxChars = Number.isInteger(config?.quoteMaxChars) && config.quoteMaxChars > 0
|
|
181
|
+
? config.quoteMaxChars
|
|
182
|
+
: DEFAULT_QUOTE_MAX_CHARS;
|
|
183
|
+
|
|
184
|
+
/** Per-session ring buffer of observed messages. */
|
|
185
|
+
const buffers = new Map();
|
|
186
|
+
/** Session the picker targets: the one with the latest user message. */
|
|
187
|
+
let currentSessionId = null;
|
|
188
|
+
let wired = false;
|
|
189
|
+
/** Disposers accumulated by wire(); drained by the keep-alive effect. */
|
|
190
|
+
const disposers = [];
|
|
191
|
+
const keep = (dispose) => {
|
|
192
|
+
if (typeof dispose === 'function' || typeof dispose === 'boolean')
|
|
193
|
+
disposers.push(dispose);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const bufferOf = (sessionId) => {
|
|
197
|
+
let list = buffers.get(sessionId);
|
|
198
|
+
if (list === undefined) {
|
|
199
|
+
list = [];
|
|
200
|
+
buffers.set(sessionId, list);
|
|
201
|
+
}
|
|
202
|
+
return list;
|
|
203
|
+
};
|
|
204
|
+
const toast = (text) => {
|
|
205
|
+
try {
|
|
206
|
+
ctx.get('tuiToast')?.show(text);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* toast-less hosts: the dialog/keyboard flow still works */
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const onSessionEvent = (session, event) => {
|
|
214
|
+
try {
|
|
215
|
+
const message = extractMessage(session, event);
|
|
216
|
+
if (message === null)
|
|
217
|
+
return;
|
|
218
|
+
const list = bufferOf(message.sessionId);
|
|
219
|
+
list.push({ seq: message.seq, role: message.role, text: message.text });
|
|
220
|
+
if (list.length > BUFFER_PER_SESSION)
|
|
221
|
+
list.splice(0, list.length - BUFFER_PER_SESSION);
|
|
222
|
+
if (message.role === 'user')
|
|
223
|
+
currentSessionId = message.sessionId;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
ctx.logger.warn(`dsh-quote-followup: session event dropped: ${error instanceof Error ? error.message : String(error)}`);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const onSessionDisposed = (session) => {
|
|
230
|
+
const id = session !== null && typeof session === 'object' && typeof session.id === 'string' ? session.id : null;
|
|
231
|
+
if (id !== null)
|
|
232
|
+
buffers.delete(id);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Ctrl+Alt+Q → picker; each pick appends one quote block to the input. */
|
|
236
|
+
const openPicker = async () => {
|
|
237
|
+
const dialogs = ctx.get('tuiDialogs');
|
|
238
|
+
if (dialogs === undefined) {
|
|
239
|
+
toast('对话框服务不可用(需要 dsh-TUI 扩展行)');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
let list = [];
|
|
243
|
+
if (currentSessionId !== null)
|
|
244
|
+
list = buffers.get(currentSessionId) ?? [];
|
|
245
|
+
if (list.length === 0) {
|
|
246
|
+
toast('本会话还没有可引用的消息');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const options = list.slice(-pickerLimit).reverse().map(message => ({
|
|
250
|
+
id: String(message.seq),
|
|
251
|
+
label: `#${message.seq} ${message.role === 'user' ? '我' : '助手'} · ${clip(message.text, 44)}`,
|
|
252
|
+
description: clip(message.text, 160),
|
|
253
|
+
}));
|
|
254
|
+
const title = '选择要引用的对话内容(可多次引用,Esc 取消)';
|
|
255
|
+
let picked;
|
|
256
|
+
try {
|
|
257
|
+
picked = await dialogs.select({ title, options, timeoutMs: PICKER_TIMEOUT_MS });
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (picked === undefined)
|
|
263
|
+
return;
|
|
264
|
+
const entry = list.find(message => String(message.seq) === picked);
|
|
265
|
+
if (entry === undefined)
|
|
266
|
+
return;
|
|
267
|
+
const record = readInjectServers().find(server => server.pid === process.pid);
|
|
268
|
+
if (record === undefined) {
|
|
269
|
+
toast('注入通道不可用(~/.dsh-tui/inject/ 无本进程套接字)');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const delivered = await injectAppend(record.socketPath, frameQuotes([entry], quoteMaxChars));
|
|
273
|
+
toast(delivered
|
|
274
|
+
? `已引用 #${entry.seq} → 输入框(可继续 ${shortcut} 追加,编辑后发送)`
|
|
275
|
+
: '引用写入输入框失败');
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const wire = async () => {
|
|
279
|
+
if (wired)
|
|
280
|
+
return;
|
|
281
|
+
wired = true;
|
|
282
|
+
// Sibling rows ahead of us mount these during the same boot pass; a
|
|
283
|
+
// short poll keeps an out-of-order embedder from silently disabling
|
|
284
|
+
// the feature, while a headless/web host exits fast and inert.
|
|
285
|
+
const deadline = Date.now() + SERVICE_WAIT_MS;
|
|
286
|
+
let shortcuts = ctx.get('tuiShortcuts');
|
|
287
|
+
while ((shortcuts === undefined || ctx.get('tuiDialogs') === undefined) && Date.now() < deadline) {
|
|
288
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
289
|
+
shortcuts = ctx.get('tuiShortcuts');
|
|
290
|
+
}
|
|
291
|
+
if (shortcuts === undefined) {
|
|
292
|
+
ctx.logger.info('dsh-quote-followup: no dsh-TUI extension seams on this profile — row stays inert (web face lives in the browser half)');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
keep(ctx.on('session/event', onSessionEvent));
|
|
296
|
+
keep(ctx.on('session/disposed', onSessionDisposed));
|
|
297
|
+
keep(shortcuts.register(shortcut, {
|
|
298
|
+
description: '引用对话内容,针对性追问 (dsh-quote-followup)',
|
|
299
|
+
handler: () => {
|
|
300
|
+
void openPicker();
|
|
301
|
+
},
|
|
302
|
+
}, ctx));
|
|
303
|
+
ctx.logger.info(`dsh-quote-followup: TUI face armed — ${shortcut} opens the quote picker`);
|
|
304
|
+
};
|
|
305
|
+
// The keep-alive effect is registered SYNCHRONOUSLY so the row's fiber
|
|
306
|
+
// outlives apply(): wire() registers the shortcut after an await, and an
|
|
307
|
+
// activation-scoped registration against an already-settled fiber is
|
|
308
|
+
// released immediately (observed: register() succeeds, dispatch never
|
|
309
|
+
// matches). This effect's disposer drains everything wire() accumulated.
|
|
310
|
+
ctx.effect(() => () => {
|
|
311
|
+
for (const dispose of disposers.splice(0)) {
|
|
312
|
+
try {
|
|
313
|
+
if (typeof dispose === 'function')
|
|
314
|
+
dispose();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* idempotent disposers; a double release is fine */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
void wire();
|
|
322
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-quote-followup",
|
|
3
|
+
"version": "0.1.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).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"dsh-tui",
|
|
10
|
+
"quote",
|
|
11
|
+
"follow-up"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./client": "./lib/client.js",
|
|
22
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"lib",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"README.md",
|
|
29
|
+
"README.zh.md"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/tr1v3r/dsh-quote-followup.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/tr1v3r/dsh-quote-followup#readme",
|
|
36
|
+
"bugs": "https://github.com/tr1v3r/dsh-quote-followup/issues",
|
|
37
|
+
"author": "tr1v3r <tr1v3r@outlook.com> (https://github.com/tr1v3r)",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dsh": {
|
|
43
|
+
"engines": {
|
|
44
|
+
"dsh": ">=0.1.1-rc.1"
|
|
45
|
+
},
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"inject": [],
|
|
51
|
+
"platform": "web"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|