dsh-quote-followup 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -81
- package/README.zh.md +18 -70
- package/cordis.patch.yml +2 -4
- package/lib/client.js +8 -2
- package/lib/index.d.ts +4 -40
- package/lib/index.js +4 -315
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -2,103 +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
|
+
- Keep the Lexical editor model and rendered composer in sync.
|
|
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
|
-
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
|
-
```
|
|
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.
|
|
76
23
|
|
|
77
|
-
##
|
|
24
|
+
## Use
|
|
78
25
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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.
|
|
89
30
|
|
|
90
31
|
## Development
|
|
91
32
|
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
dsh plugin --profile dsh-tui add link:$(pwd)
|
|
95
|
-
dsh plugin --profile web add link:$(pwd)
|
|
33
|
+
```bash
|
|
34
|
+
npm test
|
|
96
35
|
```
|
|
97
36
|
|
|
98
|
-
|
|
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.
|
|
37
|
+
The regression harness models the Lexical boundary that caused the historical “first quote works, second quote disappears” bug: once the editor has a block, the caret must be inside its last block rather than after the block at the editor root.
|
|
102
38
|
|
|
103
39
|
## License
|
|
104
40
|
|
package/README.zh.md
CHANGED
|
@@ -2,92 +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
|
+
- 通过 Lexical 的 paste 路径更新编辑器模型,避免只改 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。仅刷新页面不会替换旧服务进程中的 bundle。
|
|
40
23
|
|
|
41
24
|
## 使用
|
|
42
25
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 / 工具调用不在可引用范围。
|
|
26
|
+
1. 在对话消息中划选任意片段。
|
|
27
|
+
2. 点击 **❐ 引用**。
|
|
28
|
+
3. 如需补充,可继续选中并引用其他片段。
|
|
29
|
+
4. 在引用块下输入追问并发送。
|
|
77
30
|
|
|
78
|
-
##
|
|
31
|
+
## 开发验证
|
|
79
32
|
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
dsh plugin --profile dsh-tui add link:$(pwd)
|
|
83
|
-
dsh plugin --profile web add link:$(pwd)
|
|
33
|
+
```bash
|
|
34
|
+
npm test
|
|
84
35
|
```
|
|
85
36
|
|
|
86
|
-
|
|
87
|
-
(浏览器半区,`window.__ModuleLoader__` 闭包工厂)。
|
|
88
|
-
- 无头 E2E:挂载真实 extensions 行 + 本插件行,驱动 session/event →
|
|
89
|
-
快捷键 → 弹窗 → 注入套接字全链路(见仓库 docs/harness 说明)。
|
|
37
|
+
回归测试覆盖历史问题:“第一次引用成功,第二次消失”。根因是编辑器已有段落后,caret 不能放在 Lexical root 的 block 之后,必须放进最后一个 block 内。
|
|
90
38
|
|
|
91
|
-
##
|
|
39
|
+
## 许可证
|
|
92
40
|
|
|
93
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,7 @@ 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
|
|
35
|
+
/** Per-quote character cap for a bounded composer insertion. */
|
|
36
36
|
const QUOTE_MAX_CHARS = 1600;
|
|
37
37
|
//#endregion
|
|
38
38
|
//#region helpers
|
|
@@ -139,7 +139,13 @@ window.__ModuleLoader__.load({
|
|
|
139
139
|
}
|
|
140
140
|
const selection = document.getSelection();
|
|
141
141
|
const range = document.createRange();
|
|
142
|
-
|
|
142
|
+
// A Lexical root with existing block children does not admit a caret at
|
|
143
|
+
// `root.childNodes.length`: its paste listener ignores that DOM point,
|
|
144
|
+
// then mutation reconciliation deletes the raw-text fallback. Put the
|
|
145
|
+
// caret INSIDE the last block instead. The empty-editor case still uses
|
|
146
|
+
// the root because it has no block yet.
|
|
147
|
+
const caretContainer = element.lastElementChild ?? element;
|
|
148
|
+
range.selectNodeContents(caretContainer);
|
|
143
149
|
range.collapse(false);
|
|
144
150
|
selection?.removeAllRanges();
|
|
145
151
|
selection?.addRange(range);
|
package/lib/index.d.ts
CHANGED
|
@@ -1,40 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
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;
|
|
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,322 +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 { readFileSync } from 'node:fs';
|
|
28
|
-
import { homedir } from 'node:os';
|
|
29
|
-
import { join } from 'node:path';
|
|
30
7
|
|
|
31
|
-
/** Stable Cordis plugin name (row id lives in cordis.patch.yml). */
|
|
32
8
|
export const name = 'quote-followup';
|
|
33
|
-
/** Services are resolved lazily via ctx.get so a bare mount degrades softly. */
|
|
34
9
|
export const inject = [];
|
|
35
10
|
|
|
36
|
-
|
|
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
|
-
}
|
|
11
|
+
export function apply() {}
|
package/package.json
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-quote-followup",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Quote selected conversation
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|