@kangyishuai/dsh-session-rewind 0.5.1 → 0.6.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 CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  > In-place conversation rewind for DeepSeek Harness: send the conversation back to before any user message, withdraw everything after it from the model context and the transcript, optionally restore the workspace files those turns changed, and put the message back into the composer.
4
4
 
5
- [![npm](https://img.shields.io/npm/v/@kangyishuai/dsh-session-rewind?color=cb3837&logo=npm)](https://www.npmjs.com/package/@kangyishuai/dsh-session-rewind) [![DSH](https://img.shields.io/badge/verified%20on%20DSH-0.2.2--rc.1-4b6bfb)](#compatibility) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
+ [![npm](https://img.shields.io/npm/v/@kangyishuai/dsh-session-rewind?color=cb3837&logo=npm)](https://www.npmjs.com/package/@kangyishuai/dsh-session-rewind) [![DSH](https://img.shields.io/badge/verified%20on%20DSH-0.2.2--rc.2-4b6bfb)](#compatibility) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
6
 
7
7
  [中文](./README.zh-CN.md)
8
8
 
9
9
  Unofficial plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). Not affiliated with DeepSeek.
10
10
 
11
- > **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 185 unit tests, 34 GUI assertions in a throwaway DSH home, and **36 assertions against the real desktop app** (read-only surface plus file mode: created-file trash, modified-file restore, rescue restore, timeline). `pnpm gate:session-rewind` runs the whole release gate in one command. Young software — keep backups of anything you cannot lose.
11
+ > **Status: early.** End to end verified against the current target DSH version — the [Compatibility](#compatibility) table records exactly what was run (unit tests, throwaway-home GUI suite, real-desktop read-only and `--recall` suites) and when. `pnpm gate:session-rewind` runs the whole release gate in one command. Young software — keep backups of anything you cannot lose.
12
12
 
13
13
  ## What it does
14
14
 
@@ -21,6 +21,7 @@ Long conversations drift. A few turns later you wish you had answered a prompt d
21
21
  - **Conversation.** The target message and everything after it are withdrawn from the model-visible context and from the transcript. The session id, title and earlier history are untouched. Nothing is deleted from the log — one marker event is appended.
22
22
  - **Files (optional).** Files changed by `write` / `edit` / `apply_patch` in the withdrawn turns are restored to their pre-change content. Files created after the target are moved to the plugin's trash instead of deleted. A rescue point of the current files is created before anything is written; failures roll back from it.
23
23
  - **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten. Tick **Send this message again right after rewinding** in the confirmation dialog to retry the turn in one click (conversation-only rewinds).
24
+ - **Prompt recall without rewinding.** In the composer, bare `↑` on an empty draft (or with the caret on the first line) recalls the last prompt you sent in this session — `↑` again walks further back, `↓` walks forward and restores the draft you had before browsing. It only replaces the draft text: nothing is withdrawn from the context, sent or rewritten. Toggle it in **Settings**.
24
25
  - **Every view stays in sync.** Rewind in one tab or window and other open views of the same session hide the withdrawn rows immediately, without a reload (the notice travels through `localStorage`; the host's cuts remain the only source of truth).
25
26
  - **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (a timeline of each rewind and each rescue-point file restore, newest first: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
26
27
 
@@ -69,6 +70,8 @@ Re-run `pnpm pack` + `add` after changing the code. The plugin has a host half,
69
70
 
70
71
  **Commands.** `/rewind` opens the same candidate list. `/rewind @<seq> [chat|both]` executes directly (the preview's seq is shown in the list). `/rewind` is decorated by the client; the bare form never sends text to the model.
71
72
 
73
+ **Composer history — `↑` / `↓`.** Focus the composer and press `↑`: the newest prompt you sent in this session fills the draft; `↑` again walks further back, `↓` walks forward, and one more `↓` past the newest entry restores the draft you were typing. It works with an empty draft or when the caret sits on the first line — inside a multi-line draft on a later line the arrows keep moving the caret. Recall never rewinds, never sends and never writes to the session log. History covers the messages still in the model-visible surface (100 newest, older ones dropped) and excludes what an earlier rewind withdrew — those live in the panel's **Withdrawn** tab.
74
+
72
75
  **After a rewind** the target text returns to the composer (images are re-attached best-effort) and the composer is focused. If the composer already held a draft, the draft is kept and a notice says so.
73
76
 
74
77
  **What is refused**
@@ -112,13 +115,16 @@ On startup the plugin also removes data for sessions that no longer exist (they
112
115
  - **The marker is visible to the model** as a short `(empty message)` user message where the withdrawn turns used to be. It is not shown in the transcript.
113
116
  - **No network, no telemetry.** Everything is local; file snapshots may contain sensitive content, so treat `<DSH_HOME>/session-rewind/` like your workspace.
114
117
  - **Private client seams.** Hiding rows and injecting the ↶ button rely on DSH's internal view/slot registries. They are shape-checked at install and degrade to “context rewound, transcript not hidden” or “button missing” rather than breaking a session.
118
+ - **Recall is a draft edit.** It fills the composer through the host facade, so `Ctrl+Z` steps back through recalls like any other draft change; it never appends to the session or the model context.
115
119
  - **POSIX/macOS paths only** in this version; Windows separators and permission bits are not verified.
120
+ - **macOS overlays don't drag the window.** The drawer, its mask and the confirm dialog declare `data-window-drag-exclude`, so dragging over them while they cover the title bar behaves normally (verified with a real mouse drag on DSH 0.2.2-rc.2; earlier builds could drag the whole window).
116
121
 
117
122
  ## Compatibility
118
123
 
119
- | DSH version | Status |
120
- | ----------- | ------------------------------------------------------------------------------------------------------ |
121
- | 0.2.2-rc.1 | Verified: 185 unit tests + `smoke:session-rewind` 34/34 + desktop 36/36 (`--deep`, file mode included) |
124
+ | DSH version | Status |
125
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
126
+ | 0.2.2-rc.2 | Verified 2026-11-12: 205 unit tests + `pnpm probe:runtime` 67/67 + web `smoke:session-rewind` 49/49 (throwaway DSH home, real host). Desktop on the live app over CDP: 23/23 read-only + `--recall` 30/30 + `--deep --workspace` 39/39 (real model call, file-mode end to end: write → rewind → trash → rescue-point restore). |
127
+ | 0.2.2-rc.1 | Verified 2026-11-09: 205 unit tests + web `smoke:session-rewind` 49/49 (temp DSH home) + desktop `smoke:session-rewind-desktop` 23/23 read-only, 30/30 with `--recall`, and 39/39 with `--deep` (file mode end to end, real Electron renderer over CDP); the full `--recall --deep` combination is 46/46. |
122
128
 
123
129
  The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is pre-release and semver ranges over prereleases are unreliable (see [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md)). Treat the table as the compatibility statement. After a DSH upgrade run `pnpm probe:runtime`, `pnpm smoke:session-rewind`, and check the plugin's diagnostics tab; if a private seam changed, the plugin degrades instead of breaking the session.
124
130
 
@@ -128,7 +134,7 @@ The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is
128
134
  pnpm test # unit tests (host engine, host RPC, client helpers)
129
135
  pnpm verify # repo-wide gates
130
136
  pnpm smoke:session-rewind # temp DSH home + real GUI end-to-end (needs dsh & ego-browser)
131
- pnpm smoke:session-rewind-desktop # desktop (Electron) check: 21 read-only + 12 with --deep --workspace <dir>
137
+ pnpm smoke:session-rewind-desktop # desktop (Electron) read-only check (23); --recall adds 7 composer-recall checks; --deep --workspace <dir> adds the file-mode run (39 total)
132
138
  ```
133
139
 
134
140
  Design notes: [ADR 0006](../../docs/adr/0006-session-rewind-architecture.md) and [ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md) (Chinese; the repository docs are written in Chinese).
package/README.zh-CN.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  > DeepSeek Harness 的「就地回溯」:把会话退回到任意一条用户消息之前,目标消息及其后的内容同时从模型上下文和渲染对话中撤回;可选择一并还原这些轮次改过的工作区文件;目标消息文本(和图片)自动回填输入框。
4
4
 
5
- [![npm](https://img.shields.io/npm/v/@kangyishuai/dsh-session-rewind?color=cb3837&logo=npm)](https://www.npmjs.com/package/@kangyishuai/dsh-session-rewind) [![DSH](https://img.shields.io/badge/verified%20on%20DSH-0.2.2--rc.1-4b6bfb)](#兼容性) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
+ [![npm](https://img.shields.io/npm/v/@kangyishuai/dsh-session-rewind?color=cb3837&logo=npm)](https://www.npmjs.com/package/@kangyishuai/dsh-session-rewind) [![DSH](https://img.shields.io/badge/verified%20on%20DSH-0.2.2--rc.2-4b6bfb)](#兼容性) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
6
 
7
7
  [English](./README.md)
8
8
 
9
9
  非官方插件,与 DeepSeek 无关。面向 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)。
10
10
 
11
- > **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:185 个单测、一次性 DSH 目录里 34 项 GUI 断言、以及**真实桌面版 36 项断言**(只读界面 + 文件模式:新建文件进回收站、修改文件还原、救援点恢复、时间线)。`pnpm gate:session-rewind` 一条命令跑完发版门禁。插件还年轻,重要数据请自行备份。
11
+ > **状态:早期。** 已针对当前目标 DSH 版本端到端验证——具体跑了什么、什么时候跑的,见[兼容性](#兼容性)表格(单测、一次性 DSH 目录里的 GUI 冒烟、真实桌面版只读与 `--recall`)。`pnpm gate:session-rewind` 一条命令跑完发版门禁。插件还年轻,重要数据请自行备份。
12
12
 
13
13
  ## 它做什么
14
14
 
@@ -21,6 +21,7 @@
21
21
  - **状态变更提示**:如果要撤回的区间里改过会话状态(plan 模式、goal、待办、模型选择、权限/沙箱预设、智能体预设、定时任务、工作目录),确认框会列出来并明确说明——回溯只撤回对话,**不会**回滚这些状态。
22
22
  - **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
23
23
  - **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
24
+ - **不回溯也能召回历史输入**:输入框为空时(或光标在第一行时)按 `↑`,上一条已发送的提示词回到草稿;再按 `↑` 继续往前翻,`↓` 往回走、到底时恢复你浏览前正在写的草稿。它只替换草稿文本,不从上下文撤回、不发送、不改写任何东西。可在**设置**里关闭。
24
25
  - **多视图自动同步**:在一个标签页/窗口里回溯后,其它已打开的同会话视图会立刻隐藏被撤回的行,无需刷新(通知走 `localStorage`,渲染只认宿主 cuts)。
25
26
  - **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(时间线:每次回溯与每次救援点恢复,新在上;范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
26
27
 
@@ -69,6 +70,8 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
69
70
 
70
71
  **命令。** `/rewind` 打开同一份候选列表;`/rewind @<序号> [chat|both]` 直接执行(序号在列表里可见)。客户端会接管裸 `/rewind`,不会把命令发给模型。
71
72
 
73
+ **输入框历史 — `↑` / `↓`。** 把光标放进输入框后按 `↑`:本会话最新一条已发送提示词填进草稿;再按 `↑` 继续往前,`↓` 往回走,越过最新一条后再按一次 `↓` 就恢复你浏览前正在写的草稿。空草稿或光标在第一行时生效;多行草稿里光标在中间行时,方向键仍然只是移动光标。召回不回溯、不发送、不写会话日志。历史覆盖仍在模型可见上下文里的消息(取最新 100 条),已被回溯撤下的消息不在其中——它们在面板的**已撤回**页签里。
74
+
72
75
  **回溯之后**,目标文本回到输入框(图片尽力重新挂回)并获得焦点。如果输入框里本来有草稿,不会被覆盖,并会提示草稿已保留。
73
76
 
74
77
  **会被拒绝的情况**
@@ -112,13 +115,16 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
112
115
  - **marker 对模型可见**:在被撤回回合的位置,模型会看到一条很短的 `(empty message)` user 消息;界面不显示它。
113
116
  - **不联网、不上报**;文件快照可能含敏感内容,请把 `<DSH_HOME>/session-rewind/` 当工作区数据对待。
114
117
  - **客户端私有 seam**:隐藏行和注入 ↶ 按钮依赖 DSH 内部 view/slot 注册表;安装时做形状检查,失效时降级为「上下文已回溯、界面不隐藏」或「按钮消失」,不破坏会话。
118
+ - **召回只是改草稿**:它通过宿主输入框 facade 整篇替换草稿,所以 `Ctrl+Z` 会像普通编辑一样一步步退回;它不会向会话或模型上下文追加任何东西。
115
119
  - **本版只验证 POSIX/macOS 路径**;Windows 分隔符与权限位未验证。
120
+ - **macOS 浮层不会拖走窗口。** 抽屉、遮罩与确认框都声明了 `data-window-drag-exclude`,覆盖页头时拖动行为正常(在 DSH 0.2.2-rc.2 上用真实鼠标拖动验证;更早的构建会把整个窗口拖走)。
116
121
 
117
122
  ## 兼容性
118
123
 
119
- | DSH 版本 | 状态 |
120
- | ---------- | ---------------------------------------------------------------------------------------- |
121
- | 0.2.2-rc.1 | 已验证:185 个单测 + `smoke:session-rewind` 34/34 + 桌面版 36/36(`--deep`,含文件模式) |
124
+ | DSH 版本 | 状态 |
125
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | 0.2.2-rc.2 | 2026-11-12 已验证:205 个单测 + `pnpm probe:runtime` 67/67 + Web `smoke:session-rewind` 49/49(临时 DSH home,真实宿主)。桌面(live 桌面版,CDP):只读 23/23 + `--recall` 30/30 + `--deep --workspace` 39/39(真实模型调用,文件模式端到端:写文件 → 回溯 → 回收站 → 救援点还原)。 |
127
+ | 0.2.2-rc.1 | 2026-11-09 已验证:205 个单测 + Web `smoke:session-rewind` 49/49(临时 DSH home)+ 桌面 `smoke:session-rewind-desktop` 只读 23/23、加 `--recall` 30/30、加 `--deep` 39/39(文件模式端到端,真实 Electron 渲染进程走 CDP);全量组合 `--recall --deep` 46/46。 |
122
128
 
123
129
  插件不声明 `engines.dsh`,也不声明 `@deepseek-ai/*` 的 peer 范围:DSH 还在预发布阶段,预发布版本的 semver 范围不可靠(见 [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md))。以这张表为准。DSH 升级后跑 `pnpm probe:runtime`、`pnpm smoke:session-rewind`,并看插件诊断页签;私有 seam 变了会降级而不是让会话出错。
124
130
 
@@ -128,7 +134,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
128
134
  pnpm test # 单测(宿主文件引擎、宿主 RPC、客户端 helper)
129
135
  pnpm verify # 仓库统一门禁
130
136
  pnpm smoke:session-rewind # 临时 DSH 目录 + 真实 GUI 端到端(需要 dsh 与 ego-browser)
131
- pnpm smoke:session-rewind-desktop # 桌面(Electron)回归:只读 21 项,加 --deep --workspace <dir> 再 +12 项
137
+ pnpm smoke:session-rewind-desktop # 桌面(Electron)只读回归(23 项);--recall 再加 7 条召回断言;--deep --workspace <dir> 再跑文件模式(合计 39 项)
132
138
  ```
133
139
 
134
140
  设计记录:[ADR 0006](../../docs/adr/0006-session-rewind-architecture.md)、[ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md)。
package/lib/client.js CHANGED
@@ -147,6 +147,9 @@ window.__ModuleLoader__.load({
147
147
  "settings.pageSize": "候选每页数量",
148
148
  "settings.confirmOnList": "从候选列表选择时先确认",
149
149
  "settings.confirmOnListHint": "关闭后,默认「仅回退对话」时会直接执行;文件模式始终先确认。",
150
+ "settings.recall": "输入框 ↑/↓ 召回历史输入",
151
+ "settings.recallHint":
152
+ "空草稿(或光标在第一行)按 ↑ 召回上一条已发送消息,↓ 前进、到底恢复你原来的草稿;只填草稿,不回溯、不发送。",
150
153
  "settings.saved": "已保存",
151
154
  "settings.healthTitle": "诊断",
152
155
  "settings.healthRefresh": "刷新",
@@ -295,6 +298,9 @@ window.__ModuleLoader__.load({
295
298
  "settings.confirmOnList": "Confirm before executing from the list",
296
299
  "settings.confirmOnListHint":
297
300
  "When off, the default conversation-only mode runs directly; file mode always asks first.",
301
+ "settings.recall": "↑/↓ recalls earlier prompts in the composer",
302
+ "settings.recallHint":
303
+ "In an empty draft (or with the caret on the first line) ↑ recalls the last prompt you sent, ↓ walks forward and restores your own draft at the bottom; it only fills the draft — never rewinds, never sends.",
298
304
  "settings.saved": "Saved",
299
305
  "settings.healthTitle": "Diagnostics",
300
306
  "settings.healthRefresh": "Refresh",
@@ -606,6 +612,7 @@ window.__ModuleLoader__.load({
606
612
  defaultMode: "chat",
607
613
  pageSize: 100,
608
614
  confirmOnList: true,
615
+ recallEnabled: true,
609
616
  });
610
617
 
611
618
  /** 候选每页数量的合法范围。 */
@@ -642,7 +649,7 @@ window.__ModuleLoader__.load({
642
649
  /**
643
650
  * 规整客户端偏好(localStorage 里可能是旧版本、脏数据或非对象)。
644
651
  * @param {any} raw
645
- * @returns {{ defaultMode: "chat" | "both", pageSize: number, confirmOnList: boolean }}
652
+ * @returns {{ defaultMode: "chat" | "both", pageSize: number, confirmOnList: boolean, recallEnabled: boolean }}
646
653
  */
647
654
  function sanitizePrefs(raw) {
648
655
  let source = raw && typeof raw === "object" ? raw : {};
@@ -650,6 +657,155 @@ window.__ModuleLoader__.load({
650
657
  defaultMode: source.defaultMode === "both" ? "both" : "chat",
651
658
  pageSize: clampInt(source.pageSize, PAGE_SIZE_MIN, PAGE_SIZE_MAX, PREF_DEFAULTS.pageSize),
652
659
  confirmOnList: source.confirmOnList !== false,
660
+ recallEnabled: source.recallEnabled !== false,
661
+ };
662
+ }
663
+
664
+ // ------------------------------------------------------------------
665
+ // 输入历史召回(非破坏性):纯数据部分
666
+ // ------------------------------------------------------------------
667
+
668
+ /** 召回历史条目上限(更旧的先丢)。 */
669
+ let RECALL_LIMIT = 100;
670
+
671
+ /**
672
+ * 历史缓存的保鲜期(ms)。宿主 RPC 是本地调用,成本很低;用「先给旧的、后台刷新」
673
+ * 而不是每次按键都等一次往返。
674
+ */
675
+ let RECALL_TTL_MS = 1500;
676
+
677
+ /** 判定首/末行时允许的误差(行高比例):行内任意位置都算该行。 */
678
+ let CARET_LINE_RATIO = 0.6;
679
+
680
+ /**
681
+ * 把宿主 `sessionRewind/candidates` 的一页(新→旧)转成召回历史(旧→新)。
682
+ * host 已经过滤掉非真人消息、已被 compaction 遮蔽的消息,这里只补两件事:
683
+ * 丢掉空文本、截到 RECALL_LIMIT。
684
+ * @param {any} candidates
685
+ * @returns {Array<{seq: number, text: string}>}
686
+ */
687
+ function historyFromCandidates(candidates) {
688
+ let source = Array.isArray(candidates) ? candidates : [];
689
+ /** @type {Array<{seq: number, text: string}>} */
690
+ let out = [];
691
+ for (let i = 0; i < source.length; i += 1) {
692
+ let item = source[i];
693
+ if (!item || typeof item.seq !== "number") continue;
694
+ let text = typeof item.text === "string" ? item.text : "";
695
+ if (text.trim() === "") continue;
696
+ out.push({ seq: item.seq, text: text });
697
+ }
698
+ /* host 给的是新→旧;召回要按时间升序(末尾最新) */
699
+ out.reverse();
700
+ return out.slice(-RECALL_LIMIT);
701
+ }
702
+
703
+ /**
704
+ * 是否是召回手势:裸 ↑/↓、无组合键、非输入法组合、尚未被别的监听者消费。
705
+ * 宿主在 fixed input 通道里已经算好 composing/defaultPrevented,这里只做判定。
706
+ * @param {any} gesture
707
+ * @returns {boolean}
708
+ */
709
+ function isRecallGesture(gesture) {
710
+ if (!gesture || typeof gesture !== "object") return false;
711
+ if (gesture.code !== "ArrowUp" && gesture.code !== "ArrowDown") return false;
712
+ if (gesture.control || gesture.alt || gesture.shift || gesture.meta) return false;
713
+ if (gesture.composing) return false;
714
+ if (gesture.defaultPrevented) return false;
715
+ return true;
716
+ }
717
+
718
+ /**
719
+ * 浏览一步(纯函数):
720
+ * - `index === -1` 表示停在「正在编辑的那一行」;
721
+ * - `saved` 是开始浏览前用户自己的草稿,一路 ↓ 到底时原样还回去;
722
+ * - `restore !== null` 表示这一步要写回的文本(live line),否则按 index 取历史。
723
+ * @param {{index: number, saved: string}} state
724
+ * @param {"ArrowUp" | "ArrowDown"} code
725
+ * @param {number} total 历史条数
726
+ * @param {string} draft 当前草稿
727
+ * @returns {{index: number, saved: string, restore: string | null}}
728
+ */
729
+ function recallStep(state, code, total, draft) {
730
+ let index = state ? state.index : -1;
731
+ let saved = state ? state.saved : "";
732
+ if (total <= 0) return { index: -1, saved: "", restore: null };
733
+ if (code === "ArrowUp") {
734
+ if (index < 0) return { index: total - 1, saved: draft || "", restore: null };
735
+ return { index: Math.max(0, index - 1), saved: saved, restore: null };
736
+ }
737
+ if (index < 0) return { index: -1, saved: "", restore: null };
738
+ let next = index + 1;
739
+ if (next >= total) return { index: -1, saved: "", restore: saved };
740
+ return { index: next, saved: saved, restore: null };
741
+ }
742
+
743
+ /**
744
+ * 光标是否贴着输入框的首行 / 末行(只看矩形,便于单测)。
745
+ *
746
+ * 返回两个独立的布尔值而不是一个枚举:单行草稿的「首行」就是「末行」,
747
+ * 用枚举会丢掉其中一个(↑ 能用、↓ 反而被挡住的经典 bug)。
748
+ * @param {any} rect 光标的 getBoundingClientRect()
749
+ * @param {any} box 输入框的 getBoundingClientRect()
750
+ * @param {number} lineHeight 行高(px)
751
+ * @returns {{first: boolean, last: boolean}}
752
+ */
753
+ function caretEdges(rect, box, lineHeight) {
754
+ let none = { first: false, last: false };
755
+ if (!rect || !box) return none;
756
+ if (
757
+ typeof rect.top !== "number" ||
758
+ typeof rect.bottom !== "number" ||
759
+ typeof box.top !== "number" ||
760
+ typeof box.bottom !== "number"
761
+ )
762
+ return none;
763
+ let line = typeof lineHeight === "number" && lineHeight > 0 ? lineHeight : 20;
764
+ let tolerance = line * CARET_LINE_RATIO;
765
+ return {
766
+ first: rect.top - box.top <= tolerance,
767
+ last: box.bottom - rect.bottom <= tolerance,
768
+ };
769
+ }
770
+
771
+ /**
772
+ * 每会话的召回历史缓存:`peek` 拿旧值(立刻可用),`fresh` 判断要不要后台刷新。
773
+ * 宿主会话日志是唯一事实来源,这里只缓存它的投影。
774
+ * @param {() => number} [now] 可注入的时钟,便于单测
775
+ */
776
+ function createRecallCache(now) {
777
+ let clock = typeof now === "function" ? now : Date.now;
778
+ /** @type {Map<string, {items: Array<{seq: number, text: string}>, at: number}>} */
779
+ let entries = new Map();
780
+
781
+ /** @param {any} sessionId */
782
+ function keyOf(sessionId) {
783
+ return typeof sessionId === "string" ? sessionId : "";
784
+ }
785
+
786
+ return {
787
+ /** 最近一次读到的历史(可能已过期;没有时 undefined)。 */
788
+ peek(/** @type {any} */ sessionId) {
789
+ let entry = entries.get(keyOf(sessionId));
790
+ return entry ? entry.items : undefined;
791
+ },
792
+ /** 是否还在保鲜期内。 */
793
+ fresh(/** @type {any} */ sessionId) {
794
+ let entry = entries.get(keyOf(sessionId));
795
+ if (!entry) return false;
796
+ return clock() - entry.at < RECALL_TTL_MS;
797
+ },
798
+ /** 写入一页历史。 */
799
+ set(/** @type {any} */ sessionId, /** @type {any} */ items) {
800
+ entries.set(keyOf(sessionId), {
801
+ items: Array.isArray(items) ? items : [],
802
+ at: clock(),
803
+ });
804
+ },
805
+ /** 回溯/切换后丢弃缓存,下一次按键重新拉。 */
806
+ forget(/** @type {any} */ sessionId) {
807
+ entries.delete(keyOf(sessionId));
808
+ },
653
809
  };
654
810
  }
655
811
 
@@ -2260,7 +2416,7 @@ window.__ModuleLoader__.load({
2260
2416
 
2261
2417
  return h(
2262
2418
  "div",
2263
- { className: "dsh-srw-mask", onClick: () => api.close() },
2419
+ { className: "dsh-srw-mask", "data-window-drag-exclude": "", onClick: () => api.close() },
2264
2420
  h(
2265
2421
  "div",
2266
2422
  {
@@ -2708,6 +2864,24 @@ window.__ModuleLoader__.load({
2708
2864
  h("div", { className: "dsh-srw-option-hint" }, t("settings.confirmOnListHint")),
2709
2865
  );
2710
2866
 
2867
+ let recallRow = h(
2868
+ "div",
2869
+ { className: "dsh-srw-setting" },
2870
+ h(
2871
+ "label",
2872
+ { className: "dsh-srw-check" },
2873
+ h("input", {
2874
+ type: "checkbox",
2875
+ checked: prefs.recallEnabled !== false,
2876
+ onChange: (/** @type {any} */ event) => {
2877
+ if (event?.target) api.updatePrefs({ recallEnabled: event.target.checked === true });
2878
+ },
2879
+ }),
2880
+ h("span", null, t("settings.recall")),
2881
+ ),
2882
+ h("div", { className: "dsh-srw-option-hint" }, t("settings.recallHint")),
2883
+ );
2884
+
2711
2885
  let healthBlock;
2712
2886
  if (health.loading) {
2713
2887
  healthBlock = h("div", { className: "dsh-srw-dim" }, t("settings.healthLoading"));
@@ -2787,6 +2961,7 @@ window.__ModuleLoader__.load({
2787
2961
  ),
2788
2962
  pageRow,
2789
2963
  confirmRow,
2964
+ recallRow,
2790
2965
  data.saved === true
2791
2966
  ? h("div", { className: "dsh-srw-dim dsh-srw-saved" }, t("settings.saved"))
2792
2967
  : null,
@@ -2819,11 +2994,16 @@ window.__ModuleLoader__.load({
2819
2994
 
2820
2995
  return h(
2821
2996
  "div",
2822
- { className: "dsh-srw-drawer-mask", onClick: () => api.closeDrawer() },
2997
+ {
2998
+ className: "dsh-srw-drawer-mask",
2999
+ "data-window-drag-exclude": "",
3000
+ onClick: () => api.closeDrawer(),
3001
+ },
2823
3002
  h(
2824
3003
  "aside",
2825
3004
  {
2826
3005
  className: "dsh-srw-drawer",
3006
+ "data-window-drag-exclude": "",
2827
3007
  role: "dialog",
2828
3008
  "aria-modal": "true",
2829
3009
  "aria-label": t("drawer.title"),
@@ -3174,6 +3354,9 @@ window.__ModuleLoader__.load({
3174
3354
 
3175
3355
  let cutStore = createCutStore();
3176
3356
 
3357
+ /** 输入框召回历史缓存(来源是宿主 sessionRewind/candidates)。 */
3358
+ let recallCache = createRecallCache();
3359
+
3177
3360
  /** 读客户端偏好(localStorage 缺失/脏数据都退回默认值)。 */
3178
3361
  function readPrefs() {
3179
3362
  try {
@@ -3397,8 +3580,13 @@ window.__ModuleLoader__.load({
3397
3580
  }
3398
3581
  }
3399
3582
 
3400
- function fillComposer(/** @type {any} */ sessionId, /** @type {any} */ text) {
3401
- let facade;
3583
+ /**
3584
+ * 某会话输入框的 façade(`conversation.input.for(scope)`)。
3585
+ * 拿不到时返回 undefined —— 调用方自己决定退化路径。
3586
+ * @param {any} sessionId
3587
+ * @returns {any}
3588
+ */
3589
+ function composerFacadeOf(/** @type {any} */ sessionId) {
3402
3590
  try {
3403
3591
  let conversation = safeGet(ctx, "conversation");
3404
3592
  let scope =
@@ -3410,11 +3598,281 @@ window.__ModuleLoader__.load({
3410
3598
  typeof conversation.input.for === "function" &&
3411
3599
  scope !== undefined
3412
3600
  ) {
3413
- facade = conversation.input.for(scope);
3601
+ return conversation.input.for(scope);
3602
+ }
3603
+ } catch (_error) {
3604
+ /* 退化到 DOM 通道 */
3605
+ }
3606
+ return undefined;
3607
+ }
3608
+
3609
+ /**
3610
+ * 把整段文本写进输入框草稿(召回用:允许覆盖当前草稿,与 fillComposer 的区别就在这里)。
3611
+ * 优先走宿主 façade 的 `setDraft`;退化路径与 fillComposer 的 DOM 兜底一致。
3612
+ * @param {any} sessionId
3613
+ * @param {any} text
3614
+ * @returns {"ok" | "none"}
3615
+ */
3616
+ function writeComposerDraft(/** @type {any} */ sessionId, /** @type {any} */ text) {
3617
+ let value = typeof text === "string" ? text : "";
3618
+ let facade = composerFacadeOf(sessionId);
3619
+ if (facade && typeof facade.setDraft === "function") {
3620
+ try {
3621
+ facade.setDraft(value);
3622
+ return "ok";
3623
+ } catch (_error) {
3624
+ /* 退化到 DOM 通道 */
3625
+ }
3626
+ }
3627
+ let el = null;
3628
+ try {
3629
+ el = document.querySelector("[data-composer-input]");
3630
+ } catch (_error) {
3631
+ el = null;
3632
+ }
3633
+ if (!el || !(el instanceof HTMLElement)) return "none";
3634
+ try {
3635
+ el.focus();
3636
+ let selection =
3637
+ typeof document.getSelection === "function" ? document.getSelection() : null;
3638
+ if (selection) {
3639
+ let range = document.createRange();
3640
+ range.selectNodeContents(el);
3641
+ selection.removeAllRanges();
3642
+ selection.addRange(range);
3643
+ }
3644
+ let ok = false;
3645
+ try {
3646
+ ok = document.execCommand("insertText", false, value);
3647
+ } catch (_error) {
3648
+ ok = false;
3649
+ }
3650
+ if (!ok) {
3651
+ el.textContent = value;
3652
+ el.dispatchEvent(new Event("input", { bubbles: true }));
3653
+ ok = true;
3654
+ }
3655
+ return ok ? "ok" : "none";
3656
+ } catch (_error) {
3657
+ return "none";
3658
+ }
3659
+ }
3660
+
3661
+ /**
3662
+ * 光标是否贴着输入框首/末行(浏览器侧封装;纯判定交给 caretEdges)。
3663
+ * @param {any} el contenteditable 输入框
3664
+ * @returns {{first: boolean, last: boolean}}
3665
+ */
3666
+ function caretEdgesOf(/** @type {any} */ el) {
3667
+ try {
3668
+ let selection =
3669
+ typeof document.getSelection === "function" ? document.getSelection() : null;
3670
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed !== true)
3671
+ return { first: false, last: false };
3672
+ let range = selection.getRangeAt(0).cloneRange();
3673
+ range.collapse(true);
3674
+ let rect = range.getBoundingClientRect();
3675
+ if (
3676
+ !rect ||
3677
+ (rect.top === 0 && rect.bottom === 0 && rect.width === 0 && rect.height === 0)
3678
+ ) {
3679
+ let rects = range.getClientRects();
3680
+ if (rects && rects.length > 0) rect = rects[0];
3414
3681
  }
3682
+ let box = el.getBoundingClientRect();
3683
+ let style =
3684
+ typeof window.getComputedStyle === "function" ? window.getComputedStyle(el) : null;
3685
+ let lineHeight = style ? parseFloat(style.lineHeight) : NaN;
3686
+ if (!Number.isFinite(lineHeight) || lineHeight <= 0)
3687
+ lineHeight = rect && rect.height > 0 ? rect.height : 20;
3688
+ return caretEdges(rect, box, lineHeight);
3415
3689
  } catch (_error) {
3416
- facade = undefined;
3690
+ return { first: false, last: false };
3417
3691
  }
3692
+ }
3693
+
3694
+ /**
3695
+ * 读输入框草稿与提交阶段:优先宿主 façade,拿不到时退回 DOM 文本。
3696
+ * @param {any} sessionId
3697
+ * @param {any} target 焦点里的 contenteditable
3698
+ * @returns {{draft: string, phase: string}}
3699
+ */
3700
+ function readComposer(/** @type {any} */ sessionId, /** @type {any} */ target) {
3701
+ let facade = composerFacadeOf(sessionId);
3702
+ let snapshot = facade ? facade.snapshot : undefined;
3703
+ if (snapshot) {
3704
+ return {
3705
+ draft: typeof snapshot.draft === "string" ? snapshot.draft : "",
3706
+ phase: typeof snapshot.phase === "string" ? snapshot.phase : "plain",
3707
+ };
3708
+ }
3709
+ return {
3710
+ draft: typeof target?.textContent === "string" ? target.textContent : "",
3711
+ phase: "plain",
3712
+ };
3713
+ }
3714
+
3715
+ /**
3716
+ * 输入框历史召回(↑/↓):只替换草稿,绝不回溯、绝不发送。
3717
+ *
3718
+ * 历史来自本插件自己的宿主 RPC `sessionRewind/candidates`(真人消息、surface 感知、
3719
+ * 完整文本),按会话缓存;键处理走宿主的 fixed input 通道
3720
+ * (`shortcuts.observeFixedInput`)——裸 ↑/↓ 已被宿主固定动作 `move` 占用,
3721
+ * 注册快捷键会冲突,而观察通道连输入法组合与「是否已被消费」都替我们算好了。
3722
+ * @param {any} scope 拿到 shortcuts 的作用域
3723
+ * @param {any} cache createRecallCache() 的实例
3724
+ */
3725
+ function installComposerRecall(/** @type {any} */ scope, /** @type {any} */ cache) {
3726
+ let shortcuts = scope?.shortcuts;
3727
+ if (!shortcuts || typeof shortcuts.observeFixedInput !== "function") return;
3728
+ /**
3729
+ * 每会话的浏览状态。`written` 记录我们最后写进草稿的文本:
3730
+ * façade 的快照可能晚一拍,用它区分「快照还没跟上」和「用户真的改了」。
3731
+ * @type {Map<string, {index: number, saved: string, written: string}>}
3732
+ */
3733
+ let states = new Map();
3734
+ /** @type {Map<string, Promise<any>>} 并发合并:同一会话只发一次 RPC。 */
3735
+ let inflight = new Map();
3736
+
3737
+ /** @param {any} sessionId */
3738
+ function stateOf(sessionId) {
3739
+ let found = states.get(sessionId);
3740
+ if (!found) {
3741
+ found = { index: -1, saved: "", written: "" };
3742
+ states.set(sessionId, found);
3743
+ }
3744
+ return found;
3745
+ }
3746
+
3747
+ /**
3748
+ * 拉一页历史写进缓存。
3749
+ * @param {any} sessionId
3750
+ * @returns {Promise<Array<{seq: number, text: string}>>}
3751
+ */
3752
+ function loadHistory(sessionId) {
3753
+ let running = inflight.get(sessionId);
3754
+ if (running) return running;
3755
+ let promise = call("candidates", { sessionId: sessionId, limit: RECALL_LIMIT })
3756
+ .then((value) => historyFromCandidates(normalizeCandidatePage(value).candidates))
3757
+ .catch(() => [])
3758
+ .then((items) => {
3759
+ inflight.delete(sessionId);
3760
+ cache.set(sessionId, items);
3761
+ return items;
3762
+ });
3763
+ inflight.set(sessionId, promise);
3764
+ return promise;
3765
+ }
3766
+
3767
+ /**
3768
+ * 走一步浏览:算新状态、必要时写草稿。
3769
+ * @param {any} sessionId
3770
+ * @param {"ArrowUp" | "ArrowDown"} code
3771
+ * @param {string} draft
3772
+ * @param {Array<{seq: number, text: string}>} items
3773
+ * @param {any} input fixed-input 输入(异步补填时传空对象,没有 consume)
3774
+ * @returns {void}
3775
+ */
3776
+ function stepOnce(sessionId, code, draft, items, input) {
3777
+ let state = stateOf(sessionId);
3778
+ if (state.index >= 0) {
3779
+ let current = items[state.index];
3780
+ /* 用户自己改过草稿(既不是我们填的那条,也不是刚写进去的那条)→ 退出浏览 */
3781
+ if (!current || (current.text !== draft && state.written !== draft)) {
3782
+ state.index = -1;
3783
+ state.saved = "";
3784
+ state.written = "";
3785
+ return;
3786
+ }
3787
+ }
3788
+ let step = recallStep(state, code, items.length, draft);
3789
+ state.index = step.index;
3790
+ state.saved = step.saved;
3791
+ let value =
3792
+ step.restore !== null
3793
+ ? step.restore
3794
+ : step.index >= 0 && items[step.index]
3795
+ ? items[step.index].text
3796
+ : null;
3797
+ if (typeof input?.consume === "function") input.consume();
3798
+ if (value === null) return;
3799
+ if (writeComposerDraft(sessionId, value) !== "ok") {
3800
+ showToastKey("toast.refillNoComposer");
3801
+ return;
3802
+ }
3803
+ state.written = value;
3804
+ }
3805
+
3806
+ /** @param {any} input */
3807
+ function onFixedInput(input) {
3808
+ try {
3809
+ if (!prefs.recallEnabled) return;
3810
+ if (input?.type !== "keydown") return;
3811
+ if (!isRecallGesture(input.gesture)) return;
3812
+ let context = input.context || {};
3813
+ if (context.modal !== null && context.modal !== undefined) return;
3814
+ if (context.region !== "editable") return;
3815
+ let target = context.target;
3816
+ if (target?.isContentEditable !== true) return;
3817
+ let occurrence = target.closest ? target.closest("[data-conversation-session]") : null;
3818
+ if (!occurrence) return;
3819
+ let sessionId = occurrence.getAttribute("data-conversation-session");
3820
+ if (!sessionId || isInert(sessionId)) return;
3821
+
3822
+ let code = input.gesture.code;
3823
+ let composer = readComposer(sessionId, target);
3824
+ if (composer.phase !== "plain") return;
3825
+ let draft = composer.draft;
3826
+ let browsing = stateOf(sessionId).index >= 0;
3827
+
3828
+ if (!browsing) {
3829
+ if (code === "ArrowDown") return;
3830
+ /* 有草稿时只有光标贴着首行才接管 ↑,否则方向键交给编辑器 */
3831
+ if (draft.trim() !== "" && !caretEdgesOf(target).first) return;
3832
+ } else if (code === "ArrowDown" && !caretEdgesOf(target).last) {
3833
+ return;
3834
+ }
3835
+
3836
+ /* 浏览中:列表必须保持稳定(否则下标会指向别的消息),只有浏览开始时才重拉。 */
3837
+ if (browsing) {
3838
+ let browsingItems = cache.peek(sessionId);
3839
+ if (browsingItems === undefined || browsingItems.length === 0) return;
3840
+ stepOnce(sessionId, code, draft, browsingItems, input);
3841
+ return;
3842
+ }
3843
+
3844
+ /* 开始浏览:缓存还新鲜就直接用,否则先拉一份最新的(本地 RPC,毫秒级),
3845
+ * 这样「刚发出去的那条」一定在历史里。 */
3846
+ let items = cache.peek(sessionId);
3847
+ if (items !== undefined && cache.fresh(sessionId)) {
3848
+ stepOnce(sessionId, code, draft, items, input);
3849
+ return;
3850
+ }
3851
+
3852
+ /* 冷启动或过期:先把方向键吃掉(避免光标乱跳),拿到历史再填草稿 */
3853
+ if (typeof input.consume === "function") input.consume();
3854
+ loadHistory(sessionId)
3855
+ .then((loaded) => {
3856
+ if (loaded.length === 0) return;
3857
+ let again = readComposer(sessionId, target);
3858
+ if (again.phase !== "plain" || again.draft !== draft) return;
3859
+ if (stateOf(sessionId).index >= 0) return;
3860
+ stepOnce(sessionId, "ArrowUp", draft, loaded, null);
3861
+ })
3862
+ .catch(() => {});
3863
+ } catch (_error) {
3864
+ /* 召回是锦上添花:任何异常都不影响输入框本身 */
3865
+ }
3866
+ }
3867
+
3868
+ scope.effect(
3869
+ () => shortcuts.observeFixedInput(onFixedInput),
3870
+ "session-rewind: composer recall",
3871
+ );
3872
+ }
3873
+
3874
+ function fillComposer(/** @type {any} */ sessionId, /** @type {any} */ text) {
3875
+ let facade = composerFacadeOf(sessionId);
3418
3876
  if (facade) {
3419
3877
  let draft = "";
3420
3878
  try {
@@ -3796,6 +4254,8 @@ window.__ModuleLoader__.load({
3796
4254
  markerSeq: summary.markerSeq === null ? target.seq : summary.markerSeq,
3797
4255
  });
3798
4256
  rebuildSessionById(sessionId);
4257
+ /* 召回历史也要跟着收缩:被这次回溯撤下的消息不能再被 ↑ 翻出来 */
4258
+ recallCache.forget(sessionId);
3799
4259
  let outcome = fillComposer(sessionId, summary.text || target.text || "");
3800
4260
  // 同源其它视图也要立刻隐藏这些行(它们不会被这次 append 唤醒)。
3801
4261
  broadcastCut(sessionId, summary);
@@ -4476,6 +4936,9 @@ window.__ModuleLoader__.load({
4476
4936
  }),
4477
4937
  "session-rewind: esc esc",
4478
4938
  );
4939
+
4940
+ // 输入框 ↑/↓ 历史召回(非破坏性):同一通道的第二个观察者。
4941
+ installComposerRecall(scope, recallCache);
4479
4942
  });
4480
4943
  } catch (_error) {
4481
4944
  /* shortcuts 缺失时降级:无 Esc Esc 入口 */
@@ -4645,6 +5108,15 @@ window.__ModuleLoader__.load({
4645
5108
  rewindSummaryOf: rewindSummaryOf,
4646
5109
  normalizeTab: normalizeTab,
4647
5110
  modeLabelKey: modeLabelKey,
5111
+ // v3:输入历史召回(非破坏性)
5112
+ RECALL_LIMIT: RECALL_LIMIT,
5113
+ CARET_LINE_RATIO: CARET_LINE_RATIO,
5114
+ historyFromCandidates: historyFromCandidates,
5115
+ isRecallGesture: isRecallGesture,
5116
+ recallStep: recallStep,
5117
+ caretEdges: caretEdges,
5118
+ createRecallCache: createRecallCache,
5119
+ RECALL_TTL_MS: RECALL_TTL_MS,
4648
5120
  dictionaries: Object.freeze({ zh: zh, en: en }),
4649
5121
  }),
4650
5122
  });
package/locale/en.json CHANGED
@@ -89,6 +89,8 @@
89
89
  "settings.pageSize": "Candidates per page",
90
90
  "settings.confirmOnList": "Confirm before executing from the list",
91
91
  "settings.confirmOnListHint": "When off, the default conversation-only mode runs directly; file mode always asks first.",
92
+ "settings.recall": "↑/↓ recalls earlier prompts in the composer",
93
+ "settings.recallHint": "In an empty draft (or with the caret on the first line) ↑ recalls the last prompt you sent, ↓ walks forward and restores your own draft at the bottom; it only fills the draft — never rewinds, never sends.",
92
94
  "settings.saved": "Saved",
93
95
  "settings.healthTitle": "Diagnostics",
94
96
  "settings.healthRefresh": "Refresh",
package/locale/zh.json CHANGED
@@ -89,6 +89,8 @@
89
89
  "settings.pageSize": "候选每页数量",
90
90
  "settings.confirmOnList": "从候选列表选择时先确认",
91
91
  "settings.confirmOnListHint": "关闭后,默认「仅回退对话」时会直接执行;文件模式始终先确认。",
92
+ "settings.recall": "输入框 ↑/↓ 召回历史输入",
93
+ "settings.recallHint": "空草稿(或光标在第一行)按 ↑ 召回上一条已发送消息,↓ 前进、到底恢复你原来的草稿;只填草稿,不回溯、不发送。",
92
94
  "settings.saved": "已保存",
93
95
  "settings.healthTitle": "诊断",
94
96
  "settings.healthRefresh": "刷新",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangyishuai/dsh-session-rewind",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "In-place conversation rewind for DSH: withdraw a user message and everything after it, then refill the composer",
5
5
  "keywords": [
6
6
  "dsh",