dsh-rewind-plugin 0.1.8 → 0.1.10

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,19 @@
2
2
 
3
3
  DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
4
4
 
5
- > 状态:v0.1.8 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
5
+ > 状态:v0.1.10 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
6
6
 
7
- ## 实现状态(v0.1.8
7
+ ## 实现状态(v0.1.10
8
8
 
9
- - ✅ host 端 `/rewind` 命令(两步文本引导 + 直接执行 + `preview` 影响清单)
9
+ - ✅ host 端 `/rewind` 命令(手动单动作:不接受参数、撤回最近一条;参数化形式保留为按钮内部调用)
10
10
  - ✅ host 端变更台账(`tools/execute` 捕获 before、`tools/post-execute` 提交),按会话隔离
11
11
  - ✅ **与其他审批类插件共存**:捕获在 around-dispatch 阶段,`tools/pre-execute` 被 `ask` 短路(如 dsh-edit-approval)后批准仍能记录;被拒绝的调用不留 pending 残留
12
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 模式影响清单确认)
15
+ - ✅ client 端「回退」按钮(MutationObserver 注入用户消息行操作区)+ 模式选择浮层(含 both 模式影响清单确认)+ 手动 `/rewind` 参数拦截
16
16
  - ✅ 测试:纯函数单测 + 真实 `dsh-session` 集成测试 + `verify-host` 端到端(14 项,含审批短路/会话 cwd 场景)
17
- - ⏳ 二期:快捷键、git-first 快照式文件回退、命令路径的 client 两步浮层接管
17
+ - ⏳ 二期:快捷键、git-first 快照式文件回退
18
18
 
19
19
  ## 安装
20
20
 
@@ -68,12 +68,12 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
68
68
 
69
69
  - 每条用户消息 hover 出现「↶ 回退」按钮:点击 → 选择「仅回退对话」或
70
70
  「回退对话和代码」(后者先展示影响清单再确认)。
71
- - **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(按钮或 `/rewind <目标>`),
71
+ - **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(按钮,或 `/rewind` 撤回最近一条),
72
72
  效果是**撤回该消息及它之后的所有内容**(含 agent 回复、工具调用)——对话界面与
73
73
  Agent 上下文都回到这条消息发送之前;**该消息的文本自动填入输入框(编辑区)**,
74
74
  可直接修改后重发。命令结果提示"已撤回 seq N 及之后内容"。
75
- - 键盘流:`/rewind` → 选消息 → `/rewind <序号> chat|both`;`/rewind preview <目标>`
76
- 只输出影响清单不执行。
75
+ - 手动 `/rewind` **不接受参数**:直接撤回最近一条用户消息(内容填回输入框可修改重发);
76
+ 回退到更早的消息请用该消息旁的 ↶ 按钮(参数化命令保留为按钮内部调用)。
77
77
  - **回退后前端与 Agent 一致**:回退标记是空内容消息(deriveMessages 会跳过,模型
78
78
  上下文无任何标记噪音);client 端隐藏被撤回范围内的消息行与 `/rewind` 命令结果,
79
79
  可见对话即"撤回点之前的内容"。会话日志(append-only 审计)不受影响。
@@ -93,9 +93,10 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
93
93
 
94
94
  社区 rewind 类插件(`dsh-recall-plugin`、`dsh-checkpoint-rewind`、`dsh-turn-rewind`)均为 **fork 路线**(回退 = fork 出新会话,用户切换会话继续),且没有「仅回退对话 / 对话+代码」的选项。本插件提供:**在当前会话窗口内**改写模型上下文 + 可选还原工作区文件。
95
95
 
96
- ## 交互设计(Claude Code 式两步:先选消息,再选模式)
96
+ ## 交互设计(按钮两步;手动命令为单动作)
97
97
 
98
- 所有入口(按钮、命令)都遵循同一流程:**第一步选择要回退到的 user 消息,第二步选择回退模式**。不会在一开始就要求指定模式。
98
+ 按钮流程遵循两步:**第一步选择要回退到的 user 消息,第二步选择回退模式**。手动
99
+ `/rewind` 命令不参与两步流程——它不接受参数,只撤回最近一条消息。
99
100
 
100
101
  ### 1. 用户消息旁的「回退」按钮(主入口)
101
102
 
@@ -108,18 +109,16 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
108
109
  - 选「回退对话和代码」时,浮层内先显示将受影响的内容清单(将还原/删除的文件名与数量),确认后执行。
109
110
  - 执行结果以一条对话内消息呈现(如「已回退到 seq N,移除 M 条上下文;还原 2 个文件」)。
110
111
 
111
- ### 2. 命令(辅助入口,面向键盘流与 headless;同样两步)
112
+ ### 2. 命令(辅助入口,仅支持手动撤回最近一条)
112
113
 
113
114
  ```
114
- /rewind 第一步:列出最近的 user 消息(序号 + 时间 + 内容预览),等待选择
115
- /rewind <序号|@seq> 第二步:对选中的消息展示模式选项,等待选择
116
- /rewind <序号|@seq> chat 执行:仅回退对话
117
- /rewind <序号|@seq> both 执行:对话 + 代码
118
- /rewind preview <目标> 只输出影响清单,不执行
115
+ /rewind 撤回最近一条用户消息(不接受参数;内容填回输入框可修改重发)
119
116
  ```
120
117
 
121
- - 分步示例:`/rewind` → 返回「1. 14:02 … / 2. 13:47 …」→ 输 `/rewind 1` → 返回「回退到消息 1:/rewind 1 chat 或 /rewind 1 both」→ 输 `/rewind 1 both` 执行。
122
- - 若输入直接带全参数(`/rewind 1 both`),等价于跳过前两步直接执行——高级用法,不强制。
118
+ - 手动 `/rewind` 是**单动作**:不接受任何参数,直接撤回最近一条用户消息(对话回到
119
+ 上一条消息之前)。参数化形式(`@seq chat|both`、`preview`)仍存在于 host 端,
120
+ 但**仅供 ↶ 按钮内部调用**——client 端会在输入框拦截带参数的手动 `/rewind` 并提示
121
+ 改用按钮。回退到更早的消息请使用该消息旁的「回退」按钮。
123
122
  - UI 按钮与命令共享同一套 host 端回退逻辑(`/rewind @seq <mode>`)。
124
123
 
125
124
  ## 回退机制(host 端,全部公开 API)
@@ -153,7 +152,7 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
153
152
 
154
153
  - 按钮注入锚点:用户行 `[data-chat-flow-kind="user"]`(行容器 `data-chat-anchor-key` 为节点 key);用 MutationObserver 跟踪新增行。
155
154
  - 消息 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 场景的降级。
155
+ - **按钮两步选择浮层**:点击消息旁按钮时,客户端接管交互——目标即该消息,第二步展示模式选项(仅回退对话 / 回退对话和代码 / 取消);确认后调 `session.command('/rewind @<seq> <mode>')` 执行。**手动 `/rewind` 不经过浮层**:它不接受参数,直接撤回最近一条消息;client 端在输入框拦截带参数的手动 `/rewind` 并提示改用按钮。
157
156
  - 执行结果以命令节点出现在对话中。
158
157
  - 注入按钮与「在新对话中分支」等官方操作并排,样式遵循 dsh 设计 token。
159
158
 
package/lib/client.js CHANGED
@@ -47,7 +47,8 @@ var zh = {
47
47
  "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
48
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",
49
49
  "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
50
- "popover.back": "\u8FD4\u56DE"
50
+ "popover.back": "\u8FD4\u56DE",
51
+ "guard.hint": "/rewind \u4E0D\u63A5\u53D7\u53C2\u6570\uFF0C\u53EA\u80FD\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u6D88\u606F\uFF1B\u56DE\u9000\u5230\u66F4\u65E9\u6D88\u606F\u8BF7\u7528\u8BE5\u6D88\u606F\u65C1\u7684 \u21B6 \u6309\u94AE"
51
52
  };
52
53
  var en = {
53
54
  "button.aria": "Rewind to this message",
@@ -63,7 +64,8 @@ var en = {
63
64
  "popover.impact.failed": "Could not fetch the impact list: {message}",
64
65
  "popover.impact.none": "No ledger-recorded 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 takes no parameters \u2014 it only withdraws the most recent message. To rewind to an earlier message, use the \u21B6 button on that message."
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
  }
@@ -538,6 +556,7 @@ function apply(ctx) {
538
556
  if (node === void 0 || node.kind !== "command") continue;
539
557
  const command = node.data;
540
558
  if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
559
+ if (command.outcome.sourceEventSeq === void 0) continue;
541
560
  const target = targetOfOutcome(command.outcome.text);
542
561
  if (target === void 0 || filled.has(target)) continue;
543
562
  const text = userTextAt(session, target);
@@ -545,10 +564,67 @@ function apply(ctx) {
545
564
  if (fillComposer(text)) filled.add(target);
546
565
  }
547
566
  };
567
+ const REWIND_WITH_ARGS = /^\s*\/rewind\s+\S/i;
568
+ const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
569
+ const hasBlockedRewindDraft = () => {
570
+ const textarea = composerTextarea();
571
+ return textarea !== null && REWIND_WITH_ARGS.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);
548
620
  observer = new MutationObserver(scan);
549
621
  observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
550
622
  scan();
551
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);
552
628
  observer?.disconnect();
553
629
  for (const button of buttons.values()) button.remove();
554
630
  buttons.clear();
package/lib/index.js CHANGED
@@ -178,12 +178,6 @@ function planRewind(events, surface, target) {
178
178
  surfaceEnd: shadowedSeqs[shadowedSeqs.length - 1]
179
179
  };
180
180
  }
181
- function formatCandidate(candidate) {
182
- const time = new Date(candidate.time);
183
- const hh = String(time.getHours()).padStart(2, "0");
184
- const mm = String(time.getMinutes()).padStart(2, "0");
185
- return `${candidate.index}. ${hh}:${mm} ${candidate.preview || "(\u65E0\u6587\u672C)"}`;
186
- }
187
181
 
188
182
  // src/index.ts
189
183
  var name = "dsh-rewind";
@@ -192,11 +186,8 @@ var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_edito
192
186
  var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
193
187
  var USAGE = [
194
188
  "Usage:",
195
- " /rewind list recent user messages to rewind to",
196
- " /rewind <\u5E8F\u53F7|@seq> choose a mode for that message",
197
- " /rewind <\u5E8F\u53F7|@seq> chat rewind the conversation only",
198
- " /rewind <\u5E8F\u53F7|@seq> both rewind conversation and restore files",
199
- " /rewind preview <\u76EE\u6807> show the impact list without executing"
189
+ " /rewind \u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F\uFF08\u4E0D\u63A5\u53D7\u53C2\u6570\uFF09",
190
+ " \u56DE\u9000\u5230\u66F4\u65E9\u7684\u6D88\u606F\u8BF7\u4F7F\u7528\u8BE5\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
200
191
  ].join("\n");
201
192
  function mutationPathOf(exec) {
202
193
  const args = exec.arguments;
@@ -387,17 +378,11 @@ async function handleRewind(ctx, ledger, invocation) {
387
378
  const session = invocation.agent.session;
388
379
  const input = invocation.rawInput.trim();
389
380
  if (input === "") {
390
- const candidates = listRewindCandidates(session.events, session.surface.nodes);
381
+ const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
391
382
  if (candidates.length === 0) {
392
383
  return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
393
384
  }
394
- return {
395
- kind: "success",
396
- text: `\u9009\u62E9\u8981\u56DE\u9000\u5230\u7684\u7528\u6237\u6D88\u606F\uFF08\u6700\u8FD1\u5728\u4E0A\uFF09\uFF1A
397
- ${candidates.map(formatCandidate).join("\n")}
398
-
399
- \u7EE7\u7EED\uFF1A/rewind <\u5E8F\u53F7|@seq>\uFF0C\u6216\u76F4\u63A5 /rewind <\u5E8F\u53F7|@seq> chat|both \u6267\u884C\u3002`
400
- };
385
+ return executeRewind(ctx, ledger, invocation, `@${candidates[0].seq}`, "chat");
401
386
  }
402
387
  const parts = input.split(/\s+/);
403
388
  if (parts[0] === "preview") {
@@ -16,6 +16,11 @@
16
16
  * before executing. Execution always goes through `session.command(...)`, the
17
17
  * same host path the `/rewind` command uses.
18
18
  *
19
+ * Manual composer input of `/rewind` is deliberately restricted (the guard
20
+ * below): it takes no parameters — a bare `/rewind` withdraws the most recent
21
+ * message, and any `/rewind <args>` line is blocked with a hint pointing at
22
+ * the per-message button.
23
+ *
19
24
  * @module dsh-rewind/client
20
25
  */
21
26
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
@@ -15,6 +15,7 @@ export declare const zh: {
15
15
  'popover.impact.none': string;
16
16
  'popover.confirm': string;
17
17
  'popover.back': string;
18
+ 'guard.hint': string;
18
19
  };
19
20
  /** The rewind namespace key union. */
20
21
  export type RewindKey = keyof typeof zh;
@@ -40,4 +41,5 @@ export declare const en: {
40
41
  'popover.impact.none': string;
41
42
  'popover.confirm': string;
42
43
  'popover.back': string;
44
+ 'guard.hint': string;
43
45
  };
@@ -20,8 +20,9 @@ export declare const CLASS: {
20
20
  readonly popoverActions: 'dsh-rewind-popover-actions';
21
21
  readonly popoverPrimary: 'dsh-rewind-popover-primary';
22
22
  readonly popoverGhost: 'dsh-rewind-popover-ghost';
23
+ readonly guardHint: 'dsh-rewind-guard-hint';
23
24
  };
24
25
  /** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
25
26
  export declare const REWIND_ICON_SVG: string;
26
27
  /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
27
- export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n";
28
+ export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border-radius: 10px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * What it proves:
10
10
  * 1. the plugin registers a `rewind` command on the ctx;
11
- * 2. `/rewind` (no args) lists recent user messages;
12
- * 3. `/rewind @<seq> chat` cuts the surface in-place (log untouched);
11
+ * 2. `/rewind` (no args) withdraws the most recent user message;
12
+ * 3. `/rewind @<seq> chat` (the button's call form) cuts the surface in-place (log untouched);
13
13
  * 4. the ledger captures through `tools/execute` (NOT pre-execute): a
14
14
  * pre-execute `ask` short-circuit still gets captured after approval, and
15
15
  * a denied call never captures (no pending leak);
@@ -86,17 +86,25 @@ const check = (name, ok, detail) => {
86
86
  // 1. command registered
87
87
  check('command registered', typeof registered?.handler === 'function' && registered.name === 'rewind', JSON.stringify(registered))
88
88
 
89
- // 2. bare /rewind lists candidates
90
- const listResult = await call(agent, '')
91
- check('bare /rewind lists candidates', listResult.kind === 'success' && listResult.text.includes('second question') && listResult.text.includes('first question'), listResult.text)
89
+ // 2. bare /rewind (manual, no parameters) withdraws the most recent user
90
+ // message (seq 2 "second question") and everything after it
91
+ const bareBefore = [...session.surface.nodes]
92
+ const bareResult = await call(agent, '')
93
+ const bareAfter = [...session.surface.nodes]
94
+ check('bare /rewind succeeds', bareResult.kind === 'success', bareResult.text)
95
+ check('bare /rewind withdraws the latest message', bareAfter.length === 3 && bareAfter[0] === 0 && bareAfter[1] === 1 && bareAfter[2] > 3, `before ${JSON.stringify(bareBefore)} -> after ${JSON.stringify(bareAfter)}`)
96
+ check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
92
97
 
93
- // 3. /rewind @2 chat withdraws the target (seq 2) and everything after it
94
- const before = [...session.surface.nodes]
95
- const chatResult = await call(agent, '@2 chat')
96
- const after = [...session.surface.nodes]
98
+ // 3. /rewind @<seq> chat (the button's exact call form) cuts the surface on a
99
+ // fresh session
100
+ const paramSession = buildSession('verify-param')
101
+ const paramAgent = { id: paramSession.id, session: paramSession, status: 'idle' }
102
+ const before = [...paramSession.surface.nodes]
103
+ const chatResult = await call(paramAgent, '@2 chat')
104
+ const after = [...paramSession.surface.nodes]
97
105
  check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
98
106
  check('surface cut to [0,1,marker] (target withdrawn)', after.length === 3 && after[0] === 0 && after[1] === 1 && after[2] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
99
- check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
107
+ check('log stays append-only (5 events)', paramSession.events.length === 5, `events=${paramSession.events.length}`)
100
108
 
101
109
  const writeExec = (callId, filePath, content) => ({
102
110
  callId, name: 'write', arguments: { file_path: filePath, content }, agent, signal: aborted(),