dsh-rewind-plugin 0.6.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.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/compat/audit.md +16 -0
- package/docs/release/release.md +8 -3
- package/docs/release/release.zh.md +4 -2
- package/lib/client.js +68 -48
- package/lib/index.js +7 -0
- package/lib/types/client/hidden.d.ts +28 -0
- package/lib/types/client/popover.d.ts +9 -2
- package/lib/types/client/portals.d.ts +15 -2
- package/lib/types/snapshot.d.ts +7 -0
- package/package.json +2 -1
package/README.en.md
CHANGED
|
@@ -11,7 +11,7 @@ Conversation rewind for DeepSeek Harness: **rewind the conversation to any earli
|
|
|
11
11
|
A deliberately focused plugin with one job: **rewind to any user message, no matter how far back, in place** — and conveniently **restore the files it changed** along the way.
|
|
12
12
|
|
|
13
13
|
- **Rewinding is time-travel** — the target message and everything after it (agent replies, tool calls) are withdrawn from the model context *and* the rendered transcript at once, with no new session and no window switch; the target's text is offered back in the composer so you can edit and re-send it — **truly seamless and convenient by design**.
|
|
14
|
-
- **Lightweight workspace backup** — Claude Code-aligned behavior:
|
|
14
|
+
- **Lightweight workspace backup** — Claude Code-aligned behavior: tracks files edited by the write-class tools, and external changes to **already-tracked** files are restorable too. Selective tracking, before-write backup, store only on change. One lightweight plugin gives you a **complete** agentic rewind capability.
|
|
15
15
|
- **Privacy-first** — the plugin never deletes or rewrites the session log (append-only) and never actually deletes any of your conversation; file restores stay inside the plugin's own backup directory. Full security model: [SECURITY.md](SECURITY.md).
|
|
16
16
|
- **A complete test system** — unit, probe, and end-to-end host verification, covering compatibility probing, log replay, resume, cross-restart and other scenarios; maintained continuously as the harness evolves to ensure feature stability.
|
|
17
17
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消
|
|
|
11
11
|
刻意聚焦、保持极简,只做一件事:**就地回退到任意远的用户消息**,还能**顺手还原改过的文件**。
|
|
12
12
|
|
|
13
13
|
- **回退 = 时间回溯**——目标消息及其之后的全部内容(agent 回复、工具调用)同时从**模型上下文**和**渲染对话**中撤回,不新建会话、不切换窗口;目标消息文本会回填输入框,改完可重发。**在原理上就真正无感、便捷**。
|
|
14
|
-
-
|
|
14
|
+
- **轻量工作区备份**——对齐 Claude Code:追踪写类工具编辑过的文件,**已跟踪文件的外部变更也能还原**。局部追踪、写前备份、不变不存。一个轻型插件,即拥有**完备的智能体回退能力**。
|
|
15
15
|
- **信息安全优先**——插件从不删改会话日志(append-only),从不真正删除你的任何对话;文件还原限定在插件自己的备份目录。完整安全模型:[SECURITY.md](SECURITY.md)。
|
|
16
16
|
- **完备测试系统**——单元、探针、端到端主机验证,覆盖兼容性探测、日志重放、续接、跨重启等场景;随 harness 升级持续维护,确保功能稳定。
|
|
17
17
|
|
package/docs/compat/audit.md
CHANGED
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
> `0.1.2-rc.x`, `0.2.x`) requires appending a union member; rc rolling within a
|
|
18
18
|
> tuple (`0.1.1-rc.2 → rc.3`) is a no-op. Signal: `npm view @deepseek-ai/dsh version`;
|
|
19
19
|
> flow: `scripts/check-dsh-version.mjs`.
|
|
20
|
+
>
|
|
21
|
+
> Client channel (harness `0.1.2-alpha.1`, a **bundled pre-release — NOT published
|
|
22
|
+
> to npm**): the plugin also reads the session chat when a DSH Desktop build ships
|
|
23
|
+
> that version (`latest` on the registry is still `0.1.1-rc.2`; the alpha is only
|
|
24
|
+
> in the bundled `app.asar.unpacked/node_modules/@deepseek-ai/*`). There
|
|
25
|
+
> `uiConversation` exposes the chat as a per-session named `"chat"` view
|
|
26
|
+
> (contributed by `dsh-client-ui-chat` through the `uiSession` slot hook) instead
|
|
27
|
+
> of the session-face `chat` field. `src/client/hidden.ts` (`chatSnapshotOf`) reads
|
|
28
|
+
> the session-face snapshot first (rc.2 path), then the `uiConversation` `"chat"`
|
|
29
|
+
> view (alpha.1 path); both missing degrades to `undefined` (no targets, never a
|
|
30
|
+
> crash). Peers are intentionally **NOT** extended for `0.1.2-alpha.1` because it is
|
|
31
|
+
> not a registry version (see above); the OR-union stays
|
|
32
|
+
> `^0.1.0-rc.6 || ^0.1.1-rc.2` and will gain the `0.1.2` tuple when the harness
|
|
33
|
+
> publishes it. Pinned by `tests/chat-channel.test.ts` (channel precedence + the
|
|
34
|
+
> alpha.1 snapshot shape) and `tests/client-dom.test.ts` (the button-target
|
|
35
|
+
> pairing that consumes the chat).
|
|
20
36
|
|
|
21
37
|
## Definition of "fully compatible" (invariants)
|
|
22
38
|
|
package/docs/release/release.md
CHANGED
|
@@ -38,6 +38,9 @@ npm version patch
|
|
|
38
38
|
git push origin main --tags # triggers .github/workflows/publish.yml
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
- **Before bumping, manually confirm there is no newer DSH version the plugin
|
|
42
|
+
has not been verified against** (a pre-release can ship in DSH Desktop
|
|
43
|
+
without being on npm, e.g. `0.1.2-alpha.1`; see docs/compat/audit.md).
|
|
41
44
|
- The workflow verifies the tag matches `package.json`, runs typecheck + tests +
|
|
42
45
|
a full build + artifact verification, publishes with `--provenance`
|
|
43
46
|
(Sigstore), and creates a GitHub Release. It is **idempotent** — an already
|
|
@@ -58,9 +61,11 @@ uses an OR-union covering every published rc tuple series
|
|
|
58
61
|
(`0.1.1 → 0.1.2 → 0.2.x`); rc rolling within a tuple (`0.1.1-rc.2 → rc.3`)
|
|
59
62
|
needs nothing. All `@deepseek-ai/*` packages release together;
|
|
60
63
|
`npm view @deepseek-ai/dsh version` is the authoritative signal.
|
|
61
|
-
- **
|
|
62
|
-
latest npm version against the tuples the peers cover
|
|
63
|
-
|
|
64
|
+
- **Published-tuple check (optional)**: `node scripts/check-dsh-version.mjs`
|
|
65
|
+
compares the latest npm version against the tuples the peers cover (exit 0 =
|
|
66
|
+
nothing to do, exit 1 = update). It reads npm published versions only; a
|
|
67
|
+
newer-but-unpublished pre-release is a manual pre-release check — see the
|
|
68
|
+
"Before bumping" step above.
|
|
64
69
|
- **Update steps**: append `|| ^<new-tuple>-rc.<n>` to every
|
|
65
70
|
`@deepseek-ai/dsh-*` peer → bump devDependencies to the latest → `npm
|
|
66
71
|
install` → `npm run check` → release.
|
|
@@ -37,6 +37,7 @@ npm version patch
|
|
|
37
37
|
git push origin main --tags # 触发 .github/workflows/publish.yml
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
- **升版前手动确认**:确认无插件未针对其验证过的更新 DSH 版本(pre-release 可能只随 Desktop 捆绑、而不发到 npm,如 `0.1.2-alpha.1`;见 docs/compat/audit.md)。
|
|
40
41
|
- workflow 校验 tag 与 `package.json` 版本一致,跑 typecheck + 测试 + 完整
|
|
41
42
|
构建 + 产物验证,以 `--provenance`(Sigstore)发布并创建 GitHub Release。
|
|
42
43
|
**幂等**——已发布的版本会跳过。
|
|
@@ -54,8 +55,9 @@ OR 并集覆盖 DSH 已发布的每个 rc 元组系列(如 `^0.1.0-rc.6 || ^0.
|
|
|
54
55
|
- **何时需要更新**:仅当 DSH 发布新元组(`0.1.1 → 0.1.2 → 0.2.x`)时;
|
|
55
56
|
同元组内 rc 滚动(`0.1.1-rc.2 → rc.3`)无需动作。DSH 所有包同版本发布,
|
|
56
57
|
`npm view @deepseek-ai/dsh version` 即权威信号。
|
|
57
|
-
-
|
|
58
|
-
peer
|
|
58
|
+
- **已发布元组检查(可选)**:`node scripts/check-dsh-version.mjs` 用 npm 最新版本
|
|
59
|
+
对比 peer 覆盖的元组(exit 0 无需动作,exit 1 需要)。它**只读 npm 已发布版本**;
|
|
60
|
+
更新的未发布 pre-release 走**手动发布前检查**——见上文"升版前手动确认"。
|
|
59
61
|
- **更新步骤**:给每个 `@deepseek-ai/dsh-*` peer 追加 `|| ^<新元组>-rc.<n>`
|
|
60
62
|
→ devDependencies 同步升到最新 → `npm install` → `npm run check` → 发版。
|
|
61
63
|
- **正式版后收敛**:DSH 发布 final 版本后,正式版不受 prerelease 元组规则
|
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(
|
|
341
|
+
function findCommand(chat, match) {
|
|
342
|
+
if (chat === void 0) return void 0;
|
|
337
343
|
let found;
|
|
338
|
-
for (const key of
|
|
339
|
-
const node =
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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(
|
|
674
|
-
|
|
675
|
-
for (const key of
|
|
676
|
-
const node =
|
|
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(
|
|
695
|
-
const node =
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
@@ -1032,6 +1044,17 @@ function apply(ctx) {
|
|
|
1032
1044
|
const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
|
|
1033
1045
|
const currentSessionId = () => ctx.sessions.list.getSnapshot().current;
|
|
1034
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
|
+
};
|
|
1035
1058
|
const slots = ctx.slots;
|
|
1036
1059
|
yield slots.inject(HEADER_ACTIONS_SLOT, () => slots.register(
|
|
1037
1060
|
{
|
|
@@ -1041,23 +1064,19 @@ function apply(ctx) {
|
|
|
1041
1064
|
id: "dsh-rewind-portals",
|
|
1042
1065
|
order: 1e3
|
|
1043
1066
|
},
|
|
1044
|
-
createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
|
|
1067
|
+
createRewindBridge({ sessionOf, chatOf, currentSessionId, t, subscribeLocale })
|
|
1045
1068
|
));
|
|
1046
1069
|
const commandUi = ctx.get("commandUi");
|
|
1047
|
-
const chatOf = (sessionId) => {
|
|
1048
|
-
if (sessionId === void 0) return void 0;
|
|
1049
|
-
const face = sessionOf(sessionId);
|
|
1050
|
-
return face === void 0 ? void 0 : face.getSnapshot().chat;
|
|
1051
|
-
};
|
|
1052
1070
|
const hasCandidates = (sessionId) => {
|
|
1053
|
-
const
|
|
1071
|
+
const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
|
|
1072
|
+
const chat = chatOf(face);
|
|
1054
1073
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
1055
1074
|
};
|
|
1056
|
-
const fetchHostCandidates = async (face) => {
|
|
1057
|
-
const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
|
|
1075
|
+
const fetchHostCandidates = async (face, chatOf2) => {
|
|
1076
|
+
const known = knownCommandSeqs(face, chatOf2, (node) => isCandidateCommand(node));
|
|
1058
1077
|
const result = await face.command("/rewind __candidates");
|
|
1059
1078
|
if (!result.ok || result.value?.matched !== true) return void 0;
|
|
1060
|
-
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));
|
|
1061
1080
|
if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
|
|
1062
1081
|
return rewindCandidatesFromHostText(outcome.text);
|
|
1063
1082
|
};
|
|
@@ -1078,7 +1097,7 @@ function apply(ctx) {
|
|
|
1078
1097
|
options: async (session) => {
|
|
1079
1098
|
const face = sessionOf(session.sessionId);
|
|
1080
1099
|
if (face === void 0) return [];
|
|
1081
|
-
const candidates = await fetchHostCandidates(face);
|
|
1100
|
+
const candidates = await fetchHostCandidates(face, chatOf);
|
|
1082
1101
|
if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
|
|
1083
1102
|
return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
|
|
1084
1103
|
},
|
|
@@ -1091,13 +1110,14 @@ function apply(ctx) {
|
|
|
1091
1110
|
if (candidate === void 0) return;
|
|
1092
1111
|
openPopover({
|
|
1093
1112
|
session: face,
|
|
1113
|
+
chatOf,
|
|
1094
1114
|
seq: candidate.seq,
|
|
1095
1115
|
time: candidate.time,
|
|
1096
1116
|
preview: candidate.preview,
|
|
1097
1117
|
anchor: composerAnchor(),
|
|
1098
1118
|
t,
|
|
1099
1119
|
onRewind: (mode) => {
|
|
1100
|
-
void runRewindAndFill(face, candidate.seq, mode, currentSessionId);
|
|
1120
|
+
void runRewindAndFill(face, candidate.seq, mode, currentSessionId, chatOf);
|
|
1101
1121
|
}
|
|
1102
1122
|
});
|
|
1103
1123
|
}
|
package/lib/index.js
CHANGED
|
@@ -772,6 +772,13 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
772
772
|
* already absent, i.e. the target state is reached), a restore is a plain
|
|
773
773
|
* writeFile with a recursive mkdir of the parent. Returns how the outcome
|
|
774
774
|
* should record it.
|
|
775
|
+
*
|
|
776
|
+
* This is the only place the store writes restored content to the real FS,
|
|
777
|
+
* and it is deliberately a raw `writeFile`/`unlink` rather than the fs
|
|
778
|
+
* service: the caller only ever hands it a path from `planRestore` — one the
|
|
779
|
+
* session's own write-class tool call recorded and resolved (never a
|
|
780
|
+
* symlink/hard link) and only when it differs from the live disk. So no
|
|
781
|
+
* arbitrary path, no model input, never automatic.
|
|
775
782
|
*/
|
|
776
783
|
async applyActionToDisk(kind, path, content, deleteFile) {
|
|
777
784
|
if (kind === "delete") {
|
|
@@ -13,6 +13,34 @@ export interface HiddenChat {
|
|
|
13
13
|
get(key: string): ChatConversationViewNode | undefined;
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Reader for one session's live chat snapshot. The dual channel hides the
|
|
18
|
+
* harness split behind SiriLee/dsh-rewind#7: rc.2 serves the chat from the
|
|
19
|
+
* session face snapshot, while 0.1.2-alpha.1+ serves it from the
|
|
20
|
+
* `uiConversation` service's named "chat" view (contributed by
|
|
21
|
+
* dsh-client-ui-chat through the uiSession slot hook).
|
|
22
|
+
*/
|
|
23
|
+
export type ChatOf = (session: {
|
|
24
|
+
readonly sessionId: string;
|
|
25
|
+
getSnapshot(): {
|
|
26
|
+
chat?: unknown;
|
|
27
|
+
};
|
|
28
|
+
} | undefined) => HiddenChat | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the chat snapshot across the two harness channels: the session-face
|
|
31
|
+
* snapshot first (rc.2 — on alpha.1+ the face no longer carries `chat`, so the
|
|
32
|
+
* field reads `undefined`), then the `uiConversation` "chat" view. The view's
|
|
33
|
+
* `getSnapshot()` returns undefined until the named view is registered, so
|
|
34
|
+
* both channels missing degrades to `undefined` (no targets, no hiding —
|
|
35
|
+
* never a crash).
|
|
36
|
+
*/
|
|
37
|
+
export declare function chatSnapshotOf(face: {
|
|
38
|
+
getSnapshot(): {
|
|
39
|
+
chat?: unknown;
|
|
40
|
+
};
|
|
41
|
+
} | undefined, chatView: {
|
|
42
|
+
getSnapshot(): unknown;
|
|
43
|
+
} | undefined): HiddenChat | undefined;
|
|
16
44
|
/**
|
|
17
45
|
* Extract the rewind target seq from a `/rewind` command's structured `args`
|
|
18
46
|
* (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
17
17
|
import type { CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
|
|
18
|
+
import { type ChatOf } from './hidden.ts';
|
|
18
19
|
import type { RewindKey } from './locales.ts';
|
|
19
20
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
20
21
|
export interface PopoverOptions {
|
|
@@ -31,6 +32,12 @@ export interface PopoverOptions {
|
|
|
31
32
|
/** Pending variant: executed after the retract confirm closes the popover. */
|
|
32
33
|
readonly onRetract?: () => void;
|
|
33
34
|
readonly preview: string;
|
|
35
|
+
/**
|
|
36
|
+
* Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
|
|
37
|
+
* view): the durable variant's command probes scan the chat through it.
|
|
38
|
+
* Unused by the pending-retract variant.
|
|
39
|
+
*/
|
|
40
|
+
readonly chatOf: ChatOf;
|
|
34
41
|
/** The button that opened the popover (outside-click ignore target). */
|
|
35
42
|
readonly anchor: HTMLElement;
|
|
36
43
|
readonly t: Translate;
|
|
@@ -50,13 +57,13 @@ export declare function closePopover(): void;
|
|
|
50
57
|
* command's stale outcome (e.g. an older preview that found file changes,
|
|
51
58
|
* after those changes were already restored).
|
|
52
59
|
*/
|
|
53
|
-
export declare function knownCommandSeqs(session: SessionFace, match: (node: CommandNode) => boolean): Set<number>;
|
|
60
|
+
export declare function knownCommandSeqs(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean): Set<number>;
|
|
54
61
|
/**
|
|
55
62
|
* Resolve the outcome of the newest matching rewind command by watching the
|
|
56
63
|
* session snapshot (command/run + command/done land as one CommandNode).
|
|
57
64
|
* @returns the outcome text-bearing node, or null on timeout.
|
|
58
65
|
*/
|
|
59
|
-
export declare function waitForCommand(session: SessionFace, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
|
|
66
|
+
export declare function waitForCommand(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
|
|
60
67
|
kind: 'success' | 'error';
|
|
61
68
|
text?: string;
|
|
62
69
|
} | null>;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { type ReactNode } from 'react';
|
|
26
26
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
27
|
+
import { type ChatOf, type HiddenChat } from './hidden.ts';
|
|
27
28
|
import type { RewindKey } from './locales.ts';
|
|
28
29
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
29
30
|
/** One portal target: the actions row of a user/steering seat + its durable node. */
|
|
@@ -51,6 +52,12 @@ export type PortalTarget = {
|
|
|
51
52
|
/** Capabilities the session-scoped bridge receives from the plugin apply(). */
|
|
52
53
|
export interface RewindBridgeDeps {
|
|
53
54
|
readonly sessionOf: (sessionId: string) => SessionFace | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
|
|
57
|
+
* "chat" view): every chat snapshot read goes through it. See
|
|
58
|
+
* `chatSnapshotOf` in hidden.ts for the channel precedence.
|
|
59
|
+
*/
|
|
60
|
+
readonly chatOf: ChatOf;
|
|
54
61
|
readonly currentSessionId: () => string | undefined;
|
|
55
62
|
readonly t: Translate;
|
|
56
63
|
readonly subscribeLocale: (cb: () => void) => () => void;
|
|
@@ -87,7 +94,13 @@ export declare function fillComposer(text: string): boolean;
|
|
|
87
94
|
* the old baseline heuristic refilled withdrawn text into the composer
|
|
88
95
|
* after switching sessions or restarting dsh.
|
|
89
96
|
*/
|
|
90
|
-
export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined): Promise<void>;
|
|
97
|
+
export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined, chatOf: ChatOf): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Collect the portal targets of one session: user rows × snapshot nodes.
|
|
100
|
+
* Exported as a test seam — the DOM→targets pairing that drives the ↶ button
|
|
101
|
+
* is otherwise only reachable through a full React portal render.
|
|
102
|
+
*/
|
|
103
|
+
export declare function collectTargets(chat: HiddenChat, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
|
|
91
104
|
interface RewindPortalsProps extends RewindBridgeDeps {
|
|
92
105
|
readonly sessionId: string;
|
|
93
106
|
}
|
|
@@ -98,7 +111,7 @@ interface RewindPortalsProps extends RewindBridgeDeps {
|
|
|
98
111
|
* skipped when the target set is unchanged), so the plugin never runs a
|
|
99
112
|
* synchronous full-transcript scan inside a commit microtask.
|
|
100
113
|
*/
|
|
101
|
-
export declare function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
|
|
114
|
+
export declare function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
|
|
102
115
|
/**
|
|
103
116
|
* Build the slot-entry component for the plugin apply(): a tiny bridge that
|
|
104
117
|
* injects the apply-time capabilities (session resolution, locale, rewind
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -456,6 +456,13 @@ export declare class SnapshotStore {
|
|
|
456
456
|
* already absent, i.e. the target state is reached), a restore is a plain
|
|
457
457
|
* writeFile with a recursive mkdir of the parent. Returns how the outcome
|
|
458
458
|
* should record it.
|
|
459
|
+
*
|
|
460
|
+
* This is the only place the store writes restored content to the real FS,
|
|
461
|
+
* and it is deliberately a raw `writeFile`/`unlink` rather than the fs
|
|
462
|
+
* service: the caller only ever hands it a path from `planRestore` — one the
|
|
463
|
+
* session's own write-class tool call recorded and resolved (never a
|
|
464
|
+
* symlink/hard link) and only when it differs from the live disk. So no
|
|
465
|
+
* arbitrary path, no model input, never automatic.
|
|
459
466
|
*/
|
|
460
467
|
private applyActionToDisk;
|
|
461
468
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -154,6 +154,7 @@
|
|
|
154
154
|
"@types/react": "^18.3.31",
|
|
155
155
|
"@types/react-dom": "^18.3.7",
|
|
156
156
|
"esbuild": "^0.28.2",
|
|
157
|
+
"jsdom": "^30.0.1",
|
|
157
158
|
"react": "^18.3.1",
|
|
158
159
|
"react-dom": "^18.3.1",
|
|
159
160
|
"typescript": "^7.0.2",
|