dsh-rewind-plugin 0.5.0 → 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.
@@ -1,71 +1,90 @@
1
- # Snapshot auto-cleanup
1
+ # Snapshot cleanup
2
2
 
3
- The rewind store writes one on-disk `before` backup per tracked file change,
4
- grouped by its anchor message. Snapshots are deduped within a session and capped
5
- at the newest 100 anchor groups, but across sessions that are no longer active
6
- the store can
7
- still grow without bound. `snapshot-auto-cleanup` is an OPTIONAL global policy
8
- (off by default) that removes the whole snapshot directory of a session that has
9
- been **long-inactive** — untouched past a configurable idle cutoff.
3
+ The plugin saves a backup of each file before it's edited, so you can rewind your
4
+ code to an earlier point. These backups are called **snapshots**, and they're
5
+ grouped by message and stored per session.
10
6
 
11
- It only ever removes the whole snapshot **directory** of a long-inactive session.
12
- It never touches the active session's snapshots, never touches the conversation
13
- log, and leaves snapshot data within the idle cutoff alone.
7
+ `/snapshot-auto-cleanup` helps you manage those snapshots whether you want to
8
+ reclaim disk space from old sessions or clear the **current** session so its
9
+ rewind history starts fresh.
10
+
11
+ ## What it does
12
+
13
+ **Automatic cleanup** (off by default): the plugin can remember which sessions
14
+ you've stopped using, and now and then remove their snapshots to keep disk usage
15
+ down. It never touches your active session, and it never touches your
16
+ conversation.
17
+
18
+ **Manual:** even with automatic cleanup off, you can run the cleanup yourself:
19
+
20
+ - `/snapshot-auto-cleanup run [--apply]` — preview, then actually remove the
21
+ snapshots of sessions you haven't used for a while.
22
+ - `/snapshot-auto-cleanup run --current [--apply]` — preview, then actually
23
+ clear the **current** session's snapshots. This resets its rewind history to
24
+ "from now on" (your conversation is unaffected). If a turn is currently
25
+ running, the plugin pauses it first, then clears.
14
26
 
15
27
  ## Commands
16
28
 
17
29
  ```
18
- /snapshot-auto-cleanup show status (enabled, max-age, config path)
19
- /snapshot-auto-cleanup on|off enable/disable the automatic sweep
20
- /snapshot-auto-cleanup max-age <days> set the idle cutoff (positive integer)
21
- /snapshot-auto-cleanup run dry-run: list what would be removed
22
- /snapshot-auto-cleanup run --apply actually remove those sessions
30
+ /snapshot-auto-cleanup show the current settings
31
+ /snapshot-auto-cleanup on|off turn automatic cleanup on or off
32
+ /snapshot-auto-cleanup max-age <days> how many idle days before a session's snapshots are removed
33
+ /snapshot-auto-cleanup run preview what the automatic cleanup would remove
34
+ /snapshot-auto-cleanup run --apply actually remove those snapshots
35
+ /snapshot-auto-cleanup run --current preview clearing this session's snapshots
36
+ /snapshot-auto-cleanup run --current --apply actually clear this session's snapshots
23
37
  ```
24
38
 
25
- `run` is a manual escape hatch and works whether or not the automatic cleanup is
26
- `on`. `run` defaults to a dry-run; add `--apply` to execute.
39
+ `run` always starts as a preview; add `--apply` to make the change. `run` works
40
+ whether or not automatic cleanup is on.
27
41
 
28
- ## Config file
42
+ ## Settings
29
43
 
30
- The policy is persisted to `~/.dsh/snapshot-cleanup.json`:
44
+ The settings live in `<dsh home>/snapshot-cleanup.json`:
31
45
 
32
46
  ```json
33
47
  { "enabled": false, "maxAgeDays": 30 }
34
48
  ```
35
49
 
36
- - `enabled` — whether the automatic sweeps run (default `false`).
37
- - `maxAgeDays` — how many idle days before a long-inactive session's snapshot dir is
38
- removed (default `30`; `0`/negative are rejected, so a broken config can never
39
- delete everything).
40
-
41
- Override the path with the `DSH_SNAPSHOT_CLEANUP_CONFIG` environment variable.
42
- The file is written only by the `/snapshot-auto-cleanup` command. A missing file
43
- reads as the safe default (off); a missing or corrupt file makes a sweep
44
- **fail-closed** (delete nothing) and log a warning, and is surfaced when you run
45
- the command again.
46
-
47
- ## When it runs
48
-
49
- The 24h window is anchored on a **persisted** last-sweep time
50
- (`~/.dsh/snapshot-cleanup-last-sweep.json`), so a host restart does not reset
51
- it: the auto-sweep checks **once per process run**, on the first session
52
- activity of a window (a user message or a completed tool call), and cleans only
53
- when enabled **and** >=24h since the last sweep. It runs in the background and
54
- never blocks the activity that triggered it. Because the check happens once per
55
- run, a change that takes effect immediately is best applied with
56
- `/snapshot-auto-cleanup run`; editing the config file by hand (or enabling after
57
- the run's first activity) takes effect on the next run.
58
-
59
- ## Safety and boundaries
60
-
61
- - Removes only whole **long-inactive** session dirs; the active session and the
62
- conversation log are never touched.
63
- - "Inactive" is judged by mtime: a session still being written to keeps scrolling
64
- its newest member stamp forward, so it is never old enough to be pruned.
65
- - Dedup `ref` links are session-relative, so removing a whole dir cannot dangle a
66
- link elsewhere.
67
- - Trade-off: enabling auto-cleanup means a session resumed after a long idle gap
68
- will rewind only from its remaining (newest 100) anchors; its old snapshots are
69
- gone. The conversation log is never affected.
70
- - Deleting the whole store dir manually stays safe (it is recreated on the next
71
- capture); auto-cleanup just scopes that removal to long-inactive sessions.
50
+ - `enabled` — whether automatic cleanup runs (default `false`).
51
+ - `maxAgeDays` — how many idle days before a session's snapshots are removed
52
+ (default `30`). Only positive numbers are accepted, so a broken setting can
53
+ never delete everything.
54
+
55
+ You can point the plugin at a different file with the `DSH_SNAPSHOT_CLEANUP_CONFIG`
56
+ environment variable. The file is changed only when you turn cleanup on/off or
57
+ set `max-age`; a missing file reads as the safe default (off).
58
+
59
+ ## When automatic cleanup runs
60
+
61
+ Automatic cleanup checks at most **once per run** (a restart lets it check again),
62
+ on the first session activity (a message you send or a tool call that finishes),
63
+ and only when it's enabled and at least 24 hours have passed since the last
64
+ check. The 24-hour clock is saved to `<dsh home>/snapshot-cleanup-last-sweep.json`,
65
+ so restarting doesn't reset it. It runs in the background and never blocks
66
+ what you're doing.
67
+
68
+ If you want a change to take effect right away, use `run`; automatic cleanup
69
+ picks up a fresh setting on the next run.
70
+
71
+ ## Safety
72
+
73
+ - Only rewind **snapshots** (the file backups) are ever removed. Your
74
+ conversation is never touched, and the plugin never rewrites or deletes your
75
+ session history.
76
+ - Automatic cleanup never removes your **active** session's snapshots — only
77
+ sessions that have been idle past the cutoff.
78
+ - `run --current` clears the current session's snapshots. This is one-way for
79
+ that session's file-rewind history: you can't rewind code to before the clear,
80
+ but your conversation stays intact, and the session starts recording fresh
81
+ snapshots from now on.
82
+
83
+ ## Known limitation
84
+
85
+ The plugin keeps the most recent **100 messages'** snapshots per session. If you
86
+ rewind or compact a lot in one long session, those 100 slots can be taken up by
87
+ messages that are no longer reachable, so you may find you can't rewind as far
88
+ back as you'd like. (Claude Code behaves the same way.) To get back to a clean
89
+ state, run `/snapshot-auto-cleanup run --current --apply` to clear the current
90
+ session and start fresh.
@@ -1,42 +1,57 @@
1
- # 快照自动清理
1
+ # 快照清理
2
2
 
3
- 回退插件为每次被跟踪的文件修改写入一份磁盘 `before` 备份,并按其锚点消息分组。快照在**同一会话**内去重、且保留最近 100 组锚点,但跨**已不再活跃的会话**时存储仍会无界增长。`snapshot-auto-cleanup` 是**可选**的全局策略(默认关闭):把**长期不活跃**(失活超过可配置阈值)的会话快照**整目录**移除。
3
+ 插件会在每次修改文件前保存一份备份,方便你把代码回退到之前的状态。这些备份叫**快照**,按消息分组、按会话存放。
4
4
 
5
- 它只会移除**长期不活跃**会话的整个快照目录,绝不触碰活动会话的快照、绝不触碰对话日志,也保留仍在阈值内的快照数据。
5
+ `/snapshot-auto-cleanup` 帮你管理这些快照——既可以回收旧会话占用的磁盘,也可以清空**当前**会话、让它从"现在"重新开始记录回退历史。
6
+
7
+ ## 它能做什么
8
+
9
+ **自动清理**(默认关闭):插件会记住哪些会话你已不再使用,并定期移除它们的快照以节省磁盘。它**绝不**触碰你当前活跃的会话,也**绝不**触碰你的对话。
10
+
11
+ **手动:**即使自动清理是关闭的,你也可以自己执行清理:
12
+
13
+ - `/snapshot-auto-cleanup run [--apply]` — 先预演,再真正移除你已有一阵子没用的会话的快照。
14
+ - `/snapshot-auto-cleanup run --current [--apply]` — 先预演,再真正清空**当前**会话的快照。这会让它从"现在"重新开始记录回退历史(你的对话不受影响)。若此刻有正在进行中的回合,插件会先暂停它,再清空。
6
15
 
7
16
  ## 命令
8
17
 
9
18
  ```
10
- /snapshot-auto-cleanup 查看状态(是否开启、max-age、配置路径)
11
- /snapshot-auto-cleanup on|off 开启/关闭自动清理
12
- /snapshot-auto-cleanup max-age <天数> 设置失活阈值(正整数)
13
- /snapshot-auto-cleanup run 预演:列出将移除哪些会话
14
- /snapshot-auto-cleanup run --apply 真正移除这些会话
19
+ /snapshot-auto-cleanup 查看当前设置
20
+ /snapshot-auto-cleanup on|off 开启/关闭自动清理
21
+ /snapshot-auto-cleanup max-age <天数> 会话失活多少天后移除其快照
22
+ /snapshot-auto-cleanup run 预演:看自动清理会移除什么
23
+ /snapshot-auto-cleanup run --apply 真正移除这些快照
24
+ /snapshot-auto-cleanup run --current 预演:看清空本会话快照会怎样
25
+ /snapshot-auto-cleanup run --current --apply 真正清空本会话快照
15
26
  ```
16
27
 
17
- `run` 是手动逃生舱,无论自动清理是否为 `on` 都能用;默认只预演,加 `--apply` 才执行。
28
+ `run` 一开始总是预演;加 `--apply` 才真正执行。`run` 无论自动清理是否开启都能用。
18
29
 
19
- ## 配置文件
30
+ ## 设置
20
31
 
21
- 策略持久化到 `~/.dsh/snapshot-cleanup.json`:
32
+ 设置保存在 `<dsh home>/snapshot-cleanup.json`:
22
33
 
23
34
  ```json
24
35
  { "enabled": false, "maxAgeDays": 30 }
25
36
  ```
26
37
 
27
38
  - `enabled` — 是否运行自动清理(默认 `false`)。
28
- - `maxAgeDays` — 失活多少天后移除某**长期不活跃**会话的快照目录(默认 `30`;`0`/负数会被拒绝,因此损坏的配置不可能导致"删除一切")。
39
+ - `maxAgeDays` — 会话失活多少天后移除其快照(默认 `30`)。只接受正数,因此一个损坏的设置永远不可能"删掉一切"
40
+
41
+ 你可以用环境变量 `DSH_SNAPSHOT_CLEANUP_CONFIG` 指向别的文件。该文件只在运行 `/snapshot-auto-cleanup` 时写入;文件缺失按安全默认值(关闭)处理。
42
+
43
+ ## 自动清理何时运行
44
+
45
+ 自动清理每次运行(宿主启动)**最多检查一次**,发生在首次活动时(你发出一条消息或某次工具调用完成),且仅在**已开启**且距上次检查**已满 24 小时**时才执行。这个 24 小时时钟会写入 `<dsh home>/snapshot-cleanup-last-sweep.json`,重启不会重置。它后台运行,从不阻塞你在做的事。
29
46
 
30
- 可用环境变量 `DSH_SNAPSHOT_CLEANUP_CONFIG` 覆盖路径。该文件只由 `/snapshot-auto-cleanup` 命令写入。文件缺失按安全默认(关闭)处理;缺失或损坏会令一次清扫**fail-closed**(不删任何东西)并写入警告,再次运行命令时会有所提示。
47
+ 想让改动立即生效,请用 `run`;自动清理要到下次运行才会采用新设置。
31
48
 
32
- ## 运行时机
49
+ ## 安全
33
50
 
34
- 24 小时窗口锚定在**持久化**的上次清扫时间(`~/.dsh/snapshot-cleanup-last-sweep.json`)上,因此宿主重启不会重置:自动清扫在**每次运行只检查一次**,于某个窗口的首次会话活动(一条用户消息或一次工具调用完成)时执行,且仅在**已启用**且距上次清扫 **>=24 小时**时才清理。它后台运行,从不阻塞触发它的活动。由于每次运行只检查一次,想让改动立即生效最好用 `/snapshot-auto-cleanup run`;手动编辑配置文件(或在本次运行的首次活动之后才开启)会到下次运行才生效。
51
+ - 只会移除回退用的**快照**(那份文件备份)。你的对话绝不会被动到,插件也绝不改写或删除你的会话历史。
52
+ - 自动清理**永不**移除你**当前活跃**会话的快照——只清理已失活超过阈值的会话。
53
+ - `run --current` 会清空当前会话的快照。这对该会话的文件回退历史是**不可逆**的:你无法回退到清空之前的代码状态,但你的对话保持完整,会话会从"现在"开始重新记录快照。
35
54
 
36
- ## 安全与边界
55
+ ## 已知限制
37
56
 
38
- - 只移除**长期不活跃**会话的整目录;活动会话与对话日志永不触碰。
39
- - "不活跃"以 mtime 判定:仍在写入的会话会不断把"最新成员"时间戳往后推,因此永远到不了可被清理的失活阈值。
40
- - 去重 `ref` 链接是会话内相对的,所以移除整目录不会让别处产生悬空链接。
41
- - 权衡:开启自动清理后,某个长时间闲置才被重新打开的会话,只能从剩余(最近 100 组)锚点回退,旧快照已不存在;对话日志永不受影响。
42
- - 手动删除整个存储目录依然安全(下次捕获时自动重建);自动清理只是把这种删除限定到长期不活跃的会话。
57
+ 插件每个会话只保留**最近 100 条消息**的快照。若你在一个很长的会话里多次回退或压缩,这 100 个名额常会被"已经无法再回退到"的消息占满,你可能无法回退到想要的位置。(Claude Code 也是同样行为。)想恢复到一个干净状态,运行 `/snapshot-auto-cleanup run --current --apply` 清空当前会话、重新开始。
package/lib/client.js CHANGED
@@ -35,6 +35,11 @@ __export(index_exports, {
35
35
  module.exports = __toCommonJS(index_exports);
36
36
 
37
37
  // src/client/hidden.ts
38
+ function chatSnapshotOf(face, chatView) {
39
+ const legacy = face?.getSnapshot().chat;
40
+ if (legacy !== void 0) return legacy;
41
+ return chatView?.getSnapshot() ?? void 0;
42
+ }
38
43
  function targetSeqOfArgs(args) {
39
44
  if (args === void 0 || args === null) return void 0;
40
45
  const match = args.match(/@(\d+)/);
@@ -333,10 +338,11 @@ function parseImpactList(text) {
333
338
  }
334
339
  return { restores, deletes };
335
340
  }
336
- function findCommand(snapshot, match) {
341
+ function findCommand(chat, match) {
342
+ if (chat === void 0) return void 0;
337
343
  let found;
338
- for (const key of snapshot.chat.order) {
339
- const node = snapshot.chat.nodes.get(key);
344
+ for (const key of chat.order) {
345
+ const node = chat.nodes.get(key);
340
346
  if (node !== void 0 && node.kind === "command") {
341
347
  const command = node.data;
342
348
  if (match(command)) found = command;
@@ -344,11 +350,12 @@ function findCommand(snapshot, match) {
344
350
  }
345
351
  return found;
346
352
  }
347
- function knownCommandSeqs(session, match) {
353
+ function knownCommandSeqs(session, chatOf, match) {
348
354
  const known = /* @__PURE__ */ new Set();
349
- const snapshot = session.getSnapshot();
350
- for (const key of snapshot.chat.order) {
351
- const node = snapshot.chat.nodes.get(key);
355
+ const chat = chatOf(session);
356
+ if (chat === void 0) return known;
357
+ for (const key of chat.order) {
358
+ const node = chat.nodes.get(key);
352
359
  if (node !== void 0 && node.kind === "command") {
353
360
  const command = node.data;
354
361
  if (match(command)) known.add(command.seq);
@@ -356,7 +363,7 @@ function knownCommandSeqs(session, match) {
356
363
  }
357
364
  return known;
358
365
  }
359
- function waitForCommand(session, match, timeoutMs = 8e3) {
366
+ function waitForCommand(session, chatOf, match, timeoutMs = 8e3) {
360
367
  return new Promise((resolve) => {
361
368
  let settled = false;
362
369
  const settle = (value) => {
@@ -367,7 +374,7 @@ function waitForCommand(session, match, timeoutMs = 8e3) {
367
374
  resolve(value);
368
375
  };
369
376
  const check = () => {
370
- const node = findCommand(session.getSnapshot(), match);
377
+ const node = findCommand(chatOf(session), match);
371
378
  if (node?.outcome !== null && node?.outcome !== void 0) {
372
379
  settle({ kind: node.outcome.kind, text: node.outcome.text });
373
380
  }
@@ -381,11 +388,11 @@ function isPreviewFor(node, seq) {
381
388
  const args = node.args ?? "";
382
389
  return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
383
390
  }
384
- async function previewImpact(session, seq) {
385
- const known = knownCommandSeqs(session, (node) => isPreviewFor(node, seq));
391
+ async function previewImpact(session, chatOf, seq) {
392
+ const known = knownCommandSeqs(session, chatOf, (node) => isPreviewFor(node, seq));
386
393
  const result = await session.command(`/rewind preview @${seq} both`);
387
394
  if (!result.ok || result.value?.matched !== true) return null;
388
- return waitForCommand(session, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
395
+ return waitForCommand(session, chatOf, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
389
396
  }
390
397
  function el(tag, className, text) {
391
398
  const node = document.createElement(tag);
@@ -436,7 +443,7 @@ function renderImpactStep(root, opts, back, cached) {
436
443
  root.replaceChildren(impact, actions);
437
444
  focusFirst(root);
438
445
  void (async () => {
439
- const outcome = cached ?? await previewImpact(session, seq);
446
+ const outcome = cached ?? await previewImpact(session, opts.chatOf, seq);
440
447
  if (outcome === null) {
441
448
  impact.textContent = t("popover.impact.failed", { message: "preview command failed or timed out" });
442
449
  return;
@@ -555,10 +562,10 @@ function openPopover(opts) {
555
562
  openRetractPopover(opts);
556
563
  return;
557
564
  }
558
- const { session, seq, time, preview, anchor, t } = opts;
565
+ const { session, seq, time, preview, anchor, t, chatOf } = opts;
559
566
  const onRewind = opts.onRewind;
560
567
  if (seq === void 0 || time === void 0 || onRewind === void 0) return;
561
- const durableOpts = { session, seq, time, preview, anchor, t, onRewind };
568
+ const durableOpts = { session, seq, time, preview, anchor, t, chatOf, onRewind };
562
569
  const root = el("div", CLASS.popover);
563
570
  root.setAttribute("role", "dialog");
564
571
  root.setAttribute("aria-label", t("popover.title"));
@@ -628,7 +635,7 @@ function openPopover(opts) {
628
635
  popoverEl = root;
629
636
  disposeOutside = shell.dispose;
630
637
  void (async () => {
631
- const outcome = await previewImpact(session, seq);
638
+ const outcome = await previewImpact(session, chatOf, seq);
632
639
  impactOutcome = outcome;
633
640
  if (outcome !== null && outcome.kind === "success") {
634
641
  bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
@@ -670,10 +677,10 @@ function retractSpan(steering, targetId) {
670
677
 
671
678
  // src/client/portals.tsx
672
679
  var import_jsx_runtime = require("react/jsx-runtime");
673
- function userTextAt(session, seq) {
674
- const snap = session.getSnapshot();
675
- for (const key of snap.chat.order) {
676
- const node = snap.chat.nodes.get(key);
680
+ function userTextAt(chat, seq) {
681
+ if (chat === void 0) return void 0;
682
+ for (const key of chat.order) {
683
+ const node = chat.nodes.get(key);
677
684
  if (node === void 0 || node.kind !== "user") continue;
678
685
  const user = node.data;
679
686
  if (user.seq === seq) {
@@ -691,23 +698,23 @@ function fillComposer(text) {
691
698
  textarea.focus();
692
699
  return true;
693
700
  }
694
- function userNodeOf(session, key) {
695
- const node = session.getSnapshot().chat.nodes.get(key);
701
+ function userNodeOf(chat, key) {
702
+ const node = chat?.nodes.get(key);
696
703
  if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
697
704
  return node.data;
698
705
  }
699
- async function runRewindAndFill(session, seq, mode, currentSessionId) {
700
- const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
706
+ async function runRewindAndFill(session, seq, mode, currentSessionId, chatOf) {
707
+ const known = knownCommandSeqs(session, chatOf, (node) => isExecutedRewindCommand(node, seq));
701
708
  const result = await session.command(`/rewind @${seq} ${mode}`);
702
709
  if (!result.ok || result.value?.matched !== true) return;
703
- const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
710
+ const outcome = await waitForCommand(session, chatOf, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
704
711
  if (outcome === null) return;
705
712
  if (outcome.kind !== "success") {
706
713
  showHint(outcome.text ?? "rewind failed");
707
714
  return;
708
715
  }
709
716
  if (currentSessionId() !== session.sessionId) return;
710
- const text = userTextAt(session, seq);
717
+ const text = userTextAt(chatOf(session), seq);
711
718
  if (text === void 0 || text === "") return;
712
719
  fillComposer(text);
713
720
  }
@@ -794,7 +801,7 @@ function sameTargets(left, right) {
794
801
  return other.kind === "pending" && target.itemId === other.itemId;
795
802
  });
796
803
  }
797
- function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
804
+ function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale }) {
798
805
  const [targets, setTargets] = (0, import_react.useState)([]);
799
806
  const hidden = (0, import_react.useRef)(/* @__PURE__ */ new WeakSet());
800
807
  const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
@@ -812,12 +819,12 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
812
819
  return;
813
820
  }
814
821
  const snapshot = session.getSnapshot();
815
- const chat = snapshot.chat;
816
- const hiddenSeqs = hiddenSeqsOf(chat);
822
+ const chat = chatOf(session);
823
+ const hiddenSeqs = chat === void 0 ? /* @__PURE__ */ new Set() : hiddenSeqsOf(chat);
817
824
  let hiddenCount = 0;
818
- for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
825
+ for (const seat of chat === void 0 ? [] : document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
819
826
  const key = seat.dataset.chatAnchorKey;
820
- const anchor = key !== void 0 ? chat.nodes.get(key)?.anchorSeq : void 0;
827
+ const anchor = key !== void 0 ? chat?.nodes.get(key)?.anchorSeq : void 0;
821
828
  if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
822
829
  seat.style.display = "none";
823
830
  seat.dataset.dshRewindHidden = "true";
@@ -834,7 +841,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
834
841
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
835
842
  );
836
843
  }
837
- const next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
844
+ const durable = chat === void 0 ? [] : collectTargets(chat, hiddenSeqs);
845
+ const next = [...durable, ...collectPendingTargets(snapshot)];
838
846
  setTargets((current) => sameTargets(current, next) ? current : next);
839
847
  };
840
848
  const queueRefresh = () => {
@@ -860,6 +868,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
860
868
  target,
861
869
  sessionId,
862
870
  sessionOf,
871
+ chatOf,
863
872
  t
864
873
  },
865
874
  target.key
@@ -869,6 +878,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
869
878
  target,
870
879
  sessionId,
871
880
  sessionOf,
881
+ chatOf,
872
882
  currentSessionId,
873
883
  t
874
884
  },
@@ -878,7 +888,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
878
888
  target.key
879
889
  ));
880
890
  }
881
- function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
891
+ function RewindButton({ target, sessionId, sessionOf, chatOf, currentSessionId, t }) {
882
892
  const onClick = (event) => {
883
893
  event.stopPropagation();
884
894
  const session = sessionOf(sessionId);
@@ -886,17 +896,18 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
886
896
  console.warn("[dsh-rewind] rewind button clicked with no session binding");
887
897
  return;
888
898
  }
889
- const node = userNodeOf(session, target.key);
899
+ const node = userNodeOf(chatOf(session), target.key);
890
900
  if (node === void 0) return;
891
901
  openPopover({
892
902
  session,
903
+ chatOf,
893
904
  seq: node.seq,
894
905
  time: node.time,
895
906
  preview: messagePreviewOf(node),
896
907
  anchor: event.currentTarget,
897
908
  t,
898
909
  onRewind: (mode) => {
899
- void runRewindAndFill(session, node.seq, mode, currentSessionId);
910
+ void runRewindAndFill(session, node.seq, mode, currentSessionId, chatOf);
900
911
  }
901
912
  });
902
913
  };
@@ -927,13 +938,14 @@ async function retractPending(session, itemId, text) {
927
938
  fillComposer(text);
928
939
  }
929
940
  }
930
- function RetractButton({ target, sessionId, sessionOf, t }) {
941
+ function RetractButton({ target, sessionId, sessionOf, chatOf, t }) {
931
942
  const onClick = (event) => {
932
943
  event.stopPropagation();
933
944
  const session = sessionOf(sessionId);
934
945
  if (session === void 0) return;
935
946
  openPopover({
936
947
  session,
948
+ chatOf,
937
949
  preview: target.preview,
938
950
  anchor: event.currentTarget,
939
951
  t,
@@ -986,8 +998,7 @@ var zh = {
986
998
  "popover.impact.restore": "\u8FD8\u539F {path}",
987
999
  "popover.impact.delete": "\u5220\u9664 {path}",
988
1000
  "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
989
- "popover.back": "\u8FD4\u56DE",
990
- "guard.hint": "/rewind \u624B\u52A8\u8F93\u5165\u4E0D\u63A5\u53D7\u53C2\u6570\uFF0C\u8BF7\u76F4\u63A5\u8F93\u5165 /rewind \u6253\u5F00\u56DE\u9000\u83DC\u5355"
1001
+ "popover.back": "\u8FD4\u56DE"
991
1002
  };
992
1003
  var en = {
993
1004
  "button.aria": "Rewind to this message",
@@ -1013,8 +1024,7 @@ var en = {
1013
1024
  "popover.impact.restore": "Restore {path}",
1014
1025
  "popover.impact.delete": "Delete {path}",
1015
1026
  "popover.confirm": "Confirm rewind",
1016
- "popover.back": "Back",
1017
- "guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
1027
+ "popover.back": "Back"
1018
1028
  };
1019
1029
 
1020
1030
  // src/client/index.ts
@@ -1034,6 +1044,17 @@ function apply(ctx) {
1034
1044
  const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
1035
1045
  const currentSessionId = () => ctx.sessions.list.getSnapshot().current;
1036
1046
  const subscribeLocale = (cb) => ctx.locale.subscribe(cb);
1047
+ const uiConversation = () => ctx.get("uiConversation");
1048
+ const CHAT_VIEW = "chat";
1049
+ const chatOf = (session) => {
1050
+ if (session === void 0) return void 0;
1051
+ try {
1052
+ const view = uiConversation()?.binding(session.sessionId).target(CHAT_VIEW);
1053
+ return chatSnapshotOf(session, view);
1054
+ } catch {
1055
+ return void 0;
1056
+ }
1057
+ };
1037
1058
  const slots = ctx.slots;
1038
1059
  yield slots.inject(HEADER_ACTIONS_SLOT, () => slots.register(
1039
1060
  {
@@ -1043,23 +1064,19 @@ function apply(ctx) {
1043
1064
  id: "dsh-rewind-portals",
1044
1065
  order: 1e3
1045
1066
  },
1046
- createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
1067
+ createRewindBridge({ sessionOf, chatOf, currentSessionId, t, subscribeLocale })
1047
1068
  ));
1048
1069
  const commandUi = ctx.get("commandUi");
1049
- const chatOf = (sessionId) => {
1050
- if (sessionId === void 0) return void 0;
1051
- const face = sessionOf(sessionId);
1052
- return face === void 0 ? void 0 : face.getSnapshot().chat;
1053
- };
1054
1070
  const hasCandidates = (sessionId) => {
1055
- const chat = chatOf(sessionId);
1071
+ const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
1072
+ const chat = chatOf(face);
1056
1073
  return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
1057
1074
  };
1058
- const fetchHostCandidates = async (face) => {
1059
- const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
1075
+ const fetchHostCandidates = async (face, chatOf2) => {
1076
+ const known = knownCommandSeqs(face, chatOf2, (node) => isCandidateCommand(node));
1060
1077
  const result = await face.command("/rewind __candidates");
1061
1078
  if (!result.ok || result.value?.matched !== true) return void 0;
1062
- const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
1079
+ const outcome = await waitForCommand(face, chatOf2, (node) => isCandidateCommand(node) && !known.has(node.seq));
1063
1080
  if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
1064
1081
  return rewindCandidatesFromHostText(outcome.text);
1065
1082
  };
@@ -1069,8 +1086,7 @@ function apply(ctx) {
1069
1086
  const card = textarea?.closest("[data-composer-card]");
1070
1087
  return card ?? textarea ?? document.body;
1071
1088
  };
1072
- yield commandUi.decorate({
1073
- name: "rewind",
1089
+ const rewindPopupSpec = {
1074
1090
  // The picker exists exactly while the surface has a reachable user
1075
1091
  // message: a fresh session (no candidates) falls through to the host
1076
1092
  // command, which fails with "no user messages" — matching the harness's
@@ -1081,7 +1097,7 @@ function apply(ctx) {
1081
1097
  options: async (session) => {
1082
1098
  const face = sessionOf(session.sessionId);
1083
1099
  if (face === void 0) return [];
1084
- const candidates = await fetchHostCandidates(face);
1100
+ const candidates = await fetchHostCandidates(face, chatOf);
1085
1101
  if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
1086
1102
  return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
1087
1103
  },
@@ -1094,77 +1110,24 @@ function apply(ctx) {
1094
1110
  if (candidate === void 0) return;
1095
1111
  openPopover({
1096
1112
  session: face,
1113
+ chatOf,
1097
1114
  seq: candidate.seq,
1098
1115
  time: candidate.time,
1099
1116
  preview: candidate.preview,
1100
1117
  anchor: composerAnchor(),
1101
1118
  t,
1102
1119
  onRewind: (mode) => {
1103
- void runRewindAndFill(face, candidate.seq, mode, currentSessionId);
1120
+ void runRewindAndFill(face, candidate.seq, mode, currentSessionId, chatOf);
1104
1121
  }
1105
1122
  });
1106
1123
  }
1107
1124
  }
1108
- });
1109
- const PARAM_REWIND = /^\s*\/rewind\s+\S+/i;
1110
- const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
1111
- const hasParamRewindDraft = () => {
1112
- const textarea = composerTextarea();
1113
- return textarea !== null && PARAM_REWIND.test(textarea.value);
1114
- };
1115
- let guardHintEl = null;
1116
- let guardHintTimer;
1117
- const showGuardHint = () => {
1118
- if (guardHintEl !== null) guardHintEl.remove();
1119
- if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
1120
- const textarea = composerTextarea();
1121
- if (textarea === null) return;
1122
- const card = textarea.closest("[data-composer-card]");
1123
- const hint = document.createElement("div");
1124
- hint.className = "dsh-rewind-guard-hint";
1125
- hint.setAttribute("role", "status");
1126
- hint.textContent = t("guard.hint");
1127
- document.body.appendChild(hint);
1128
- const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.getBoundingClientRect();
1129
- hint.style.left = `${Math.round(rect.left)}px`;
1130
- hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
1131
- guardHintEl = hint;
1132
- guardHintTimer = window.setTimeout(() => {
1133
- hint.remove();
1134
- if (guardHintEl === hint) guardHintEl = null;
1135
- guardHintTimer = void 0;
1136
- }, 3200);
1137
1125
  };
1138
- const onParamRewindSubmit = (event) => {
1139
- if (!hasParamRewindDraft()) return;
1140
- event.preventDefault();
1141
- event.stopPropagation();
1142
- showGuardHint();
1143
- };
1144
- const onKeyDownGuard = (event) => {
1145
- if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
1146
- onParamRewindSubmit(event);
1147
- };
1148
- const onClickGuard = (event) => {
1149
- if (event.button !== 0 || !hasParamRewindDraft()) return;
1150
- const target = event.target;
1151
- if (!(target instanceof Element)) return;
1152
- const button = target.closest("button");
1153
- if (button === null) return;
1154
- const card = button.closest("[data-composer-card]");
1155
- if (card === null) return;
1156
- const all = card.querySelectorAll("button");
1157
- if (all[all.length - 1] !== button) return;
1158
- if (button.querySelector("rect") !== null) return;
1159
- onParamRewindSubmit(event);
1160
- };
1161
- document.addEventListener("keydown", onKeyDownGuard, true);
1162
- document.addEventListener("click", onClickGuard, true);
1126
+ for (const name2 of ["rewind", "undo"]) {
1127
+ yield commandUi.decorate({ name: name2, ...rewindPopupSpec });
1128
+ }
1129
+ const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
1163
1130
  yield () => {
1164
- document.removeEventListener("keydown", onKeyDownGuard, true);
1165
- document.removeEventListener("click", onClickGuard, true);
1166
- if (guardHintEl !== null) guardHintEl.remove();
1167
- if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
1168
1131
  style.remove();
1169
1132
  };
1170
1133
  }, "dsh-rewind client lifecycle");