dsh-rewind-plugin 0.1.9 → 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
@@ -2,19 +2,20 @@
2
2
 
3
3
  DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
4
4
 
5
- > 状态:v0.1.9 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
5
+ > 状态:v0.2.0 已实现(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
6
6
 
7
- ## 实现状态(v0.1.9
7
+ ## 实现状态(v0.2.0
8
8
 
9
- - ✅ host 端 `/rewind` 命令(两步文本引导 + 直接执行 + `preview` 影响清单)
10
- - ✅ host 端变更台账(`tools/execute` 捕获 before、`tools/post-execute` 提交),按会话隔离
9
+ - ✅ host 端 `/rewind` 命令(参数化形式作为 按钮的内部调用通道;手动输入由 client 拦截)
10
+ - ✅ **Claude Code 式 checkpoint 文件回退**:`tools/execute` 捕获写前备份、`tools/post-execute` 按消息分组**落盘**提交(`~/.dsh/rewind-snapshots`),重启后仍可还原
11
11
  - ✅ **与其他审批类插件共存**:捕获在 around-dispatch 阶段,`tools/pre-execute` 被 `ask` 短路(如 dsh-edit-approval)后批准仍能记录;被拒绝的调用不留 pending 残留
12
- - ✅ **路径按会话 cwd 解析**(复刻 `dsh-tool-fs` 的 session-cwd 规则),相对路径台账/还原指向真实文件;台账记录解析后的 display path
12
+ - ✅ **路径按会话 cwd 解析**(复刻 `dsh-tool-fs` 的 session-cwd 规则),相对路径备份/还原指向真实文件;记录解析后的 display path
13
13
  - ✅ **fs 服务动态获取**(`ctx.inject(['fs'])`):fs 后挂载也不失效,无 fs 部署时命令仍可用
14
14
  - ✅ 同窗口 in-place 回退:追加空内容标记 + `surfaceOp: replace` 替换目标及之后全部 surface(真实 `dsh-session` 集成测试通过)
15
- - ✅ client 端「回退」按钮(MutationObserver 注入用户消息行操作区)+ 模式选择浮层(含 both 模式影响清单确认)
16
- - ✅ 测试:纯函数单测 + 真实 `dsh-session` 集成测试 + `verify-host` 端到端(14 项,含审批短路/会话 cwd 场景)
17
- - 二期:快捷键、git-first 快照式文件回退、命令路径的 client 两步浮层接管
15
+ - ✅ **还原走 `node:fs` 直写**(不经 fs 服务):文件内容真正落盘;符号链接跳过并警告
16
+ - ✅ client 端「回退」按钮(MutationObserver 注入用户消息行操作区)+ 模式选择浮层(含 both 模式影响清单确认)+ 手动 `/rewind` 输入拦截
17
+ - 测试:纯函数单测 + 真实 `dsh-session` 集成测试 + `verify-host` 端到端(18 项,含审批短路/会话 cwd/新建文件删除/重启持久化场景)
18
+ - ⏳ 二期:快捷键、bash/外部修改的快照覆盖(整树快照)
18
19
 
19
20
  ## 安装
20
21
 
@@ -68,24 +69,24 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
68
69
 
69
70
  - 每条用户消息 hover 出现「↶ 回退」按钮:点击 → 选择「仅回退对话」或
70
71
  「回退对话和代码」(后者先展示影响清单再确认)。
71
- - **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(按钮或 `/rewind <目标>`),
72
+ - **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(使用消息旁的 按钮),
72
73
  效果是**撤回该消息及它之后的所有内容**(含 agent 回复、工具调用)——对话界面与
73
74
  Agent 上下文都回到这条消息发送之前;**该消息的文本自动填入输入框(编辑区)**,
74
75
  可直接修改后重发。命令结果提示"已撤回 seq N 及之后内容"。
75
- - 键盘流:`/rewind` 选消息 → `/rewind <序号> chat|both`;`/rewind preview <目标>`
76
- 只输出影响清单不执行。
76
+ - **手动 `/rewind` 不支持**:在输入框手动输入 `/rewind`(含裸命令)会被 client 拦截并
77
+ 提示改用按钮——`/rewind` 命令仅作为按钮的内部调用通道存在。
77
78
  - **回退后前端与 Agent 一致**:回退标记是空内容消息(deriveMessages 会跳过,模型
78
79
  上下文无任何标记噪音);client 端隐藏被撤回范围内的消息行与 `/rewind` 命令结果,
79
80
  可见对话即"撤回点之前的内容"。会话日志(append-only 审计)不受影响。
80
81
 
81
- ## 已知限制(v0.1
82
+ ## 已知限制(v0.2
82
83
 
83
- - 台账只覆盖插件运行期间、经 `write` / `edit` / `str_replace_editor` 的变更;
84
- bash 或外部程序的修改无法还原(二期可加 git-first 快照层)。台账按会话有界
85
- (每会话保留最近 2000 条,最旧先丢弃)。
86
- - 文件删除走 `processPath` 直删(本地 backend);sandbox/远程 backend 下还原
84
+ - checkpoint 只覆盖**插件运行期间、经 `write` / `edit` / `str_replace_editor` 的变更**;
85
+ bash 或外部程序的修改无法还原(与 Claude Code 相同的限制,二期可加整树快照层)。
86
+ 备份按消息分组**落盘**(每会话保留最近 100 组,最旧先清理),dsh 重启不丢失。
87
+ - 文件删除/还原走真实路径直删直写(本地 backend);sandbox/远程 backend 下路径解析
87
88
  可能受限。
88
- - 回退本身可再回退(标记进入日志),但文件还原动作不再重新入台账。
89
+ - 回退本身可再回退(标记进入日志),但文件还原动作不再记录新备份。
89
90
  - 回退按钮只出现在**当前会话**渲染的用户消息行上(DOM 注入范围即当前视图);
90
91
  subagent/分屏等非当前会话的对话需要先切到该会话再回退。
91
92
 
@@ -93,9 +94,10 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
93
94
 
94
95
  社区 rewind 类插件(`dsh-recall-plugin`、`dsh-checkpoint-rewind`、`dsh-turn-rewind`)均为 **fork 路线**(回退 = fork 出新会话,用户切换会话继续),且没有「仅回退对话 / 对话+代码」的选项。本插件提供:**在当前会话窗口内**改写模型上下文 + 可选还原工作区文件。
95
96
 
96
- ## 交互设计(Claude Code 式两步:先选消息,再选模式)
97
+ ## 交互设计(按钮两步;手动命令不支持)
97
98
 
98
- 所有入口(按钮、命令)都遵循同一流程:**第一步选择要回退到的 user 消息,第二步选择回退模式**。不会在一开始就要求指定模式。
99
+ 按钮流程遵循两步:**第一步选择要回退到的 user 消息,第二步选择回退模式**。手动
100
+ `/rewind` 命令不支持输入——client 端在输入框拦截所有手动 `/rewind` 并提示改用按钮。
99
101
 
100
102
  ### 1. 用户消息旁的「回退」按钮(主入口)
101
103
 
@@ -108,18 +110,11 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
108
110
  - 选「回退对话和代码」时,浮层内先显示将受影响的内容清单(将还原/删除的文件名与数量),确认后执行。
109
111
  - 执行结果以一条对话内消息呈现(如「已回退到 seq N,移除 M 条上下文;还原 2 个文件」)。
110
112
 
111
- ### 2. 命令(辅助入口,面向键盘流与 headless;同样两步)
113
+ ### 2. 命令(仅作按钮内部通道,不支持手动输入)
112
114
 
113
- ```
114
- /rewind 第一步:列出最近的 user 消息(序号 + 时间 + 内容预览),等待选择
115
- /rewind <序号|@seq> 第二步:对选中的消息展示模式选项,等待选择
116
- /rewind <序号|@seq> chat 执行:仅回退对话
117
- /rewind <序号|@seq> both 执行:对话 + 代码
118
- /rewind preview <目标> 只输出影响清单,不执行
119
- ```
120
-
121
- - 分步示例:`/rewind` → 返回「1. 14:02 … / 2. 13:47 …」→ 输 `/rewind 1` → 返回「回退到消息 1:/rewind 1 chat 或 /rewind 1 both」→ 输 `/rewind 1 both` 执行。
122
- - 若输入直接带全参数(`/rewind 1 both`),等价于跳过前两步直接执行——高级用法,不强制。
115
+ - **手动输入 `/rewind`(含裸命令)会被 client 端在输入框直接拦截**,并提示改用消息旁
116
+ 按钮。`/rewind` 命令仅作为按钮的内部调用通道存在
117
+ (`/rewind @seq chat|both`、`/rewind preview @seq both`)。
123
118
  - UI 按钮与命令共享同一套 host 端回退逻辑(`/rewind @seq <mode>`)。
124
119
 
125
120
  ## 回退机制(host 端,全部公开 API)
@@ -130,30 +125,35 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
130
125
  - 效果:当前窗口上下文从目标点重新开始;**不产生新会话、不切换窗口**;原始日志完整保留(append-only 审计不变),仅不再进入模型上下文。
131
126
  - 依赖:`@deepseek-ai/dsh-session`(`Session.append`、`foldSurface`)、`@deepseek-ai/dsh-llm`(`createUserMessage`)、`@deepseek-ai/dsh-commands`(命令注册)、`@deepseek-ai/dsh-agent`(`Agent.status` idle 守卫)。
132
127
 
133
- ### 4. 文件回退:变更台账
128
+ ### 4. 文件回退:Claude Code 式 checkpoint(写前备份,按消息分组落盘)
134
129
 
135
- - 在 `tools/execute`(around-dispatch 阶段)读取目标文件 before,`tools/post-execute` 记录
136
- `{ 消息锚点 seq, 文件路径(解析后), before, after }`。捕获放在 execute 而非
137
- pre-execute:**审批类插件(如 dsh-edit-approval)在 `tools/pre-execute` 返回 `ask`
138
- 会短路后续监听器**,但批准后 dispatch 阶段必然执行——共存的写操作照样入台账;
139
- 被拒绝的调用不 dispatch,不会留下 pending 残留。
130
+ - 在 `tools/execute`(around-dispatch 阶段)读取目标文件**写前备份**(before;文件不存在记
131
+ 为「新建」),`tools/post-execute` 将备份按**当前轮用户消息 seq(锚点)**落盘提交到
132
+ `~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/`。捕获放在 execute 而非 pre-execute
133
+ **审批类插件(如 dsh-edit-approval)在 `tools/pre-execute` 返回 `ask` 会短路后续监听器**,
134
+ 但批准后 dispatch 阶段必然执行——共存的写操作照样入备份;被拒绝的调用不 dispatch,
135
+ 不会留下残留。
140
136
  - 相对路径按**会话 cwd** 解析(与 `dsh-tool-fs` 同规则,`src/session-cwd.ts`),
141
- 台账记录解析后的 display path,preview/还原始终指向真实文件。
142
- - 回退「对话和代码」时,把目标点之后发生的变更**逆序还原**(内容写回 before、新建文件删除)。
143
- - 边界(已知限制):台账只覆盖**插件运行期间、经写类工具**的变更;bash 命令或外部程序的修改不在台账内,无法还原(二期可加 git-first 快照层)。
137
+ 备份记录解析后的 display path,preview/还原始终指向真实文件。
138
+ - 回退「对话和代码」到消息 N 时:对锚点 ≥ N 的每条备份取**该文件最早一条**——内容写回
139
+ before、新建文件删除(与 Claude Code rewind 语义一致)。恢复用 `node:fs` 直写真实
140
+ 文件,不经过 fs 服务;符号链接跳过并在结果中提示。
141
+ - 持久化:备份在磁盘上,**dsh 重启后仍可还原**;每会话保留最近 100 个消息分组,最旧先清理。
142
+ - 边界(已知限制):只覆盖**插件运行期间、经写类工具**的变更;bash 命令或外部程序的修改
143
+ 不在备份内,无法还原(与 Claude Code 相同的限制,二期可加整树快照层)。
144
144
 
145
145
  ### 5. 安全守卫
146
146
 
147
147
  - agent 运行中(LLM 思考/输出)执行回退时**自动强制停止**当前回合(`cancel({kind:'user'})`),
148
148
  等待 quiescence 后回退;停止超时/失败则中止并报错。无需先手动停止。
149
149
  - 文件还原是破坏性操作:UI 选择「对话和代码」时需经影响清单确认;命令路径用 `preview` 先行查看。
150
- - 回退本身可再回退(回退动作同样进入台账/日志)。
150
+ - 回退本身可再回退(回退动作同样进入会话日志),但文件还原动作不再记录新备份。
151
151
 
152
152
  ## 客户端实现要点(纯插件,无源码补丁)
153
153
 
154
154
  - 按钮注入锚点:用户行 `[data-chat-flow-kind="user"]`(行容器 `data-chat-anchor-key` 为节点 key);用 MutationObserver 跟踪新增行。
155
155
  - 消息 seq 获取:从行元素的 `data-chat-anchor-key` → 运行时快照 `session.getSnapshot().chat.nodes.get(key)` → `UserMessageNode.seq`(DOM 只用于定位,数据取自 runtime,不解析 DOM 文本)。
156
- - **两步选择浮层(命令与按钮共用)**:输入 `/rewind` 或点击消息旁按钮时,客户端接管交互——第一步展示最近 user 消息列表(序号 + 时间 + 预览),选中后第二步展示模式选项(仅回退对话 / 回退对话和代码 / 取消);确认后调 `session.command('/rewind @<seq> <mode>')` 执行。host 端分步文本引导作为无客户端/headless 场景的降级。
156
+ - **按钮两步选择浮层**:点击消息旁按钮时,客户端接管交互——目标即该消息,第二步展示模式选项(仅回退对话 / 回退对话和代码 / 取消);确认后调 `session.command('/rewind @<seq> <mode>')` 执行。**手动 `/rewind` 被 client 整体拦截**(含裸命令):输入框 guard 阻止提交并提示改用按钮;`/rewind` 命令仅作为按钮内部调用通道存在。
157
157
  - 执行结果以命令节点出现在对话中。
158
158
  - 注入按钮与「在新对话中分支」等官方操作并排,样式遵循 dsh 设计 token。
159
159
 
@@ -162,22 +162,22 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
162
162
  - 快捷键(esc+esc 回退等)——独立的快捷键插件,二期。
163
163
  - 压缩(`/compact`)——官方已有。
164
164
  - fork/分支回退——官方已有(「在新对话中分支」)。
165
- - 快照式文件回退(git-first)——二期(台账方案先行)。
165
+ - 快照式文件回退(整树/git-first,覆盖 bash 与外部修改)——二期(checkpoint 方案先行)。
166
166
 
167
167
  ## 目录结构(实际)
168
168
 
169
169
  ```
170
- src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute 台账(fs 动态注入)
170
+ src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute checkpoint(fs 动态注入)
171
171
  src/rewind.ts planRewind 纯函数(目标解析、surface 范围计算、候选列表)
172
- src/ledger.ts 变更台账(记录、查询、逆序还原、影响清单;按会话 cwd 解析)
172
+ src/snapshot.ts checkpoint 存储(写前备份按消息分组落盘、还原/删除/影响清单、有界清理)
173
173
  src/session-cwd.ts 会话 cwd 解析(复刻 dsh-tool-fs 规则,可单测)
174
174
  src/client/index.ts client 插件:消息行「回退」按钮 + 模式选择浮层
175
175
  src/client/popover.ts 浮层 DOM(含 both 模式影响清单确认)
176
176
  src/client/locales.ts zh/en 文案(LocaleNamespaceMap 合并)
177
177
  src/client/styles.ts 注入样式(dsh 设计 token)
178
178
  scripts/build.mjs esbuild 构建:lib/index.js(host ESM)+ lib/client.js(loader 闭包)
179
- scripts/verify-host.mjs 端到端验证(真实 cordis + dsh-session,14 项断言)
180
- tests/ rewind/ledger 单测 + 真实 dsh-session 集成测试
179
+ scripts/verify-host.mjs 端到端验证(真实 cordis + dsh-session + 真实临时文件,18 项断言)
180
+ tests/ rewind/snapshot 单测 + 真实 dsh-session 集成测试
181
181
  cordis.patch.yml bundle patch(插入 dsh-rewind-plugin 一行,双面)
182
182
  package.json dsh.bundle + dsh.client 声明、optional peerDependencies
183
183
  ```
package/lib/client.js CHANGED
@@ -45,9 +45,10 @@ var zh = {
45
45
  "popover.cancel": "\u53D6\u6D88",
46
46
  "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
47
47
  "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
48
- "popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53F0\u8D26\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
48
+ "popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u8DDF\u8E2A\u5230\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
49
49
  "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
50
- "popover.back": "\u8FD4\u56DE"
50
+ "popover.back": "\u8FD4\u56DE",
51
+ "guard.hint": "/rewind \u547D\u4EE4\u4E0D\u652F\u6301\u624B\u52A8\u8F93\u5165\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684 \u21B6 \u56DE\u9000\u6309\u94AE"
51
52
  };
52
53
  var en = {
53
54
  "button.aria": "Rewind to this message",
@@ -61,9 +62,10 @@ var en = {
61
62
  "popover.cancel": "Cancel",
62
63
  "popover.impact.loading": "Fetching impact list\u2026",
63
64
  "popover.impact.failed": "Could not fetch the impact list: {message}",
64
- "popover.impact.none": "No ledger-recorded file changes after the target; nothing to restore.",
65
+ "popover.impact.none": "No tracked file changes after the target; nothing to restore.",
65
66
  "popover.confirm": "Confirm rewind",
66
- "popover.back": "Back"
67
+ "popover.back": "Back",
68
+ "guard.hint": "/rewind cannot be typed manually \u2014 use the \u21B6 button on a message to rewind."
67
69
  };
68
70
 
69
71
  // src/client/styles.ts
@@ -79,7 +81,8 @@ var CLASS = {
79
81
  popoverImpact: "dsh-rewind-popover-impact",
80
82
  popoverActions: "dsh-rewind-popover-actions",
81
83
  popoverPrimary: "dsh-rewind-popover-primary",
82
- popoverGhost: "dsh-rewind-popover-ghost"
84
+ popoverGhost: "dsh-rewind-popover-ghost",
85
+ guardHint: "dsh-rewind-guard-hint"
83
86
  };
84
87
  var REWIND_ICON_SVG = [
85
88
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">',
@@ -198,6 +201,20 @@ var STYLE = `
198
201
  .dsh-rewind-popover-ghost:hover {
199
202
  background: var(--dsw-alias-interactive-bg-hover);
200
203
  }
204
+
205
+ .dsh-rewind-guard-hint {
206
+ position: fixed;
207
+ z-index: 1000;
208
+ max-width: min(440px, calc(100vw - 24px));
209
+ padding: 8px 12px;
210
+ border-radius: 10px;
211
+ background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));
212
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
213
+ font-size: 13px;
214
+ line-height: 18px;
215
+ color: var(--dsw-alias-label-primary);
216
+ pointer-events: none;
217
+ }
201
218
  `;
202
219
 
203
220
  // src/client/popover.ts
@@ -406,10 +423,11 @@ function hiddenSeqsOf(session) {
406
423
  const command = node.data;
407
424
  if (command.name !== "rewind") continue;
408
425
  if (command.outcome?.kind !== "success") continue;
426
+ if (command.outcome.sourceEventSeq === void 0) continue;
409
427
  hidden.add(command.seq);
410
- const marker = command.outcome?.sourceEventSeq;
411
- const target = targetOfOutcome(command.outcome?.text);
412
- if (marker !== void 0 && target !== void 0 && (latest === null || marker > latest.marker)) {
428
+ const marker = command.outcome.sourceEventSeq;
429
+ const target = targetOfOutcome(command.outcome.text);
430
+ if (target !== void 0 && (latest === null || marker > latest.marker)) {
413
431
  latest = { marker, target };
414
432
  }
415
433
  }
@@ -501,45 +519,27 @@ function apply(ctx) {
501
519
  buttons.set(key, button);
502
520
  seat.setAttribute(REWIND_ATTACHED, "");
503
521
  };
504
- let lastRewindSeq = -1;
505
- let hiddenSeqsCache = null;
506
522
  const scan = () => {
507
523
  for (const [key, button] of buttons) {
508
524
  if (!button.isConnected) buttons.delete(key);
509
525
  }
510
526
  const session = sessionFor();
511
- let newestRewind = -1;
512
- if (session !== void 0) {
513
- const snap = session.getSnapshot();
514
- for (const key of snap.chat.order) {
515
- const node = snap.chat.nodes.get(key);
516
- if (node === void 0 || node.kind !== "command") continue;
517
- const command = node.data;
518
- if (command.name === "rewind" && command.outcome?.kind === "success" && command.seq > newestRewind) {
519
- newestRewind = command.seq;
520
- }
521
- }
522
- }
523
- if (newestRewind > lastRewindSeq) {
524
- lastRewindSeq = newestRewind;
525
- hiddenSeqsCache = session !== void 0 ? hiddenSeqsOf(session) : null;
526
- if (session !== void 0) fillComposerForRewind(session, filledTargets);
527
- }
528
- const hiddenSeqs = hiddenSeqsCache;
527
+ const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
529
528
  let hiddenCount = 0;
530
- if (hiddenSeqs !== null && hiddenSeqs.size > 0 && session !== void 0) {
531
- for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
532
- const key = seat.dataset.chatAnchorKey;
533
- const anchor = key !== void 0 ? session.getSnapshot().chat.nodes.get(key)?.anchorSeq : void 0;
534
- if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
535
- seat.style.display = "none";
536
- hidden.add(seat);
537
- hiddenCount += 1;
538
- } else if (hidden.has(seat)) {
539
- seat.style.display = "";
540
- hidden.delete(seat);
541
- }
529
+ for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
530
+ const key = seat.dataset.chatAnchorKey;
531
+ const anchor = key !== void 0 && session !== void 0 ? session.getSnapshot().chat.nodes.get(key)?.anchorSeq : void 0;
532
+ if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
533
+ seat.style.display = "none";
534
+ hidden.add(seat);
535
+ hiddenCount += 1;
536
+ } else if (hidden.has(seat)) {
537
+ seat.style.display = "";
538
+ hidden.delete(seat);
542
539
  }
540
+ }
541
+ if (session !== void 0) fillComposerForRewind(session, filledTargets);
542
+ if (hiddenSeqs.size > 0 || hiddenCount > 0) {
543
543
  console.info(
544
544
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
545
545
  );
@@ -556,6 +556,7 @@ function apply(ctx) {
556
556
  if (node === void 0 || node.kind !== "command") continue;
557
557
  const command = node.data;
558
558
  if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
559
+ if (command.outcome.sourceEventSeq === void 0) continue;
559
560
  const target = targetOfOutcome(command.outcome.text);
560
561
  if (target === void 0 || filled.has(target)) continue;
561
562
  const text = userTextAt(session, target);
@@ -563,10 +564,67 @@ function apply(ctx) {
563
564
  if (fillComposer(text)) filled.add(target);
564
565
  }
565
566
  };
567
+ const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
568
+ const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
569
+ const hasBlockedRewindDraft = () => {
570
+ const textarea = composerTextarea();
571
+ return textarea !== null && MANUAL_REWIND.test(textarea.value);
572
+ };
573
+ let guardHintEl = null;
574
+ let guardHintTimer;
575
+ const showGuardHint = () => {
576
+ if (guardHintEl !== null) guardHintEl.remove();
577
+ if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
578
+ const textarea = composerTextarea();
579
+ if (textarea === null) return;
580
+ const card = textarea.closest("[data-composer-card]");
581
+ const hint = document.createElement("div");
582
+ hint.className = CLASS.guardHint;
583
+ hint.setAttribute("role", "status");
584
+ hint.textContent = t("guard.hint");
585
+ document.body.appendChild(hint);
586
+ const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.getBoundingClientRect();
587
+ hint.style.left = `${Math.round(rect.left)}px`;
588
+ hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
589
+ guardHintEl = hint;
590
+ guardHintTimer = window.setTimeout(() => {
591
+ hint.remove();
592
+ if (guardHintEl === hint) guardHintEl = null;
593
+ guardHintTimer = void 0;
594
+ }, 3200);
595
+ };
596
+ const onKeyDownGuard = (event) => {
597
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
598
+ if (!hasBlockedRewindDraft()) return;
599
+ event.preventDefault();
600
+ event.stopPropagation();
601
+ showGuardHint();
602
+ };
603
+ const onClickGuard = (event) => {
604
+ if (event.button !== 0 || !hasBlockedRewindDraft()) return;
605
+ const target = event.target;
606
+ if (!(target instanceof Element)) return;
607
+ const button = target.closest("button");
608
+ if (button === null) return;
609
+ const card = button.closest("[data-composer-card]");
610
+ if (card === null) return;
611
+ const all = card.querySelectorAll("button");
612
+ if (all[all.length - 1] !== button) return;
613
+ if (button.querySelector("rect") !== null) return;
614
+ event.preventDefault();
615
+ event.stopPropagation();
616
+ showGuardHint();
617
+ };
618
+ document.addEventListener("keydown", onKeyDownGuard, true);
619
+ document.addEventListener("click", onClickGuard, true);
566
620
  observer = new MutationObserver(scan);
567
- observer.observe(document.body, { childList: true, subtree: true });
621
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
568
622
  scan();
569
623
  yield () => {
624
+ document.removeEventListener("keydown", onKeyDownGuard, true);
625
+ document.removeEventListener("click", onClickGuard, true);
626
+ if (guardHintEl !== null) guardHintEl.remove();
627
+ if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
570
628
  observer?.disconnect();
571
629
  for (const button of buttons.values()) button.remove();
572
630
  buttons.clear();