dsh-rewind-plugin 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/README.zh.md +12 -9
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/lib/client.js +181 -35
- package/lib/index.js +312 -63
- package/lib/types/client/candidates.d.ts +76 -0
- package/lib/types/client/hidden.d.ts +10 -6
- package/lib/types/client/index.d.ts +17 -9
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/popover.d.ts +7 -0
- package/lib/types/client/portals.d.ts +22 -0
- package/lib/types/locales.d.ts +67 -0
- package/lib/types/snapshot.d.ts +107 -10
- package/package.json +13 -1
- package/assets/screenshots/guard-hint.png +0 -0
package/README.md
CHANGED
|
@@ -29,12 +29,10 @@ Each user message gains a compact **↶ rewind** action in its action row. Click
|
|
|
29
29
|
</tr>
|
|
30
30
|
<tr>
|
|
31
31
|
<td align="center"><img src="assets/screenshots/impact-list.png" width="440" alt="Impact list"><br><sub>"Conversation and code" impact list</sub></td>
|
|
32
|
-
<td align="center"><img src="assets/screenshots/
|
|
32
|
+
<td align="center"><img src="assets/screenshots/rewind-candidates.png" width="440" alt="/rewind candidate picker"><br><sub>/rewind candidate picker</sub></td>
|
|
33
33
|
</tr>
|
|
34
34
|
</table>
|
|
35
35
|
|
|
36
|
-
Manual `/rewind` input in the composer is intercepted — submitting shows a transient hint pointing at the ↶ button.
|
|
37
|
-
|
|
38
36
|
## Install
|
|
39
37
|
|
|
40
38
|
```sh
|
|
@@ -53,7 +51,11 @@ For contributors: install from a local checkout or a pinned commit — `dsh plug
|
|
|
53
51
|
2. **Click it.** The target is that message; a small popover offers the two modes ("conversation and code" is hidden when no tracked file changes exist after the target).
|
|
54
52
|
3. The rewind executes as an in-session command; a result message confirms, and the withdrawn message's text is filled back into the composer for editing and re-sending.
|
|
55
53
|
|
|
56
|
-
|
|
54
|
+
**Command-line entry**: type a bare `/rewind` and press Enter to open the candidate picker; selecting a target continues the same flow as the button.
|
|
55
|
+
|
|
56
|
+
Both the candidate picker and the mode popover support the keyboard: ↑↓ to move, Enter to confirm, Esc to cancel/back.
|
|
57
|
+
|
|
58
|
+
Rewinds can be repeated (each appends a marker to the log). A rewind cannot be undone through the plugin, but the withdrawn messages can be recovered by manually editing the session log. The file-restore action is not re-backed up.
|
|
57
59
|
|
|
58
60
|
## How it works
|
|
59
61
|
|
|
@@ -74,17 +76,16 @@ The plugin tracks the write-class tools — `write`, `edit`, `str_replace_editor
|
|
|
74
76
|
|
|
75
77
|
1. **Before-capture** at `tools/execute` (the around-dispatch stage): the target file is read; the resolved path + content are held in a pending map. This stage runs only after any pre-execute approval gate let the call through — an `ask` short-circuit (dsh-edit-approval) **cannot skip** the backup, and a denied call never records. If the read fails (e.g. a permission error), the change is simply not backed up — the plugin warns in the log but **does not block the write**.
|
|
76
78
|
2. **Disk commit** at `tools/post-execute`: the before-backup is written under the turn's anchor message seq (`~/.dsh/rewind-snapshots/<session>/<anchor seq>/<callId>.json`).
|
|
77
|
-
3. **Restore** (`/rewind @<seq> both`): every backup anchored at or after the target applies — modified files are written back to their **earliest** captured before-state, files created after the target are deleted,
|
|
79
|
+
3. **Restore** (`/rewind @<seq> both`): every backup anchored at or after the target applies once reconciled with the current disk — modified files are written back to their **earliest** captured before-state, files created after the target are deleted, files already matching the target state are left untouched (idempotent). Symbolic / hard links are skipped (they share an inode with another name; restoring through one would clobber both). Writes go through plain `node:fs`, independent of the fs service — under sandbox / remote backends, path resolution may be restricted.
|
|
78
80
|
4. A tool body that **throws** skips `tools/post-execute`; a `tools/result` safety net clears the pending capture so nothing leaks in memory.
|
|
79
81
|
|
|
80
82
|
Backups persist across host restarts, bounded to the newest 100 anchor groups per session.
|
|
81
83
|
|
|
82
84
|
## What it deliberately does NOT do
|
|
83
85
|
|
|
84
|
-
- **Whole-tree / git-first snapshots** — only write-class tool edits are backed up
|
|
86
|
+
- **Whole-tree / git-first snapshots** — only write-class tool edits plus external changes to already-tracked files are backed up; files never touched by a tool are not restored: the same limitation as Claude Code, which defers such rollbacks to the user's git.
|
|
85
87
|
- **Subagent edits** — not tracked (same as Claude Code): a subagent runs its own session, so its backups could never be restored by a rewind of the parent session.
|
|
86
88
|
- **Fork / branch rewind and `/compact`** — the harness already provides these ("branch in new chat", compact).
|
|
87
|
-
- **Keyboard shortcuts** (esc+esc rewind menu) — planned as a follow-up.
|
|
88
89
|
|
|
89
90
|
## Comparison with similar projects
|
|
90
91
|
|
|
@@ -116,7 +117,9 @@ Full instructions: [docs/troubleshooting.md](docs/troubleshooting.md)
|
|
|
116
117
|
|
|
117
118
|
## Security
|
|
118
119
|
|
|
119
|
-
This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history.
|
|
120
|
+
This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. Workspace files are written only when you choose "conversation and code"; backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials.
|
|
121
|
+
|
|
122
|
+
> **Note:** a rewind only hides messages from view — the exported session log (`/export`) still contains them, and this plugin cannot alter exports. To remove a conversation completely, delete its session file.
|
|
120
123
|
|
|
121
124
|
## Development
|
|
122
125
|
|
|
@@ -125,7 +128,7 @@ npm install # devDeps from the npm registry
|
|
|
125
128
|
npm run typecheck # tsc on both compilation surfaces (host + client)
|
|
126
129
|
npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
|
|
127
130
|
npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
128
|
-
node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end
|
|
131
|
+
node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end
|
|
129
132
|
```
|
|
130
133
|
|
|
131
134
|
`prepare` runs the full build, so git installs and `npm pack` / `npm publish` always produce a complete `lib/` and the `LICENSE`.
|
package/README.zh.md
CHANGED
|
@@ -29,12 +29,10 @@
|
|
|
29
29
|
</tr>
|
|
30
30
|
<tr>
|
|
31
31
|
<td align="center"><img src="assets/screenshots/impact-list.png" width="440" alt="影响清单"><br><sub>「回退对话和代码」影响清单</sub></td>
|
|
32
|
-
<td align="center"><img src="assets/screenshots/
|
|
32
|
+
<td align="center"><img src="assets/screenshots/rewind-candidates.png" width="440" alt="/rewind 候选面板"><br><sub>/rewind 候选面板</sub></td>
|
|
33
33
|
</tr>
|
|
34
34
|
</table>
|
|
35
35
|
|
|
36
|
-
在输入框手动输入 `/rewind`(含裸命令)会被**拦截**——提交时弹出临时提示,指向消息旁的 ↶ 按钮。
|
|
37
|
-
|
|
38
36
|
## 安装
|
|
39
37
|
|
|
40
38
|
```sh
|
|
@@ -53,7 +51,11 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
53
51
|
2. **点击它。** 目标即这条消息;小浮层提供两种模式(目标之后没有跟踪的变更时,「回退对话和代码」不显示)。
|
|
54
52
|
3. 回退以一条会话内命令执行;结果消息确认,被撤回消息的文本自动填入输入框,可编辑后重发。
|
|
55
53
|
|
|
56
|
-
|
|
54
|
+
**命令行入口**:输入裸 `/rewind` 回车打开候选面板,选择目标后流程与按钮一致。
|
|
55
|
+
|
|
56
|
+
候选面板与模式弹层均支持键盘操作:↑↓ 移动、Enter 确认、Esc 取消/返回。
|
|
57
|
+
|
|
58
|
+
回退可重复进行(每次追加一条标记到日志)。回退无法通过插件撤销,但可以手动编辑会话日志恢复。文件还原动作不再记录新备份。
|
|
57
59
|
|
|
58
60
|
## 原理
|
|
59
61
|
|
|
@@ -74,17 +76,16 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
74
76
|
|
|
75
77
|
1. **写前备份**(`tools/execute`,around-dispatch 阶段):读取目标文件,把解析后的路径与内容放入 pending 表。此阶段只在任何 pre-execute 审批门放行之后运行——审批 `ask` 短路(dsh-edit-approval)**无法跳过**备份,被拒绝的调用也不会记录。若读取失败(如权限错误),该次变更直接不入备份——插件只在日志中警告,**不会阻塞写操作**。
|
|
76
78
|
2. **落盘提交**(`tools/post-execute`):备份按当前轮**锚点消息 seq** 写入 `~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/<callId>.json`。
|
|
77
|
-
3. **还原**(`/rewind @<seq> both`):锚点 ≥
|
|
79
|
+
3. **还原**(`/rewind @<seq> both`):锚点 ≥ 目标的备份与磁盘**对账后生效**(改过的写回最早 before、新建的删除、一致的跳过——幂等)。符号/硬链接跳过(它们与另一名字共享 inode,透过一个还原会误伤两个)。写入走纯 `node:fs`,不经 fs 服务——sandbox / 远程 backend 下路径解析可能受限。
|
|
78
80
|
4. 工具体**抛异常**会跳过 `tools/post-execute`;`tools/result` 兜底清掉 pending,避免内存泄漏。
|
|
79
81
|
|
|
80
82
|
备份跨 host 重启持久化,每会话有界保留最近 100 组锚点。
|
|
81
83
|
|
|
82
84
|
## 明确不做的事
|
|
83
85
|
|
|
84
|
-
- **整树 / git-first
|
|
86
|
+
- **整树 / git-first 快照**——只备份写类工具编辑及已追踪文件的外部改动;从未被工具碰过的文件不还原:与 Claude Code 相同,此类回退交由用户 git 处理。
|
|
85
87
|
- **子代理(subagent)的编辑**——不跟踪(同 Claude Code):子代理运行在自己的会话里,其备份无法被父会话的回退还原。
|
|
86
88
|
- **fork / 分支回退与 `/compact`**——harness 已内置(「在新对话中分支」、compact)。
|
|
87
|
-
- **快捷键**(esc+esc 打开回退菜单)——规划中的后续项。
|
|
88
89
|
|
|
89
90
|
## 与同类项目对比
|
|
90
91
|
|
|
@@ -116,7 +117,9 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
116
117
|
|
|
117
118
|
## 安全
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
|
|
121
|
+
|
|
122
|
+
> **注意:** 回退只是把消息从视图中隐藏——导出的会话日志(`/export`)仍包含撤回前的内容,本插件无法改动导出。要彻底删除对话,请删除对应的会话文件。
|
|
120
123
|
|
|
121
124
|
## 开发
|
|
122
125
|
|
|
@@ -125,7 +128,7 @@ npm install # devDeps 来自 npm registry
|
|
|
125
128
|
npm run typecheck # tsc 双面编译(host + client)
|
|
126
129
|
npm test # vitest:rewind / snapshot / hidden / session-cwd / 集成
|
|
127
130
|
npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
|
|
128
|
-
node scripts/verify-host.mjs #
|
|
131
|
+
node scripts/verify-host.mjs # 端到端验证构建产物
|
|
129
132
|
```
|
|
130
133
|
|
|
131
134
|
`prepare` 执行完整构建,所以 git 安装与 `npm pack` / `npm publish` 总会产出完整的 `lib/` 与 `LICENSE`。
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -32,14 +32,10 @@ __export(index_exports, {
|
|
|
32
32
|
});
|
|
33
33
|
module.exports = __toCommonJS(index_exports);
|
|
34
34
|
|
|
35
|
-
// src/client/portals.tsx
|
|
36
|
-
var import_react = require("react");
|
|
37
|
-
var import_react_dom = require("react-dom");
|
|
38
|
-
|
|
39
35
|
// src/client/hidden.ts
|
|
40
|
-
function
|
|
41
|
-
if (
|
|
42
|
-
const match =
|
|
36
|
+
function targetSeqOfArgs(args) {
|
|
37
|
+
if (args === void 0 || args === null) return void 0;
|
|
38
|
+
const match = args.match(/@(\d+)/);
|
|
43
39
|
return match !== null ? Number(match[1]) : void 0;
|
|
44
40
|
}
|
|
45
41
|
function isExecutedRewindCommand(node, seq) {
|
|
@@ -52,7 +48,7 @@ function hasFileImpact(text) {
|
|
|
52
48
|
if (text === void 0) return true;
|
|
53
49
|
const match = text.match(/impact=(\d+)/);
|
|
54
50
|
if (match !== null) return Number(match[1]) > 0;
|
|
55
|
-
return
|
|
51
|
+
return false;
|
|
56
52
|
}
|
|
57
53
|
function isPreviewCommand(command) {
|
|
58
54
|
return (command.args ?? "").includes("preview");
|
|
@@ -73,7 +69,7 @@ function hiddenSeqsOf(snap) {
|
|
|
73
69
|
const marker = command.outcome.sourceEventSeq;
|
|
74
70
|
if (marker === void 0) continue;
|
|
75
71
|
hidden.add(command.seq);
|
|
76
|
-
const target =
|
|
72
|
+
const target = targetSeqOfArgs(command.args);
|
|
77
73
|
if (target !== void 0) {
|
|
78
74
|
spans.push({ start: target, end: marker });
|
|
79
75
|
}
|
|
@@ -89,6 +85,48 @@ function hiddenSeqsOf(snap) {
|
|
|
89
85
|
return hidden;
|
|
90
86
|
}
|
|
91
87
|
|
|
88
|
+
// src/client/candidates.ts
|
|
89
|
+
var PREVIEW_CHARS = 80;
|
|
90
|
+
function messagePreviewOf(message) {
|
|
91
|
+
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
92
|
+
return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
|
|
93
|
+
}
|
|
94
|
+
function formatCandidateTime(time) {
|
|
95
|
+
const d = new Date(time);
|
|
96
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
97
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
98
|
+
return `${hh}:${mm}`;
|
|
99
|
+
}
|
|
100
|
+
function rewindCandidatesOf(snap, hidden, limit = 10) {
|
|
101
|
+
const candidates = [];
|
|
102
|
+
for (let i = snap.order.length - 1; i >= 0 && candidates.length < limit; i--) {
|
|
103
|
+
const key = snap.order[i];
|
|
104
|
+
if (key === void 0) continue;
|
|
105
|
+
const node = snap.nodes.get(key);
|
|
106
|
+
if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
|
|
107
|
+
if (hidden.has(node.anchorSeq ?? node.data.seq)) continue;
|
|
108
|
+
candidates.push({
|
|
109
|
+
seq: node.data.seq,
|
|
110
|
+
time: node.data.time,
|
|
111
|
+
preview: messagePreviewOf(node.data)
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return candidates;
|
|
115
|
+
}
|
|
116
|
+
function rewindCandidatesOfChat(snap) {
|
|
117
|
+
return rewindCandidatesOf(snap, hiddenSeqsOf(snap));
|
|
118
|
+
}
|
|
119
|
+
function rewindOptionsOf(snap, t) {
|
|
120
|
+
return rewindCandidatesOfChat(snap).map((candidate) => ({
|
|
121
|
+
id: String(candidate.seq),
|
|
122
|
+
label: candidate.preview || t("popover.noText"),
|
|
123
|
+
detail: formatCandidateTime(candidate.time)
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
function candidateBySeq(snap, seq) {
|
|
127
|
+
return rewindCandidatesOfChat(snap).find((candidate) => candidate.seq === seq);
|
|
128
|
+
}
|
|
129
|
+
|
|
92
130
|
// src/client/styles.ts
|
|
93
131
|
var CLASS = {
|
|
94
132
|
button: "dsh-rewind-btn",
|
|
@@ -266,8 +304,14 @@ function formatTarget(t, seq, time, preview) {
|
|
|
266
304
|
const previewText = preview.length > 0 ? preview : t("popover.noText");
|
|
267
305
|
return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
|
|
268
306
|
}
|
|
269
|
-
function
|
|
270
|
-
|
|
307
|
+
function parseImpactList(text) {
|
|
308
|
+
const restores = [];
|
|
309
|
+
const deletes = [];
|
|
310
|
+
for (const line of text.split("\n")) {
|
|
311
|
+
if (line.startsWith("restore:")) restores.push(line.slice("restore:".length));
|
|
312
|
+
else if (line.startsWith("delete:")) deletes.push(line.slice("delete:".length));
|
|
313
|
+
}
|
|
314
|
+
return { restores, deletes };
|
|
271
315
|
}
|
|
272
316
|
function findCommand(snapshot, match) {
|
|
273
317
|
let found;
|
|
@@ -339,6 +383,20 @@ function modeOption(label, hint, onClick) {
|
|
|
339
383
|
button.addEventListener("click", onClick);
|
|
340
384
|
return button;
|
|
341
385
|
}
|
|
386
|
+
function focusableButtons(root) {
|
|
387
|
+
return Array.from(root.querySelectorAll("button")).filter((button) => !button.disabled && !button.classList.contains(CLASS.popoverGhost));
|
|
388
|
+
}
|
|
389
|
+
function focusFirst(root) {
|
|
390
|
+
focusableButtons(root)[0]?.focus();
|
|
391
|
+
}
|
|
392
|
+
function moveFocus(root, dir) {
|
|
393
|
+
const buttons = focusableButtons(root);
|
|
394
|
+
if (buttons.length === 0) return;
|
|
395
|
+
const active = document.activeElement;
|
|
396
|
+
const index = active instanceof HTMLButtonElement ? buttons.indexOf(active) : -1;
|
|
397
|
+
const next = index === -1 ? dir === 1 ? 0 : buttons.length - 1 : (index + dir + buttons.length) % buttons.length;
|
|
398
|
+
buttons[next]?.focus();
|
|
399
|
+
}
|
|
342
400
|
function renderImpactStep(root, opts, back, cached) {
|
|
343
401
|
const { session, seq, t } = opts;
|
|
344
402
|
const impact = el("div", CLASS.popoverImpact, t("popover.impact.loading"));
|
|
@@ -356,6 +414,7 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
356
414
|
confirm.disabled = true;
|
|
357
415
|
actions.append(confirm);
|
|
358
416
|
root.replaceChildren(impact, actions);
|
|
417
|
+
focusFirst(root);
|
|
359
418
|
void (async () => {
|
|
360
419
|
const outcome = cached ?? await previewImpact(session, seq);
|
|
361
420
|
if (outcome === null) {
|
|
@@ -366,8 +425,22 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
366
425
|
impact.textContent = t("popover.impact.failed", { message: outcome.text ?? "unknown error" });
|
|
367
426
|
return;
|
|
368
427
|
}
|
|
369
|
-
|
|
428
|
+
if (outcome.text === void 0) {
|
|
429
|
+
impact.textContent = t("popover.impact.none");
|
|
430
|
+
} else {
|
|
431
|
+
const { restores, deletes } = parseImpactList(outcome.text);
|
|
432
|
+
if (restores.length === 0 && deletes.length === 0) {
|
|
433
|
+
impact.textContent = t("popover.impact.none");
|
|
434
|
+
} else {
|
|
435
|
+
const lines = [
|
|
436
|
+
...restores.map((path) => t("popover.impact.restore", { path })),
|
|
437
|
+
...deletes.map((path) => t("popover.impact.delete", { path }))
|
|
438
|
+
];
|
|
439
|
+
impact.textContent = lines.join("\n");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
370
442
|
confirm.disabled = false;
|
|
443
|
+
confirm.focus();
|
|
371
444
|
confirm.addEventListener("click", () => {
|
|
372
445
|
closePopover();
|
|
373
446
|
opts.onRewind("both");
|
|
@@ -384,7 +457,9 @@ function openPopover(opts) {
|
|
|
384
457
|
root.setAttribute("aria-label", t("popover.title"));
|
|
385
458
|
let bothState = { state: "loading" };
|
|
386
459
|
let impactOutcome = null;
|
|
460
|
+
let step = "modes";
|
|
387
461
|
const renderModes = () => {
|
|
462
|
+
step = "modes";
|
|
388
463
|
const children = [
|
|
389
464
|
el("div", CLASS.popoverTitle, t("popover.title")),
|
|
390
465
|
el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
|
|
@@ -399,9 +474,7 @@ function openPopover(opts) {
|
|
|
399
474
|
const option = modeOption(
|
|
400
475
|
t("popover.both"),
|
|
401
476
|
bothState.state === "loading" ? t("popover.checking") : t("popover.both.hint"),
|
|
402
|
-
|
|
403
|
-
renderImpactStep(root, opts, renderModes, impactOutcome);
|
|
404
|
-
}
|
|
477
|
+
renderImpact
|
|
405
478
|
);
|
|
406
479
|
if (bothState.state === "loading") option.disabled = true;
|
|
407
480
|
children.push(option);
|
|
@@ -415,6 +488,11 @@ function openPopover(opts) {
|
|
|
415
488
|
actions.append(cancel);
|
|
416
489
|
children.push(actions);
|
|
417
490
|
root.replaceChildren(...children);
|
|
491
|
+
focusFirst(root);
|
|
492
|
+
};
|
|
493
|
+
const renderImpact = () => {
|
|
494
|
+
step = "impact";
|
|
495
|
+
renderImpactStep(root, opts, renderModes, impactOutcome);
|
|
418
496
|
};
|
|
419
497
|
const position = () => {
|
|
420
498
|
const rect = anchor.getBoundingClientRect();
|
|
@@ -427,6 +505,7 @@ function openPopover(opts) {
|
|
|
427
505
|
renderModes();
|
|
428
506
|
document.body.append(root);
|
|
429
507
|
position();
|
|
508
|
+
focusFirst(root);
|
|
430
509
|
void (async () => {
|
|
431
510
|
const outcome = await previewImpact(session, seq);
|
|
432
511
|
impactOutcome = outcome;
|
|
@@ -447,25 +526,40 @@ function openPopover(opts) {
|
|
|
447
526
|
closePopover();
|
|
448
527
|
};
|
|
449
528
|
const onKeyDown = (event) => {
|
|
450
|
-
if (event.key === "
|
|
529
|
+
if (event.key === "ArrowDown") {
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
event.stopPropagation();
|
|
532
|
+
moveFocus(root, 1);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (event.key === "ArrowUp") {
|
|
536
|
+
event.preventDefault();
|
|
537
|
+
event.stopPropagation();
|
|
538
|
+
moveFocus(root, -1);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (event.key === "Escape") {
|
|
542
|
+
event.preventDefault();
|
|
543
|
+
event.stopPropagation();
|
|
544
|
+
if (step === "impact") renderModes();
|
|
545
|
+
else closePopover();
|
|
546
|
+
}
|
|
451
547
|
};
|
|
452
548
|
const deferred = setTimeout(() => {
|
|
453
549
|
document.addEventListener("pointerdown", onPointerDown);
|
|
454
|
-
document.addEventListener("keydown", onKeyDown);
|
|
550
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
455
551
|
}, 0);
|
|
456
552
|
disposeOutside = () => {
|
|
457
553
|
clearTimeout(deferred);
|
|
458
554
|
document.removeEventListener("pointerdown", onPointerDown);
|
|
459
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
555
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
460
556
|
};
|
|
461
557
|
}
|
|
462
558
|
|
|
463
559
|
// src/client/portals.tsx
|
|
560
|
+
var import_react = require("react");
|
|
561
|
+
var import_react_dom = require("react-dom");
|
|
464
562
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
465
|
-
function messagePreviewOf(node) {
|
|
466
|
-
const text = node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
467
|
-
return text.length <= 80 ? text : `${text.slice(0, 79)}\u2026`;
|
|
468
|
-
}
|
|
469
563
|
function userTextAt(session, seq) {
|
|
470
564
|
const snap = session.getSnapshot();
|
|
471
565
|
for (const key of snap.chat.order) {
|
|
@@ -663,9 +757,11 @@ var zh = {
|
|
|
663
757
|
"popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
|
|
664
758
|
"popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
|
|
665
759
|
"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",
|
|
760
|
+
"popover.impact.restore": "\u8FD8\u539F {path}",
|
|
761
|
+
"popover.impact.delete": "\u5220\u9664 {path}",
|
|
666
762
|
"popover.confirm": "\u786E\u8BA4\u56DE\u9000",
|
|
667
763
|
"popover.back": "\u8FD4\u56DE",
|
|
668
|
-
"guard.hint": "/rewind \
|
|
764
|
+
"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"
|
|
669
765
|
};
|
|
670
766
|
var en = {
|
|
671
767
|
"button.aria": "Rewind to this message",
|
|
@@ -682,14 +778,16 @@ var en = {
|
|
|
682
778
|
"popover.impact.loading": "Fetching impact list\u2026",
|
|
683
779
|
"popover.impact.failed": "Could not fetch the impact list: {message}",
|
|
684
780
|
"popover.impact.none": "No tracked file changes after the target; nothing to restore.",
|
|
781
|
+
"popover.impact.restore": "Restore {path}",
|
|
782
|
+
"popover.impact.delete": "Delete {path}",
|
|
685
783
|
"popover.confirm": "Confirm rewind",
|
|
686
784
|
"popover.back": "Back",
|
|
687
|
-
"guard.hint": "/rewind
|
|
785
|
+
"guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
|
|
688
786
|
};
|
|
689
787
|
|
|
690
788
|
// src/client/index.ts
|
|
691
789
|
var name = "dsh-rewind";
|
|
692
|
-
var inject = ["slots", "sessions", "locale"];
|
|
790
|
+
var inject = ["slots", "sessions", "locale", "commandUi"];
|
|
693
791
|
var NS = "rewind";
|
|
694
792
|
var HEADER_ACTIONS_SLOT = "conversation.session.header.actions";
|
|
695
793
|
var COMPOSER_SELECTOR2 = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
@@ -715,11 +813,58 @@ function apply(ctx) {
|
|
|
715
813
|
},
|
|
716
814
|
createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
|
|
717
815
|
));
|
|
718
|
-
const
|
|
816
|
+
const commandUi = ctx.get("commandUi");
|
|
817
|
+
const chatOf = (sessionId) => {
|
|
818
|
+
if (sessionId === void 0) return void 0;
|
|
819
|
+
const face = sessionOf(sessionId);
|
|
820
|
+
return face === void 0 ? void 0 : face.getSnapshot().chat;
|
|
821
|
+
};
|
|
822
|
+
const hasCandidates = (sessionId) => {
|
|
823
|
+
const chat = chatOf(sessionId);
|
|
824
|
+
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
825
|
+
};
|
|
826
|
+
const composerAnchor = () => {
|
|
827
|
+
const textarea = composerTextarea();
|
|
828
|
+
const card = textarea?.closest("[data-composer-card]");
|
|
829
|
+
return card ?? textarea ?? document.body;
|
|
830
|
+
};
|
|
831
|
+
yield commandUi.decorate({
|
|
832
|
+
name: "rewind",
|
|
833
|
+
// The picker exists exactly while the surface has a reachable user
|
|
834
|
+
// message: a fresh session (no candidates) falls through to the host
|
|
835
|
+
// command, which fails with "no user messages" — matching the harness's
|
|
836
|
+
// own decoration convention (see ui-permission-presets).
|
|
837
|
+
available: (session) => hasCandidates(session.sessionId),
|
|
838
|
+
ui: {
|
|
839
|
+
kind: "popupSelect",
|
|
840
|
+
options: (session) => {
|
|
841
|
+
const chat = chatOf(session.sessionId);
|
|
842
|
+
return Promise.resolve(chat === void 0 ? [] : rewindOptionsOf(chat, t));
|
|
843
|
+
},
|
|
844
|
+
onSelect: (option, session) => {
|
|
845
|
+
const face = sessionOf(session.sessionId);
|
|
846
|
+
const chat = chatOf(session.sessionId);
|
|
847
|
+
const candidate = chat !== void 0 ? candidateBySeq(chat, Number(option.id)) : void 0;
|
|
848
|
+
if (face === void 0 || candidate === void 0) return;
|
|
849
|
+
openPopover({
|
|
850
|
+
session: face,
|
|
851
|
+
seq: candidate.seq,
|
|
852
|
+
time: candidate.time,
|
|
853
|
+
preview: candidate.preview,
|
|
854
|
+
anchor: composerAnchor(),
|
|
855
|
+
t,
|
|
856
|
+
onRewind: (mode) => {
|
|
857
|
+
void runRewindAndFill(face, candidate.seq, mode, currentSessionId);
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
const PARAM_REWIND = /^\s*\/rewind\s+\S+/i;
|
|
719
864
|
const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
|
|
720
|
-
const
|
|
865
|
+
const hasParamRewindDraft = () => {
|
|
721
866
|
const textarea = composerTextarea();
|
|
722
|
-
return textarea !== null &&
|
|
867
|
+
return textarea !== null && PARAM_REWIND.test(textarea.value);
|
|
723
868
|
};
|
|
724
869
|
let guardHintEl = null;
|
|
725
870
|
let guardHintTimer;
|
|
@@ -744,15 +889,18 @@ function apply(ctx) {
|
|
|
744
889
|
guardHintTimer = void 0;
|
|
745
890
|
}, 3200);
|
|
746
891
|
};
|
|
747
|
-
const
|
|
748
|
-
if (
|
|
749
|
-
if (!hasBlockedRewindDraft()) return;
|
|
892
|
+
const onParamRewindSubmit = (event) => {
|
|
893
|
+
if (!hasParamRewindDraft()) return;
|
|
750
894
|
event.preventDefault();
|
|
751
895
|
event.stopPropagation();
|
|
752
896
|
showGuardHint();
|
|
753
897
|
};
|
|
898
|
+
const onKeyDownGuard = (event) => {
|
|
899
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
900
|
+
onParamRewindSubmit(event);
|
|
901
|
+
};
|
|
754
902
|
const onClickGuard = (event) => {
|
|
755
|
-
if (event.button !== 0 || !
|
|
903
|
+
if (event.button !== 0 || !hasParamRewindDraft()) return;
|
|
756
904
|
const target = event.target;
|
|
757
905
|
if (!(target instanceof Element)) return;
|
|
758
906
|
const button = target.closest("button");
|
|
@@ -762,9 +910,7 @@ function apply(ctx) {
|
|
|
762
910
|
const all = card.querySelectorAll("button");
|
|
763
911
|
if (all[all.length - 1] !== button) return;
|
|
764
912
|
if (button.querySelector("rect") !== null) return;
|
|
765
|
-
event
|
|
766
|
-
event.stopPropagation();
|
|
767
|
-
showGuardHint();
|
|
913
|
+
onParamRewindSubmit(event);
|
|
768
914
|
};
|
|
769
915
|
document.addEventListener("keydown", onKeyDownGuard, true);
|
|
770
916
|
document.addEventListener("click", onClickGuard, true);
|