dsh-quote-followup 0.1.0 → 0.2.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/README.md CHANGED
@@ -48,11 +48,22 @@ Add `dsh-quote-followup` to `dsh.profile.bundles` and restart. No grants file
48
48
 
49
49
  ### TUI
50
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.
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.
56
67
 
57
68
  ### Web (dsh web GUI)
58
69
 
@@ -69,9 +80,11 @@ Patch rows replace the whole `config`, so restate every key when overriding:
69
80
  - id: quote-followup
70
81
  name: dsh-quote-followup
71
82
  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)
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)
75
88
  ```
76
89
 
77
90
  ## Known edges
package/README.zh.md CHANGED
@@ -42,10 +42,18 @@ bundle 清单(`dsh.profile.bundles`)加上 `dsh-quote-followup` 后重启即
42
42
 
43
43
  ### TUI
44
44
 
45
- 1. 正常对话(消息进入缓冲)。
46
- 2. `Ctrl+Alt+Q` → 选择器列出最近消息(`#序号 我/助手 · 摘要`,方向键 + Enter)。
47
- 3. 引用块落入输入框;可再次 `Ctrl+Alt+Q` 追加多条。
48
- 4. 在引用块下方写下问题,正常发送。
45
+ **划选引用(推荐,与 Web 面体验一致)**:dsh-TUI 全屏模式下鼠标划选文本会
46
+ 自动复制到剪贴板并清除高亮(copy-on-select)。划选后按 `Ctrl+Alt+Q`,
47
+ 选择器**第一行就是刚划选的内容**(`📋 划选/剪贴板 · …`),回车即引用。
48
+
49
+ **整条消息引用**:`Ctrl+Alt+Q` → 选择器列出最近消息(`#序号 我/助手 · 摘要`,
50
+ 方向键 + Enter),可与划选行混用、多次追加。
51
+
52
+ 引用块落入输入框后在下方写下问题,正常发送。
53
+
54
+ > 快捷键送达说明:TUI 启用了 kitty 键盘协议,现代终端(iTerm2 3.5+、kitty、
55
+ > WezTerm、Ghostty 等)下 `Ctrl+Alt+Q` 以 CSI-u 编码可靠送达。若你的终端不发
56
+ > 该组合,在行 config 里把 `shortcut` 换成任意可送达的 ctrl/alt 组合即可。
49
57
 
50
58
  ### Web(dsh web GUI)
51
59
 
@@ -61,9 +69,11 @@ bundle 清单(`dsh.profile.bundles`)加上 `dsh-quote-followup` 后重启即
61
69
  - id: quote-followup
62
70
  name: dsh-quote-followup
63
71
  config:
64
- shortcut: ctrl+alt+q # 需带 ctrl 或 alt;避开保留组合
65
- pickerLimit: 30 # 选择器列出的消息数上限
66
- quoteMaxChars: 1600 # 单条引用的截断上限(字符)
72
+ shortcut: ctrl+alt+q # 需带 ctrl 或 alt;避开保留组合
73
+ pickerLimit: 30 # 选择器列出的消息数上限
74
+ quoteMaxChars: 1600 # 单条引用的截断上限(字符)
75
+ clipboardReadCommand: '' # 可选:自定义剪贴板读取命令(/bin/sh -c;
76
+ # 默认 pbpaste / wl-paste / xclip / xsel 探测)
67
77
  ```
68
78
 
69
79
  ## 已知边界
package/lib/client.js CHANGED
@@ -91,10 +91,36 @@ window.__ModuleLoader__.load({
91
91
  }
92
92
  return null;
93
93
  };
94
+ /** Dispatch a real paste path so stateful editors (Lexical) own the update. */
95
+ const pasteIntoEditor = (element, text) => {
96
+ let clipboardData = null;
97
+ try {
98
+ clipboardData = new DataTransfer();
99
+ clipboardData.setData("text/plain", text);
100
+ } catch {}
101
+ let event;
102
+ try {
103
+ event = new ClipboardEvent("paste", {
104
+ bubbles: true,
105
+ cancelable: true,
106
+ clipboardData
107
+ });
108
+ } catch {
109
+ event = new Event("paste", { bubbles: true, cancelable: true });
110
+ }
111
+ if (clipboardData !== null && event.clipboardData == null) {
112
+ try {
113
+ Object.defineProperty(event, "clipboardData", { value: clipboardData });
114
+ } catch {}
115
+ }
116
+ element.dispatchEvent(event);
117
+ return event.defaultPrevented;
118
+ };
94
119
  /**
95
120
  * Append the quote at the END of the composer text (classic quote-reply
96
121
  * reading order) and leave the caret on the blank line for the question.
97
- * React-controlled inputs need the native setter + input event.
122
+ * Textareas use their native setter; stateful contenteditables receive a
123
+ * paste event so Lexical/other editors update their model, not only the DOM.
98
124
  */
99
125
  const appendToComposer = (element, insertText) => {
100
126
  element.focus();
@@ -117,6 +143,10 @@ window.__ModuleLoader__.load({
117
143
  range.collapse(false);
118
144
  selection?.removeAllRanges();
119
145
  selection?.addRange(range);
146
+ if (pasteIntoEditor(element, insertText))
147
+ return;
148
+ if (typeof document.execCommand === "function" && document.execCommand("insertText", false, insertText))
149
+ return;
120
150
  range.insertNode(document.createTextNode(insertText));
121
151
  range.collapse(false);
122
152
  element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: insertText }));
@@ -208,9 +238,10 @@ window.__ModuleLoader__.load({
208
238
  console.warn("[dsh-quote-followup] composer input not found — quote dropped");
209
239
  return;
210
240
  }
241
+ // Clear the transcript range BEFORE focusing the composer. Clearing after
242
+ // insertion destroys Lexical's caret and makes the next quote unreliable.
243
+ document.getSelection()?.removeAllRanges();
211
244
  appendToComposer(composer, quoteFrame(quote.text, quote.role));
212
- const selection = document.getSelection();
213
- selection?.removeAllRanges();
214
245
  };
215
246
  //#endregion
216
247
  //#region apply
package/lib/index.d.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  */
5
5
  import type { Context } from '@deepseek-ai/cordis';
6
6
 
7
- /** One quoted message. */
7
+ /** One quoted message (`seq: null` marks a mouse-selection/clipboard quote). */
8
8
  export interface QuoteEntry {
9
- seq: number;
10
- role: 'user' | 'assistant';
9
+ seq: number | null;
10
+ role: 'user' | 'assistant' | '划选';
11
11
  text: string;
12
12
  }
13
13
 
@@ -19,6 +19,9 @@ export interface QuoteFollowupConfig {
19
19
  pickerLimit?: number;
20
20
  /** Per-quote character cap. Default 1600. */
21
21
  quoteMaxChars?: number;
22
+ /** Custom clipboard reader (`/bin/sh -c`; defaults to pbpaste / wl-paste /
23
+ * xclip / xsel probing). */
24
+ clipboardReadCommand?: string;
22
25
  }
23
26
 
24
27
  /** Clip a string to `max` cells with an ellipsis marker. */
@@ -34,6 +37,9 @@ export declare function extractMessage(session: { id: string } | null, event: {
34
37
  /** Compose the quote block appended into the prompt input. */
35
38
  export declare function frameQuotes(quotes: QuoteEntry[], quoteMaxChars?: number): string;
36
39
 
40
+ /** Read the system clipboard ('' on failure / unsupported platform). */
41
+ export declare function readClipboard(overrideCommand?: string): Promise<string>;
42
+
37
43
  export declare const name: string;
38
44
  export declare const inject: string[];
39
45
 
package/lib/index.js CHANGED
@@ -24,6 +24,7 @@
24
24
  * @module dsh-quote-followup
25
25
  */
26
26
  import { createConnection } from 'node:net';
27
+ import { execFile } from 'node:child_process';
27
28
  import { readFileSync } from 'node:fs';
28
29
  import { homedir } from 'node:os';
29
30
  import { join } from 'node:path';
@@ -45,6 +46,10 @@ const DEFAULT_QUOTE_MAX_CHARS = 1600;
45
46
  const PICKER_TIMEOUT_MS = 180000;
46
47
  /** How long to wait for sibling TUI services before going inert (ms). */
47
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__';
48
53
  /** Injection-channel discovery file (see dsh-tui's inject-channel module). */
49
54
  const INJECT_SERVERS_FILE = join(homedir(), '.dsh-tui', 'inject', 'servers.json');
50
55
 
@@ -106,7 +111,8 @@ function quoteBlock(entry, index, total, quoteMaxChars) {
106
111
  body = body.slice(0, quoteMaxChars);
107
112
  truncated = true;
108
113
  }
109
- const header = `> [引用 ${index}/${total} · ${entry.role}#${entry.seq}${truncated ? ',已截断' : ''}]`;
114
+ const tail = entry.seq === null || entry.seq === undefined ? '' : `#${entry.seq}`;
115
+ const header = `> [引用 ${index}/${total} · ${entry.role}${tail}${truncated ? ',已截断' : ''}]`;
110
116
  const lines = body.split('\n').map(line => `> ${line}`.trimEnd());
111
117
  return `${header}\n${lines.join('\n')}`;
112
118
  }
@@ -165,6 +171,40 @@ function injectAppend(socketPath, text) {
165
171
  });
166
172
  }
167
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
+
168
208
  /**
169
209
  * Apply: wire the TUI face when (and only when) the TUI extension seams are
170
210
  * mounted. Every registration is scoped with ctx.effect so a profile that
@@ -180,6 +220,9 @@ export function apply(ctx, config) {
180
220
  const quoteMaxChars = Number.isInteger(config?.quoteMaxChars) && config.quoteMaxChars > 0
181
221
  ? config.quoteMaxChars
182
222
  : DEFAULT_QUOTE_MAX_CHARS;
223
+ const clipboardCommand = typeof config?.clipboardReadCommand === 'string'
224
+ ? config.clipboardReadCommand
225
+ : undefined;
183
226
 
184
227
  /** Per-session ring buffer of observed messages. */
185
228
  const buffers = new Map();
@@ -242,15 +285,27 @@ export function apply(ctx, config) {
242
285
  let list = [];
243
286
  if (currentSessionId !== null)
244
287
  list = buffers.get(currentSessionId) ?? [];
245
- if (list.length === 0) {
246
- toast('本会话还没有可引用的消息');
247
- return;
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
+ });
248
299
  }
249
- const options = list.slice(-pickerLimit).reverse().map(message => ({
300
+ options.push(...list.slice(-pickerLimit).reverse().map(message => ({
250
301
  id: String(message.seq),
251
302
  label: `#${message.seq} ${message.role === 'user' ? '我' : '助手'} · ${clip(message.text, 44)}`,
252
303
  description: clip(message.text, 160),
253
- }));
304
+ })));
305
+ if (options.length === 0) {
306
+ toast('没有可引用内容:划选一段文本(自动复制)或先对话后重试');
307
+ return;
308
+ }
254
309
  const title = '选择要引用的对话内容(可多次引用,Esc 取消)';
255
310
  let picked;
256
311
  try {
@@ -261,7 +316,9 @@ export function apply(ctx, config) {
261
316
  }
262
317
  if (picked === undefined)
263
318
  return;
264
- const entry = list.find(message => String(message.seq) === picked);
319
+ const entry = picked === CLIPBOARD_ROW_ID
320
+ ? { seq: null, role: '划选', text: clipboardText }
321
+ : list.find(message => String(message.seq) === picked);
265
322
  if (entry === undefined)
266
323
  return;
267
324
  const record = readInjectServers().find(server => server.pid === process.pid);
@@ -271,7 +328,9 @@ export function apply(ctx, config) {
271
328
  }
272
329
  const delivered = await injectAppend(record.socketPath, frameQuotes([entry], quoteMaxChars));
273
330
  toast(delivered
274
- ? `已引用 #${entry.seq} 输入框(可继续 ${shortcut} 追加,编辑后发送)`
331
+ ? entry.seq === null
332
+ ? '已引用划选内容 → 输入框(可继续追加,编辑后发送)'
333
+ : `已引用 #${entry.seq} → 输入框(可继续 ${shortcut} 追加,编辑后发送)`
275
334
  : '引用写入输入框失败');
276
335
  };
277
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "keywords": [
6
6
  "dsh",