dsh-next-prompt 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  | 版本 | 主要变化 |
4
4
  | --- | --- |
5
+ | 0.6.0 | `↑` 不再只能填上一条:接着按 `↑` 往更早翻、按 `↓` 往回翻,在最近一条上按 `↓` 清空;改过翻出的内容后 `↑` / `↓` 恢复成移动光标。相邻重复发送的消息只算一条 |
6
+ | 0.5.2 | 适配 DSH 0.2.1 的插件热更新:卸载时等提示词缓存写完再让新实例加载;缓存的临时文件名不再只带进程号,避免新旧实例同时写盘失败;界面半更新后替换页面里旧版本的样式 |
5
7
  | 0.5.1 | 功能不变;改由 GitHub Actions 自动发布,npm 页面会显示来源证明(provenance),可核对安装包确实由本仓库构建 |
6
8
  | 0.5.0 | 改名为 `dsh-next-prompt`(原 `dsh-prompt-prefill`),避免和模型的「回答预填(prefill)」混淆;插件 id、RPC 路径、日志前缀随之改名。新增:关掉 DSH 再打开、切回会话后还原上次的灰字:后台把每个会话最近一条提示词存到 DSH 主目录,之后没有新对话时还原,有新对话、新回合开始或按 Escape 关掉时丢弃;新增配置 `persistSuggestions` |
7
9
  | 0.4.2 | 正确处理 DSH 以 `finish` 片段返回的模型失败:不支持关闭思考时去掉该参数重试,其他错误写进诊断。修复部分模型永远不出灰字 |
package/README.en.md CHANGED
@@ -8,13 +8,13 @@
8
8
  A DeepSeek Harness (DSH) desktop plugin that adds two small features to the input box:
9
9
 
10
10
  - **→ / Tab: accept a suggested next prompt.** When the agent finishes replying, a light gray suggestion appears in the empty input box, guessing what you are likely to say next based on the recent conversation. Press `Tab` or `→` to put it into your draft.
11
- - **↑: recall your last message.** With the input box empty, press `↑` to fill in the last message you sent in this session.
11
+ - **↑ / ↓: browse what you sent.** With the input box empty, press `↑` to fill in the last message you sent in this session; keep pressing `↑` to go further back and `↓` to come forward again.
12
12
 
13
13
  Both only fill the input box. **Nothing is ever sent automatically.**
14
14
 
15
15
  > This plugin was called `dsh-prompt-prefill` before 0.5.0. If you installed the old one, run `dsh plugin --profile desktop remove dsh-prompt-prefill` first, then install as below.
16
16
 
17
- Tested on DSH desktop **0.2.0-rc.2** (other versions are untested).
17
+ Tested on DSH desktop **0.2.1-rc.3**, **0.2.1-rc.2** and **0.2.0-rc.2** (other versions are untested).
18
18
 
19
19
  ## Install
20
20
 
@@ -22,11 +22,11 @@ Tested on DSH desktop **0.2.0-rc.2** (other versions are untested).
22
22
  dsh plugin --profile desktop add dsh-next-prompt
23
23
  ```
24
24
 
25
- After installing, **fully quit DSH (including the tray icon) and reopen it**. It is best to quit DSH before installing too, otherwise the command waits for DSH to release its file lock.
25
+ From DSH 0.2.1 on, no restart is needed: run the command while DSH is open and the plugin takes effect right away. On earlier versions, **fully quit DSH (including the tray icon) and reopen it** after installing; it is best to quit DSH before installing too, otherwise the command waits for DSH to release its file lock.
26
26
 
27
27
  - Latest code from the repository: `dsh plugin --profile desktop add github:Tleon-H/dsh-next-prompt`
28
28
  - Web version of DSH: replace `--profile desktop` with your own profile (usually `--profile web`)
29
- - Update: `remove` and `add` again, then restart DSH
29
+ - Update: `remove` and `add` again (DSH before 0.2.1 also needs a restart)
30
30
  - Uninstall: `dsh plugin --profile desktop remove dsh-next-prompt`
31
31
 
32
32
  ## Usage
@@ -63,8 +63,9 @@ Only the text of the last few messages is sent to the model. Common key and toke
63
63
  ## Notes
64
64
 
65
65
  - The gray text is an overlay aligned with the input box; very long suggestions may be cut off visually, but accepting still inserts the full text.
66
- - `↑` only recalls the most recent message, text only (no image attachments).
67
- - Do not install alongside other plugins that also intercept `Tab` / `→` / `↑` (see [similar plugins](docs/similar-plugins.md)).
66
+ - Once you edit a recalled message, `↑` / `↓` move the cursor again; in a multi-line message the caret has to reach the start (`↑`) or end (`↓`) before it moves on. Pressing `↓` on the newest message clears the input box.
67
+ - Text only; image attachments are not restored.
68
+ - Do not install alongside other plugins that also intercept `Tab` / `→` / `↑` / `↓` (see [similar plugins](docs/similar-plugins.md)).
68
69
 
69
70
  ## More docs (Chinese)
70
71
 
package/README.md CHANGED
@@ -8,13 +8,13 @@
8
8
  DeepSeek Harness(DSH)桌面端插件,给输入框加两个小功能:
9
9
 
10
10
  - **→ / Tab:采纳建议的下一句。** Agent 回答完后,输入框里出现一条浅灰色的提示词,是根据最近的对话猜你接下来想说的话。按 `Tab` 或 `→` 填进草稿。
11
- - **↑:填入上一次发送的内容。** 输入框为空时按 `↑`,填入这个会话里你上一次发出的消息。
11
+ - **↑ / ↓:翻看发送过的内容。** 输入框为空时按 `↑`,填入这个会话里你上一次发出的消息;接着按 `↑` 往更早翻,按 `↓` 往回翻。
12
12
 
13
13
  两者都**只填进输入框,不会自动发送**。
14
14
 
15
15
  > 本插件原名 `dsh-prompt-prefill`,0.5.0 起改名为 `dsh-next-prompt`。装过旧版的话,先 `dsh plugin --profile desktop remove dsh-prompt-prefill`,再按下面的方法安装新版。
16
16
 
17
- 适用版本:DSH 桌面端 **0.2.0-rc.2**(其他版本未验证)。
17
+ 适用版本:DSH 桌面端 **0.2.1-rc.3**、**0.2.1-rc.2**、**0.2.0-rc.2**(其他版本未验证)。
18
18
 
19
19
  ## 安装
20
20
 
@@ -22,11 +22,11 @@ DeepSeek Harness(DSH)桌面端插件,给输入框加两个小功能:
22
22
  dsh plugin --profile desktop add dsh-next-prompt
23
23
  ```
24
24
 
25
- 装完后**完全退出 DSH(包括托盘图标)再重新打开**。安装前最好也先退出 DSH,否则命令会等待它释放文件锁。
25
+ DSH 0.2.1 起,装完不用重启,DSH 开着时直接运行命令就行,插件马上生效。更早的版本装完要**完全退出 DSH(包括托盘图标)再重新打开**,安装前最好也先退出 DSH,否则命令会等待它释放文件锁。
26
26
 
27
27
  - 想用仓库最新代码:`dsh plugin --profile desktop add github:Tleon-H/dsh-next-prompt`
28
28
  - 用网页版 DSH:把 `--profile desktop` 换成你自己的 profile(通常是 `--profile web`)
29
- - 更新:先 `remove` 再重新 `add`,然后重启 DSH
29
+ - 更新:先 `remove` 再重新 `add`(0.2.1 之前的 DSH 还要重启)
30
30
  - 卸载:`dsh plugin --profile desktop remove dsh-next-prompt`
31
31
 
32
32
  ## 用法
@@ -63,8 +63,9 @@ dsh plugin --profile desktop add dsh-next-prompt
63
63
  ## 注意事项
64
64
 
65
65
  - 灰字是对齐在输入框上的浮层,候选很长时可能显示不全,采纳后仍是完整内容。
66
- - `↑` 只能填入上一条,只恢复文字,不恢复图片附件。
67
- - 不要和其他同样拦截 `Tab` / `→` / `↑` 的插件同时安装,见[同类插件](docs/similar-plugins.md)。
66
+ - 翻出的内容一旦改动,`↑` / `↓` 就恢复成移动光标;多行内容要等光标移到开头(`↑`)或末尾(`↓`)才接着翻。在最近一条上按 `↓` 会清空输入框。
67
+ - 只恢复文字,不恢复图片附件。
68
+ - 不要和其他同样拦截 `Tab` / `→` / `↑` / `↓` 的插件同时安装,见[同类插件](docs/similar-plugins.md)。
68
69
 
69
70
  ## 更多文档
70
71
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  **重开后还原灰字**:模型生成成功后,后台把提示词连同当时最后一条对话消息的指纹(`historyFingerprint`,忽略切换模型、压缩上下文等 DSH 插入的消息)写进 `<DSH 主目录>/plugin-data/dsh-next-prompt/suggestions.json`(主目录取 `profileContext.home`,退回 `$DSH_HOME` 或 `~/.dsh`;最多 200 个会话,整文件原子替换)。界面第一次挂载某个会话、内存里又没有提示词时问一次(`method: 'cached'`),指纹对得上才还原,对不上就删掉。重开后会话要等用户在里面发消息才会加载(`sessions.get()` 取不到),这时没法核对,直接信任缓存,因为插件运行期间每次 `turn/start` 都会删掉缓存;会话已加载却读不到历史时回 `NOT_READY`,界面每 1.5 秒再问,最多 4 次。`turn/start` 和 Escape(`method: 'dismiss'`)也会删掉。关掉 `persistSuggestions` 则完全不读写磁盘。
20
20
 
21
- **↑ 的流程**:界面拦截空输入框上的 `↑`,向后台要这个会话最后一条真人消息(`method: 'lastSent'`),拿到后 `setDraft()`。等待期间你开始打字或切换会话,结果就丢弃。
21
+ **↑ / ↓ 的流程**:界面拦截空输入框上的 `↑`,向后台要这个会话最近一条真人消息(`method: 'lastSent'`,`index: 0`),拿到后 `setDraft()` 并记下翻到第几条。输入框里还是这段原文(光标在开头 / 末尾,单行不看光标)时,`↑` 要 `index + 1`、`↓` 要 `index - 1`,在第 0 条上按 `↓` 清空输入框;翻过最早一条时后台回 `END_OF_HISTORY`,界面不动。后台列历史时相邻重复的消息只算一条。等待期间输入框变了或切换了会话,结果就丢弃。读不到选区(`window.getSelection`)时多行原文不拦截,留给光标换行。
22
22
 
23
23
  **接口的安全边界**:只接受 POST;TCP 对端和 `Host` 都必须是本机回环地址;带 `Origin` 时必须与连接精确同源;`Sec-Fetch-Site: cross-site` 一律拒绝。桌面端转发请求时会去掉 `Origin`,所以没有 `Origin` 时放行。插件没有做用户级鉴权。
24
24
 
@@ -50,10 +50,11 @@ dsh-next-prompt/
50
50
  │ ├── core.js 纯逻辑:配置归一化、脱敏、取最近对话、判断上一轮是否完成、清洗模型输出
51
51
  │ ├── index.js 后台:回合跟踪、RPC(suggestion / lastSent / cached / dismiss / diagnostics)、调用模型、诊断记录
52
52
  │ ├── cache.js 后台:提示词的磁盘缓存(重开 DSH 后还原灰字)
53
- │ └── client.js 界面:灰字浮层、Tab / → / ↑ / Escape、会话状态、样式注入
53
+ │ └── client.js 界面:灰字浮层、Tab / → / ↑ / ↓ / Escape、会话状态、样式注入
54
54
  └── test/
55
55
  ├── core.test.mjs 纯逻辑
56
56
  ├── host.test.mjs 后台:路由与安全校验、回合事件、模型失败与重试、诊断
57
+ ├── cache.test.mjs 后台:提示词缓存的上限,以及热更新时新旧实例共用缓存文件
57
58
  └── client.test.mjs 界面:自带微型 React 与 DOM,覆盖按键、时机、会话切换与生命周期
58
59
  ```
59
60
 
@@ -83,5 +84,10 @@ git push --follow-tags
83
84
  7. 切到别的会话再切回:之前的灰字还在。
84
85
  8. 回答进行中:不出现灰字。
85
86
  9. 回答出错或中途停止:不出现灰字。
86
- 10. 输入框为空时按 `↑`:填入上一次发送的内容;有字时按 `↑`,光标正常上移。
87
+ 10. 输入框为空时按 `↑`:填入上一次发送的内容;再按 `↑` 填入更早一条,按 `↓` 往回,在最近一条上按 `↓` 清空。自己打的字或改过的内容上按 `↑` / `↓`,光标正常移动。翻出一条多行消息时,光标在中间行按 `↑` 只移动光标,到第一行开头后再按才翻到更早一条。
87
88
  11. 有灰字时完全退出 DSH 再打开,切回这个会话:灰字还在,且诊断里没有新的生成记录;按 `Escape` 关掉后再重开,不再出现。
89
+
90
+ DSH 0.2.1 起桌面端默认开启插件热更新:`dsh plugin` 安装、删除或更新后,后台半不用重启就会换成新代码(旧实例先卸载,新实例再加载,两者在同一个进程里)。界面半在浏览器页面里另有一套加载方式;0.2.1-rc.2 Windows 真机上不刷新窗口也生效了。改动后再过一遍:
91
+
92
+ 12. DSH 开着时 `remove` 再 `add` 本插件:命令不卡住;不重启 DSH,发一条消息等回答完,能出灰字。若不出,先刷新窗口再试,还不行再重启 DSH,并记下需要哪一步。
93
+ 13. 有灰字时按第 12 条更新一次,再切回这个会话:灰字能还原;`plugin-data/dsh-next-prompt/` 下没有残留的 `.tmp` 文件。
@@ -14,4 +14,4 @@
14
14
  | [converk/dsh-tweaks · prompt-history](https://github.com/converk/dsh-tweaks/tree/main/plugins/prompt-history) | 空输入框 ↑ / ↓ 连续翻历史,显示位置角标 |
15
15
  | [PerryLink/dsh-composer-history](https://github.com/PerryLink/dsh-composer-history) | 终端风格的输入历史,支持 Ctrl+R 搜索 |
16
16
 
17
- 它们同样会拦截 `Tab` / `→` / `↑`,**不要与本插件同时安装**。本插件的特点是把「下一句建议」和「上一条回填」放在一个小插件里,并带诊断记录,方便排查。
17
+ 它们同样会拦截 `Tab` / `→` / `↑` / `↓`,**不要与本插件同时安装**。本插件的特点是把「下一句建议」和「历史回填」放在一个小插件里,并带诊断记录,方便排查。
package/lib/cache.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * @module dsh-next-prompt/cache
12
12
  */
13
13
 
14
+ import { randomUUID } from 'node:crypto'
14
15
  import { mkdir, rename, writeFile } from 'node:fs/promises'
15
16
  import { readFileSync } from 'node:fs'
16
17
  import { homedir } from 'node:os'
@@ -81,7 +82,9 @@ export function createSuggestionCache({ file, logger, maxEntries = MAX_CACHED_SE
81
82
  if (!dirty) return
82
83
  dirty = false
83
84
  const body = JSON.stringify({ format: FORMAT, sessions: Object.fromEntries(load()) })
84
- const temp = `${file}.${process.pid}.tmp`
85
+ // 临时文件名不能只带 pid:DSH 热更新插件时,新旧两个实例在同一进程里,
86
+ // 旧实例还没写完、新实例又开始写,就会写进同一个临时文件。
87
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`
85
88
  try {
86
89
  await mkdir(dirname(file), { recursive: true, mode: 0o700 })
87
90
  await writeFile(temp, body, { mode: 0o600 })
package/lib/client.js CHANGED
@@ -92,7 +92,11 @@ window.__ModuleLoader__.load({
92
92
 
93
93
  if (typeof document !== 'undefined') {
94
94
  try {
95
- if (document.querySelector(`style[data-plugin-css=${JSON.stringify(ID)}]`) === null) {
95
+ // 已有标签时改写内容而不是跳过:插件更新后页面里可能还留着旧版本的样式。
96
+ const existing = document.querySelector(`style[data-plugin-css=${JSON.stringify(ID)}]`)
97
+ if (existing !== null) {
98
+ if (existing.textContent !== CSS) existing.textContent = CSS
99
+ } else {
96
100
  const tag = document.createElement('style')
97
101
  tag.dataset.plugin = ID
98
102
  tag.dataset.pluginCss = ID
@@ -148,6 +152,11 @@ window.__ModuleLoader__.load({
148
152
  * (表现为:刚出现的候选被立刻抹掉)。因此用计数决定真正清理的时机。
149
153
  */
150
154
  instances: 0,
155
+ /**
156
+ * 按 ↑ / ↓ 翻到的历史:`{ index, text }`,index 为 0 是最近发送的一条。
157
+ * 只有输入框里还是这段原文时才接着翻;改过、清空或发送后视为重新开始。
158
+ */
159
+ recall: undefined,
151
160
  /** 交给 Harness 原生内联建议的 id;未使用原生接口时为 undefined。 */
152
161
  suggestionId: undefined,
153
162
  /** 上一次观察到的原生建议 id,用来区分「被 Escape 关闭」与「被采纳」。 */
@@ -388,31 +397,35 @@ window.__ModuleLoader__.load({
388
397
  }
389
398
 
390
399
  /**
391
- * 按 ↑ 时,向宿主半要这个会话上一次发送的内容并写入草稿。
400
+ * 按 ↑ / ↓ 时,向宿主半要这个会话发送过的第 `index` 条内容(0 是最近一条)并写入草稿。
392
401
  *
393
402
  * 历史只存在宿主侧的会话记录里(InputActions / InputState 都不提供),
394
- * 所以这里走一次本机 RPC;等待期间用户已经开始输入、切换了会话、
395
- * 输入被锁或组件已卸载时,一律不覆盖。
403
+ * 所以这里走一次本机 RPC;等待期间用户改了输入框、切换了会话、
404
+ * 输入被锁或组件已卸载时,一律不覆盖。已经翻到最早一条时什么也不做。
396
405
  *
397
406
  * @param sessionId - 按键时的会话 id。
398
407
  * @param store - 该会话的状态对象(用 `recalling` 防止连按重复请求)。
399
408
  * @param interactionRef - 组件最新的草稿、锁定状态与 inputActions。
409
+ * @param index - 要第几条。
410
+ * @param draft - 按键时的草稿;回来时草稿变了就不覆盖。
400
411
  */
401
- function recallLastSent(sessionId, store, interactionRef) {
412
+ function recallSent(sessionId, store, interactionRef, index, draft) {
402
413
  if (store.recalling) return
403
414
  store.recalling = true
404
- const warn = (message) => log('warn', `没有取到上一次发送的内容:${message}`)
405
- rpc({ method: 'lastSent', sessionId })
415
+ const warn = (message) => log('warn', `没有取到发送过的内容:${message}`)
416
+ rpc({ method: 'lastSent', sessionId, index })
406
417
  .then((payload) => {
418
+ if (payload?.code === 'END_OF_HISTORY') return
407
419
  if (!payload || payload.ok !== true || typeof payload.text !== 'string' || payload.text === '') {
408
420
  warn(typeof payload?.message === 'string' ? payload.message : '会话里没有可用的消息')
409
421
  return
410
422
  }
411
423
  const current = interactionRef.current
412
424
  if (store.instances === 0 || !current || current.sessionId !== sessionId) return
413
- if (current.draft !== '' || current.locked || current.removed) return
425
+ if (current.draft !== draft || current.locked || current.removed) return
414
426
  if (typeof current.actions?.setDraft !== 'function') return
415
427
  current.actions.setDraft(payload.text)
428
+ store.recall = { index, text: payload.text }
416
429
  })
417
430
  .catch((error) => warn(String(error?.message ?? error)))
418
431
  .finally(() => {
@@ -420,6 +433,51 @@ window.__ModuleLoader__.load({
420
433
  })
421
434
  }
422
435
 
436
+ /** 比较输入框文字与翻出的原文:忽略换行符差异和首尾空白(编辑器可能补一个换行)。 */
437
+ function sameText(a, b) {
438
+ const normalize = (text) => String(text).replace(/\r\n?/g, '\n').trim()
439
+ return normalize(a) === normalize(b)
440
+ }
441
+
442
+ /**
443
+ * 光标是否在输入框文字的开头(`edge` 为 'start')或结尾('end')。
444
+ *
445
+ * 多行内容里 ↑ / ↓ 要留给光标换行,只有光标已经到头时才翻历史;
446
+ * 单行内容不用看光标。读不到选区时保守地返回 false,把按键交还给输入框。
447
+ */
448
+ function caretAtEdge(input, text, edge) {
449
+ if (!text.includes('\n')) return true
450
+ try {
451
+ const selection = typeof window.getSelection === 'function' ? window.getSelection() : null
452
+ if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) return false
453
+ const caret = selection.getRangeAt(0)
454
+ const range = document.createRange()
455
+ range.selectNodeContents(input)
456
+ if (edge === 'start') range.setEnd(caret.startContainer, caret.startOffset)
457
+ else range.setStart(caret.startContainer, caret.startOffset)
458
+ return range.toString().trim() === ''
459
+ } catch {
460
+ return false
461
+ }
462
+ }
463
+
464
+ /**
465
+ * 算出这次 ↑ / ↓ 要翻到第几条;不该拦截时返回 undefined。
466
+ *
467
+ * - 输入框为空:↑ 取最近一条,↓ 不拦截。
468
+ * - 输入框里还是上次翻出的原文、光标在头(↑)或尾(↓):↑ 往更早翻,↓ 往回翻;
469
+ * 在最近一条上按 ↓ 返回 -1,表示清空输入框。
470
+ * - 其他情况(自己打的字、改过的历史)一律交给光标移动。
471
+ */
472
+ function historyStep(key, current, input, store) {
473
+ const text = domTextOf(input)
474
+ if (current.draft === '' && text.trim() === '') return key === 'ArrowUp' ? 0 : undefined
475
+ const recall = store.recall
476
+ if (!recall || !sameText(current.draft, recall.text) || !sameText(text, recall.text)) return undefined
477
+ if (!caretAtEdge(input, text, key === 'ArrowUp' ? 'start' : 'end')) return undefined
478
+ return key === 'ArrowUp' ? recall.index + 1 : recall.index - 1
479
+ }
480
+
423
481
  /**
424
482
  * 把幽灵浮层对齐到 textarea 的内容区。
425
483
  *
@@ -815,17 +873,18 @@ window.__ModuleLoader__.load({
815
873
  // (光标移动、Tab 切换焦点)执行。按键时的状态一律从 interactionRef 读最新值。
816
874
  //
817
875
  // - Tab / →:幽灵文本可见时采纳(与 Claude Code、Harness 原生建议一致)。
818
- // - ↑:输入框为空时回填上一次发送的内容。与幽灵文本是否显示无关,也不受原生
819
- // 建议接口影响;Agent 正在回答时也可用(Harness 支持回答中排队发送下一条)。
820
- // DSH 自己只在 `/`、`@` 菜单弹出时使用 ↑,而菜单弹出时输入框必然有字,
821
- // 因此空输入框上拦截 ↑ 不会与它冲突。
876
+ // - ↑ / ↓:翻看这个会话发送过的内容。输入框为空时 ↑ 填入最近一条;输入框里还是
877
+ // 翻出的原文时,↑ 往更早翻、↓ 往回翻,在最近一条上按 ↓ 清空。见 historyStep。
878
+ // 与幽灵文本是否显示无关,也不受原生建议接口影响;Agent 正在回答时也可用
879
+ // (Harness 支持回答中排队发送下一条)。DSH 自己只在 `/`、`@` 菜单弹出时使用
880
+ // ↑ / ↓,而菜单弹出时输入框里是 `/`、`@` 开头的字,不会等于翻出的原文。
822
881
  // - Escape:关闭幽灵文本。
823
882
  react.useEffect(() => {
824
883
  if (sessionId === undefined) return
825
884
 
826
885
  const onKeyDown = (event) => {
827
886
  const { key } = event
828
- if (key !== 'ArrowRight' && key !== 'Tab' && key !== 'ArrowUp' && key !== 'Escape') return
887
+ if (key !== 'ArrowRight' && key !== 'Tab' && key !== 'ArrowUp' && key !== 'ArrowDown' && key !== 'Escape') return
829
888
  const current = interactionRef.current
830
889
  if (!current) return
831
890
 
@@ -840,15 +899,33 @@ window.__ModuleLoader__.load({
840
899
  if (event.defaultPrevented || event.isComposing || event.repeat) return
841
900
  // 带修饰键的 → / Tab / ↑ 是别的操作(选区移动、Shift+Tab 反向切换焦点等),不拦截。
842
901
  if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
843
- if (current.locked || current.draft !== '') return
902
+ if (current.locked) return
844
903
  if (typeof current.actions?.setDraft !== 'function') return
845
904
 
846
- if (key === 'ArrowUp') {
905
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
847
906
  if (current.removed) return
848
- } else {
849
- if (!current.visible || current.busy || store.candidate !== current.candidate) return
907
+ const input = focusedComposerInput(anchorRef.current)
908
+ if (input === null) return
909
+ const index = historyStep(key, current, input, store)
910
+ if (index === undefined) return
911
+ event.preventDefault()
912
+ event.stopPropagation()
913
+ if (index >= 0) {
914
+ recallSent(sessionId, store, interactionRef, index, current.draft)
915
+ return
916
+ }
917
+ store.recall = undefined
918
+ try {
919
+ current.actions.setDraft('')
920
+ } catch {
921
+ /* 清不掉就保留原文 */
922
+ }
923
+ return
850
924
  }
851
925
 
926
+ if (current.draft !== '') return
927
+ if (!current.visible || current.busy || store.candidate !== current.candidate) return
928
+
852
929
  const input = focusedComposerInput(anchorRef.current)
853
930
  // 只有「输入框真的为空」时才拦截,避免抢走正常的行内光标移动。
854
931
  // 输入框是 contentEditable div(没有 .value),所以必须用 domIsEmpty。
@@ -856,11 +933,6 @@ window.__ModuleLoader__.load({
856
933
 
857
934
  event.preventDefault()
858
935
  event.stopPropagation()
859
-
860
- if (key === 'ArrowUp') {
861
- recallLastSent(sessionId, store, interactionRef)
862
- return
863
- }
864
936
  try {
865
937
  current.actions.setDraft(current.candidate)
866
938
  } catch {
package/lib/core.js CHANGED
@@ -227,18 +227,22 @@ export function extractRecentTurns(items, config) {
227
227
  }
228
228
 
229
229
  /**
230
- * 找出用户在这个会话里最后发出的那条消息的文字。
230
+ * 列出用户在这个会话里发出过的消息文字,最新的在前。
231
231
  *
232
- * 只认真人输入(`source.kind === 'user'`),工具结果、系统注入不算。
232
+ * 只认真人输入(`source.kind === 'user'`),工具结果、系统注入不算;纯空白的跳过。
233
+ * 相邻两条完全相同的只留一条(与终端历史的 ignoredups 一样),连发几次同一句时
234
+ * 按 ↑ 不会原地踏步。
233
235
  * 返回原文:这段文字只会回填到用户自己的输入框,不会发给模型,因此不脱敏、不截断。
234
236
  * 两种形状都接受,见 extractRecentTurns。
235
237
  *
236
238
  * @param items - Message 数组或 SessionEvent 数组。
237
- * @returns 最后一条用户消息的文字;没有时返回 undefined。
239
+ * @param limit - 最多返回几条。
240
+ * @returns 用户消息文字的数组,最新的在前;没有时为空数组。
238
241
  */
239
- export function lastUserText(items) {
240
- if (!Array.isArray(items)) return undefined
241
- for (let index = items.length - 1; index >= 0; index -= 1) {
242
+ export function userTexts(items, limit = Infinity) {
243
+ const texts = []
244
+ if (!Array.isArray(items)) return texts
245
+ for (let index = items.length - 1; index >= 0 && texts.length < limit; index -= 1) {
242
246
  const item = items[index]
243
247
  if (!item || typeof item !== 'object') continue
244
248
  let payload
@@ -247,9 +251,20 @@ export function lastUserText(items) {
247
251
  else continue
248
252
  if (!payload || !payload.source || payload.source.kind !== 'user') continue
249
253
  const text = messageText(payload)
250
- if (text.trim() !== '') return text
254
+ if (text.trim() === '' || text === texts[texts.length - 1]) continue
255
+ texts.push(text)
251
256
  }
252
- return undefined
257
+ return texts
258
+ }
259
+
260
+ /**
261
+ * 找出用户在这个会话里最后发出的那条消息的文字,规则同 userTexts。
262
+ *
263
+ * @param items - Message 数组或 SessionEvent 数组。
264
+ * @returns 最后一条用户消息的文字;没有时返回 undefined。
265
+ */
266
+ export function lastUserText(items) {
267
+ return userTexts(items, 1)[0]
253
268
  }
254
269
 
255
270
  /**
package/lib/index.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  extractRecentTurns,
23
23
  historyFingerprint,
24
24
  lastTurnState,
25
- lastUserText,
25
+ userTexts,
26
26
  parseCandidate,
27
27
  pickFallback,
28
28
  resolveConfig,
@@ -647,7 +647,9 @@ function registerRoute(ctx, config) {
647
647
  return { ok: true, candidate: entry.candidate, source: 'cache', verified: true }
648
648
  }
649
649
 
650
- if (cache !== undefined) ctx.effect(() => () => { void cache.flush() }, 'dsh-next-prompt: 提示词缓存')
650
+ // 卸载时返回写盘的 promise,DSH 会等它完成:热更新时新实例紧接着读这个文件,
651
+ // 旧实例最后一次写还没落盘的话,新实例会读到旧内容。
652
+ if (cache !== undefined) ctx.effect(() => () => cache.flush(), 'dsh-next-prompt: 提示词缓存')
651
653
 
652
654
  /*
653
655
  * ---------------- 回合跟踪:由宿主监听回合结束 ----------------
@@ -876,12 +878,18 @@ function registerRoute(ctx, config) {
876
878
  return
877
879
  }
878
880
 
879
- // 按 ↑ 回填上一次发送的内容:只读会话历史,不调用模型。
881
+ // 按 ↑ / ↓ 翻看发送过的内容:只读会话历史,不调用模型。
882
+ // index 为 0 是最近一条,越大越早;不传时取最近一条。
880
883
  if (body.method === 'lastSent') {
881
- const text = lastUserText(sessionHistory(ctx, sessionId))
882
- json(response, 200, text === undefined
883
- ? { ok: false, code: 'NO_HISTORY', message: '这个会话还没有发送过消息' }
884
- : { ok: true, text })
884
+ const index = Number.isSafeInteger(body.index) && body.index >= 0 ? body.index : 0
885
+ const texts = userTexts(sessionHistory(ctx, sessionId), index + 1)
886
+ if (texts.length === 0) {
887
+ json(response, 200, { ok: false, code: 'NO_HISTORY', message: '这个会话还没有发送过消息' })
888
+ } else if (index >= texts.length) {
889
+ json(response, 200, { ok: false, code: 'END_OF_HISTORY', message: '已经是最早的一条' })
890
+ } else {
891
+ json(response, 200, { ok: true, text: texts[index], index })
892
+ }
885
893
  return
886
894
  }
887
895
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-next-prompt",
3
- "version": "0.5.1",
4
- "description": "DeepSeek Harness 插件:Agent 回答结束后在输入框预填一条灰色的下一句建议,按 Tab 或 → 采纳;输入框为空时按 ↑ 填入上一次发送的内容。",
3
+ "version": "0.6.0",
4
+ "description": "DeepSeek Harness 插件:Agent 回答结束后在输入框预填一条灰色的下一句建议,按 Tab 或 → 采纳;输入框为空时按 ↑ / ↓ 翻看发送过的内容。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -45,7 +45,7 @@
45
45
  "url": "https://github.com/Tleon-H/dsh-next-prompt/issues"
46
46
  },
47
47
  "scripts": {
48
- "test": "node --test test/core.test.mjs test/host.test.mjs test/client.test.mjs",
48
+ "test": "node --test test/core.test.mjs test/host.test.mjs test/cache.test.mjs test/client.test.mjs",
49
49
  "prepublishOnly": "npm test"
50
50
  },
51
51
  "engines": {