dsh-rewind-plugin 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -14
- package/README.zh.md +9 -14
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/docs/harness-reference.md +1 -1
- package/lib/client.js +152 -27
- package/lib/types/client/candidates.d.ts +76 -0
- package/lib/types/client/index.d.ts +17 -9
- package/lib/types/client/popover.d.ts +7 -0
- package/lib/types/client/portals.d.ts +22 -0
- package/package.json +8 -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
|
|
@@ -45,14 +43,7 @@ Restart `dsh web` (`--profile web`) after installing.
|
|
|
45
43
|
|
|
46
44
|
> ⚠️ The npm name `dsh-rewind` belongs to another author's package — install with `dsh-rewind-plugin`.
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```sh
|
|
51
|
-
dsh plugin --profile web add /path/to/dsh-rewind # local checkout
|
|
52
|
-
dsh plugin --profile web add github:SiriLee/dsh-rewind#<sha> # pinned commit
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
A git install fails on first run: pnpm blocks git dependencies from running build scripts. Follow the CLI hint to add an `allowBuilds` key to the profile's `pnpm-workspace.yaml`, then retry — pnpm runs the plugin's `prepare` (full build) and installs it.
|
|
46
|
+
For contributors: install from a local checkout or a pinned commit — `dsh plugin --profile web add /path/to/dsh-rewind` or `dsh plugin --profile web add github:SiriLee/dsh-rewind#<sha>`. A git install fails on first run until you add an `allowBuilds` key to the profile's `pnpm-workspace.yaml` (pnpm blocks git dependencies from running build scripts); after that it runs the plugin's `prepare` and installs it.
|
|
56
47
|
|
|
57
48
|
## Usage
|
|
58
49
|
|
|
@@ -60,7 +51,9 @@ A git install fails on first run: pnpm blocks git dependencies from running buil
|
|
|
60
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).
|
|
61
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.
|
|
62
53
|
|
|
63
|
-
|
|
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
|
+
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.
|
|
64
57
|
|
|
65
58
|
## How it works
|
|
66
59
|
|
|
@@ -70,7 +63,7 @@ The plugin appends an **empty-content marker** `assistant/message` into the sess
|
|
|
70
63
|
|
|
71
64
|
- The marker carries `sourceEventSeqs` covering every shadowed node, and `Session.append`'s surface rules validate the cut (a contiguous range on the current surface).
|
|
72
65
|
- Because the marker is **empty**, the harness derives it to `null` — it never enters the model context and never renders as conversation content. Agent and user both see the conversation exactly as it was at the target.
|
|
73
|
-
- The marker's **turn number reuses the last started turn** (`markerTurnOf`), never `lastTurn + 1`: the harness numbers its next real turn exactly `last turn/start + 1`, so a `maxTurn + 1` marker would sit *before* that `turn/start` — the client conversation builder rejects the ordering (`…turn-tail… received an update before its start Match`)
|
|
66
|
+
- The marker's **turn number reuses the last started turn** (`markerTurnOf`), never `lastTurn + 1`: the harness numbers its next real turn exactly `last turn/start + 1`, so a `maxTurn + 1` marker would sit *before* that `turn/start` — the client conversation builder rejects the ordering (`…turn-tail… received an update before its start Match`) and history load fails. Reusing an already-consumed turn makes the marker a harmless trailing update on the previous completed turn's tail — it can never collide with a future turn.
|
|
74
67
|
- The append-only log is **untouched** — every withdrawn event stays in the audit trail; only the model-visible surface is cut, so the next request derives its context from the target onward.
|
|
75
68
|
|
|
76
69
|
A running turn (LLM thinking / streaming) is force-stopped first (`cancel({ kind: 'user' })`) and the rewind waits for quiescence; if it can't stop, the rewind is aborted with an error.
|
|
@@ -125,12 +118,14 @@ Full instructions: [docs/troubleshooting.md](docs/troubleshooting.md)
|
|
|
125
118
|
|
|
126
119
|
This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. File writes happen only when you choose "conversation and code" — before-backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials.
|
|
127
120
|
|
|
121
|
+
> **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.
|
|
122
|
+
|
|
128
123
|
## Development
|
|
129
124
|
|
|
130
125
|
```sh
|
|
131
126
|
npm install # devDeps from the npm registry
|
|
132
127
|
npm run typecheck # tsc on both compilation surfaces (host + client)
|
|
133
|
-
npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
|
|
128
|
+
npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
|
|
134
129
|
npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
135
130
|
node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end (18 checks)
|
|
136
131
|
```
|
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
|
|
@@ -45,14 +43,7 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
45
43
|
|
|
46
44
|
> ⚠️ npm 上的 `dsh-rewind` 属于其他作者,请用 `dsh-rewind-plugin` 安装。
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```sh
|
|
51
|
-
dsh plugin --profile web add /path/to/dsh-rewind # 本地 checkout
|
|
52
|
-
dsh plugin --profile web add github:SiriLee/dsh-rewind#<sha> # pin commit
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
git 安装首次会失败:pnpm 默认禁止 git 依赖执行构建脚本。按 CLI 提示在 profile 的 `pnpm-workspace.yaml` 中加 `allowBuilds` 后重试——pnpm 会执行插件的 `prepare`(完整构建)并装入 profile。
|
|
46
|
+
给贡献者:可从本地 checkout 或 pin 的 commit 安装——`dsh plugin --profile web add /path/to/dsh-rewind` 或 `dsh plugin --profile web add github:SiriLee/dsh-rewind#<sha>`。git 安装首次会失败:pnpm 默认禁止 git 依赖执行构建脚本,需先在 profile 的 `pnpm-workspace.yaml` 加 `allowBuilds`;之后 pnpm 会执行插件的 `prepare`(完整构建)并装入 profile。
|
|
56
47
|
|
|
57
48
|
## 使用
|
|
58
49
|
|
|
@@ -60,7 +51,9 @@ git 安装首次会失败:pnpm 默认禁止 git 依赖执行构建脚本。按
|
|
|
60
51
|
2. **点击它。** 目标即这条消息;小浮层提供两种模式(目标之后没有跟踪的变更时,「回退对话和代码」不显示)。
|
|
61
52
|
3. 回退以一条会话内命令执行;结果消息确认,被撤回消息的文本自动填入输入框,可编辑后重发。
|
|
62
53
|
|
|
63
|
-
|
|
54
|
+
**命令行入口**:输入裸 `/rewind` 回车打开候选面板,选择目标后流程与按钮一致。
|
|
55
|
+
|
|
56
|
+
回退可重复进行(每次追加一条标记到日志)。回退无法通过插件撤销,但可以手动编辑会话日志恢复。文件还原动作不再记录新备份。
|
|
64
57
|
|
|
65
58
|
## 原理
|
|
66
59
|
|
|
@@ -70,7 +63,7 @@ git 安装首次会失败:pnpm 默认禁止 git 依赖执行构建脚本。按
|
|
|
70
63
|
|
|
71
64
|
- 标记携带 `sourceEventSeqs` 覆盖所有被遮蔽节点,`Session.append` 的 surface 规则校验切割合法性(仅限当前 surface 上的连续区间)。
|
|
72
65
|
- 因为标记**内容为空**,harness 会将其派生为 `null`——永不进入模型上下文、也永不渲染成对话内容。agent 与用户看到的对话都回到目标消息当时的样子。
|
|
73
|
-
- 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match`
|
|
66
|
+
- 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match` 拒绝重放——历史加载失败、整个对话从界面消失。复用已消费的 turn 号则标记只是上一个已完成回合尾部的一次无害追加,永不与新回合冲突。
|
|
74
67
|
- append-only 日志**不被改写**——审计轨迹完整保留每条被撤回的事件,只有模型可见的 surface 被剪掉,下一条请求从目标消息起派生上下文。
|
|
75
68
|
|
|
76
69
|
若 agent 正在运行(LLM 思考/流式输出),会先强制停止(`cancel({ kind: 'user' })`)并等待 quiescence 再回退;停不下来则中止并报错。
|
|
@@ -125,12 +118,14 @@ git 安装首次会失败:pnpm 默认禁止 git 依赖执行构建脚本。按
|
|
|
125
118
|
|
|
126
119
|
本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。文件写入仅在你选择「回退对话和代码」时发生,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
|
|
127
120
|
|
|
121
|
+
> **注意:** 回退只是把消息从视图中隐藏——导出的会话日志(`/export`)仍包含撤回前的内容,本插件无法改动导出。要彻底删除对话,请删除对应的会话文件。
|
|
122
|
+
|
|
128
123
|
## 开发
|
|
129
124
|
|
|
130
125
|
```sh
|
|
131
126
|
npm install # devDeps 来自 npm registry
|
|
132
127
|
npm run typecheck # tsc 双面编译(host + client)
|
|
133
|
-
npm test # vitest:rewind / snapshot / hidden / session-cwd /
|
|
128
|
+
npm test # vitest:rewind / snapshot / hidden / session-cwd / 集成
|
|
134
129
|
npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
|
|
135
130
|
node scripts/verify-host.mjs # 端到端验证构建产物(18 项检查)
|
|
136
131
|
```
|
|
Binary file
|
|
@@ -48,7 +48,7 @@ src/client/styles.ts injected styles (dsh design tokens)
|
|
|
48
48
|
scripts/build.mjs esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
49
49
|
scripts/verify-host.mjs end-to-end host verification (18 checks)
|
|
50
50
|
scripts/repair-markers.mjs offline marker-turn repair tool (ships as `dsh-rewind-repair`)
|
|
51
|
-
tests/ vitest suites (rewind / snapshot / hidden / session-cwd / integration
|
|
51
|
+
tests/ vitest suites (rewind / snapshot / hidden / session-cwd / integration)
|
|
52
52
|
docs/ maintainer docs: harness reference, troubleshooting, release steps
|
|
53
53
|
assets/screenshots/ UI screenshots
|
|
54
54
|
cordis.patch.yml bundle patch (mounts the dual-face plugin row)
|
package/lib/client.js
CHANGED
|
@@ -32,10 +32,6 @@ __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
36
|
function targetOfOutcome(text) {
|
|
41
37
|
if (text === void 0) return void 0;
|
|
@@ -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",
|
|
@@ -339,6 +377,20 @@ function modeOption(label, hint, onClick) {
|
|
|
339
377
|
button.addEventListener("click", onClick);
|
|
340
378
|
return button;
|
|
341
379
|
}
|
|
380
|
+
function focusableButtons(root) {
|
|
381
|
+
return Array.from(root.querySelectorAll("button")).filter((button) => !button.disabled && !button.classList.contains(CLASS.popoverGhost));
|
|
382
|
+
}
|
|
383
|
+
function focusFirst(root) {
|
|
384
|
+
focusableButtons(root)[0]?.focus();
|
|
385
|
+
}
|
|
386
|
+
function moveFocus(root, dir) {
|
|
387
|
+
const buttons = focusableButtons(root);
|
|
388
|
+
if (buttons.length === 0) return;
|
|
389
|
+
const active = document.activeElement;
|
|
390
|
+
const index = active instanceof HTMLButtonElement ? buttons.indexOf(active) : -1;
|
|
391
|
+
const next = index === -1 ? dir === 1 ? 0 : buttons.length - 1 : (index + dir + buttons.length) % buttons.length;
|
|
392
|
+
buttons[next]?.focus();
|
|
393
|
+
}
|
|
342
394
|
function renderImpactStep(root, opts, back, cached) {
|
|
343
395
|
const { session, seq, t } = opts;
|
|
344
396
|
const impact = el("div", CLASS.popoverImpact, t("popover.impact.loading"));
|
|
@@ -356,6 +408,7 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
356
408
|
confirm.disabled = true;
|
|
357
409
|
actions.append(confirm);
|
|
358
410
|
root.replaceChildren(impact, actions);
|
|
411
|
+
focusFirst(root);
|
|
359
412
|
void (async () => {
|
|
360
413
|
const outcome = cached ?? await previewImpact(session, seq);
|
|
361
414
|
if (outcome === null) {
|
|
@@ -368,6 +421,7 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
368
421
|
}
|
|
369
422
|
impact.textContent = outcome.text === void 0 ? t("popover.impact.none") : stripImpactToken(outcome.text);
|
|
370
423
|
confirm.disabled = false;
|
|
424
|
+
confirm.focus();
|
|
371
425
|
confirm.addEventListener("click", () => {
|
|
372
426
|
closePopover();
|
|
373
427
|
opts.onRewind("both");
|
|
@@ -384,7 +438,9 @@ function openPopover(opts) {
|
|
|
384
438
|
root.setAttribute("aria-label", t("popover.title"));
|
|
385
439
|
let bothState = { state: "loading" };
|
|
386
440
|
let impactOutcome = null;
|
|
441
|
+
let step = "modes";
|
|
387
442
|
const renderModes = () => {
|
|
443
|
+
step = "modes";
|
|
388
444
|
const children = [
|
|
389
445
|
el("div", CLASS.popoverTitle, t("popover.title")),
|
|
390
446
|
el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
|
|
@@ -399,9 +455,7 @@ function openPopover(opts) {
|
|
|
399
455
|
const option = modeOption(
|
|
400
456
|
t("popover.both"),
|
|
401
457
|
bothState.state === "loading" ? t("popover.checking") : t("popover.both.hint"),
|
|
402
|
-
|
|
403
|
-
renderImpactStep(root, opts, renderModes, impactOutcome);
|
|
404
|
-
}
|
|
458
|
+
renderImpact
|
|
405
459
|
);
|
|
406
460
|
if (bothState.state === "loading") option.disabled = true;
|
|
407
461
|
children.push(option);
|
|
@@ -415,6 +469,11 @@ function openPopover(opts) {
|
|
|
415
469
|
actions.append(cancel);
|
|
416
470
|
children.push(actions);
|
|
417
471
|
root.replaceChildren(...children);
|
|
472
|
+
focusFirst(root);
|
|
473
|
+
};
|
|
474
|
+
const renderImpact = () => {
|
|
475
|
+
step = "impact";
|
|
476
|
+
renderImpactStep(root, opts, renderModes, impactOutcome);
|
|
418
477
|
};
|
|
419
478
|
const position = () => {
|
|
420
479
|
const rect = anchor.getBoundingClientRect();
|
|
@@ -427,6 +486,7 @@ function openPopover(opts) {
|
|
|
427
486
|
renderModes();
|
|
428
487
|
document.body.append(root);
|
|
429
488
|
position();
|
|
489
|
+
focusFirst(root);
|
|
430
490
|
void (async () => {
|
|
431
491
|
const outcome = await previewImpact(session, seq);
|
|
432
492
|
impactOutcome = outcome;
|
|
@@ -447,25 +507,40 @@ function openPopover(opts) {
|
|
|
447
507
|
closePopover();
|
|
448
508
|
};
|
|
449
509
|
const onKeyDown = (event) => {
|
|
450
|
-
if (event.key === "
|
|
510
|
+
if (event.key === "ArrowDown") {
|
|
511
|
+
event.preventDefault();
|
|
512
|
+
event.stopPropagation();
|
|
513
|
+
moveFocus(root, 1);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (event.key === "ArrowUp") {
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
event.stopPropagation();
|
|
519
|
+
moveFocus(root, -1);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (event.key === "Escape") {
|
|
523
|
+
event.preventDefault();
|
|
524
|
+
event.stopPropagation();
|
|
525
|
+
if (step === "impact") renderModes();
|
|
526
|
+
else closePopover();
|
|
527
|
+
}
|
|
451
528
|
};
|
|
452
529
|
const deferred = setTimeout(() => {
|
|
453
530
|
document.addEventListener("pointerdown", onPointerDown);
|
|
454
|
-
document.addEventListener("keydown", onKeyDown);
|
|
531
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
455
532
|
}, 0);
|
|
456
533
|
disposeOutside = () => {
|
|
457
534
|
clearTimeout(deferred);
|
|
458
535
|
document.removeEventListener("pointerdown", onPointerDown);
|
|
459
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
536
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
460
537
|
};
|
|
461
538
|
}
|
|
462
539
|
|
|
463
540
|
// src/client/portals.tsx
|
|
541
|
+
var import_react = require("react");
|
|
542
|
+
var import_react_dom = require("react-dom");
|
|
464
543
|
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
544
|
function userTextAt(session, seq) {
|
|
470
545
|
const snap = session.getSnapshot();
|
|
471
546
|
for (const key of snap.chat.order) {
|
|
@@ -558,10 +633,12 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
558
633
|
const anchor = key !== void 0 ? chat.nodes.get(key)?.anchorSeq : void 0;
|
|
559
634
|
if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
|
|
560
635
|
seat.style.display = "none";
|
|
636
|
+
seat.dataset.dshRewindHidden = "true";
|
|
561
637
|
hidden.current.add(seat);
|
|
562
638
|
hiddenCount += 1;
|
|
563
639
|
} else if (hidden.current.has(seat)) {
|
|
564
640
|
seat.style.display = "";
|
|
641
|
+
delete seat.dataset.dshRewindHidden;
|
|
565
642
|
hidden.current.delete(seat);
|
|
566
643
|
}
|
|
567
644
|
}
|
|
@@ -663,7 +740,7 @@ var zh = {
|
|
|
663
740
|
"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",
|
|
664
741
|
"popover.confirm": "\u786E\u8BA4\u56DE\u9000",
|
|
665
742
|
"popover.back": "\u8FD4\u56DE",
|
|
666
|
-
"guard.hint": "/rewind \
|
|
743
|
+
"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"
|
|
667
744
|
};
|
|
668
745
|
var en = {
|
|
669
746
|
"button.aria": "Rewind to this message",
|
|
@@ -682,12 +759,12 @@ var en = {
|
|
|
682
759
|
"popover.impact.none": "No tracked file changes after the target; nothing to restore.",
|
|
683
760
|
"popover.confirm": "Confirm rewind",
|
|
684
761
|
"popover.back": "Back",
|
|
685
|
-
"guard.hint": "/rewind
|
|
762
|
+
"guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
|
|
686
763
|
};
|
|
687
764
|
|
|
688
765
|
// src/client/index.ts
|
|
689
766
|
var name = "dsh-rewind";
|
|
690
|
-
var inject = ["slots", "sessions", "locale"];
|
|
767
|
+
var inject = ["slots", "sessions", "locale", "commandUi"];
|
|
691
768
|
var NS = "rewind";
|
|
692
769
|
var HEADER_ACTIONS_SLOT = "conversation.session.header.actions";
|
|
693
770
|
var COMPOSER_SELECTOR2 = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
@@ -713,11 +790,58 @@ function apply(ctx) {
|
|
|
713
790
|
},
|
|
714
791
|
createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
|
|
715
792
|
));
|
|
716
|
-
const
|
|
793
|
+
const commandUi = ctx.get("commandUi");
|
|
794
|
+
const chatOf = (sessionId) => {
|
|
795
|
+
if (sessionId === void 0) return void 0;
|
|
796
|
+
const face = sessionOf(sessionId);
|
|
797
|
+
return face === void 0 ? void 0 : face.getSnapshot().chat;
|
|
798
|
+
};
|
|
799
|
+
const hasCandidates = (sessionId) => {
|
|
800
|
+
const chat = chatOf(sessionId);
|
|
801
|
+
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
802
|
+
};
|
|
803
|
+
const composerAnchor = () => {
|
|
804
|
+
const textarea = composerTextarea();
|
|
805
|
+
const card = textarea?.closest("[data-composer-card]");
|
|
806
|
+
return card ?? textarea ?? document.body;
|
|
807
|
+
};
|
|
808
|
+
yield commandUi.decorate({
|
|
809
|
+
name: "rewind",
|
|
810
|
+
// The picker exists exactly while the surface has a reachable user
|
|
811
|
+
// message: a fresh session (no candidates) falls through to the host
|
|
812
|
+
// command, which fails with "no user messages" — matching the harness's
|
|
813
|
+
// own decoration convention (see ui-permission-presets).
|
|
814
|
+
available: (session) => hasCandidates(session.sessionId),
|
|
815
|
+
ui: {
|
|
816
|
+
kind: "popupSelect",
|
|
817
|
+
options: (session) => {
|
|
818
|
+
const chat = chatOf(session.sessionId);
|
|
819
|
+
return Promise.resolve(chat === void 0 ? [] : rewindOptionsOf(chat, t));
|
|
820
|
+
},
|
|
821
|
+
onSelect: (option, session) => {
|
|
822
|
+
const face = sessionOf(session.sessionId);
|
|
823
|
+
const chat = chatOf(session.sessionId);
|
|
824
|
+
const candidate = chat !== void 0 ? candidateBySeq(chat, Number(option.id)) : void 0;
|
|
825
|
+
if (face === void 0 || candidate === void 0) return;
|
|
826
|
+
openPopover({
|
|
827
|
+
session: face,
|
|
828
|
+
seq: candidate.seq,
|
|
829
|
+
time: candidate.time,
|
|
830
|
+
preview: candidate.preview,
|
|
831
|
+
anchor: composerAnchor(),
|
|
832
|
+
t,
|
|
833
|
+
onRewind: (mode) => {
|
|
834
|
+
void runRewindAndFill(face, candidate.seq, mode, currentSessionId);
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
const PARAM_REWIND = /^\s*\/rewind\s+\S+/i;
|
|
717
841
|
const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
|
|
718
|
-
const
|
|
842
|
+
const hasParamRewindDraft = () => {
|
|
719
843
|
const textarea = composerTextarea();
|
|
720
|
-
return textarea !== null &&
|
|
844
|
+
return textarea !== null && PARAM_REWIND.test(textarea.value);
|
|
721
845
|
};
|
|
722
846
|
let guardHintEl = null;
|
|
723
847
|
let guardHintTimer;
|
|
@@ -742,15 +866,18 @@ function apply(ctx) {
|
|
|
742
866
|
guardHintTimer = void 0;
|
|
743
867
|
}, 3200);
|
|
744
868
|
};
|
|
745
|
-
const
|
|
746
|
-
if (
|
|
747
|
-
if (!hasBlockedRewindDraft()) return;
|
|
869
|
+
const onParamRewindSubmit = (event) => {
|
|
870
|
+
if (!hasParamRewindDraft()) return;
|
|
748
871
|
event.preventDefault();
|
|
749
872
|
event.stopPropagation();
|
|
750
873
|
showGuardHint();
|
|
751
874
|
};
|
|
875
|
+
const onKeyDownGuard = (event) => {
|
|
876
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
877
|
+
onParamRewindSubmit(event);
|
|
878
|
+
};
|
|
752
879
|
const onClickGuard = (event) => {
|
|
753
|
-
if (event.button !== 0 || !
|
|
880
|
+
if (event.button !== 0 || !hasParamRewindDraft()) return;
|
|
754
881
|
const target = event.target;
|
|
755
882
|
if (!(target instanceof Element)) return;
|
|
756
883
|
const button = target.closest("button");
|
|
@@ -760,9 +887,7 @@ function apply(ctx) {
|
|
|
760
887
|
const all = card.querySelectorAll("button");
|
|
761
888
|
if (all[all.length - 1] !== button) return;
|
|
762
889
|
if (button.querySelector("rect") !== null) return;
|
|
763
|
-
event
|
|
764
|
-
event.stopPropagation();
|
|
765
|
-
showGuardHint();
|
|
890
|
+
onParamRewindSubmit(event);
|
|
766
891
|
};
|
|
767
892
|
document.addEventListener("keydown", onKeyDownGuard, true);
|
|
768
893
|
document.addEventListener("click", onClickGuard, true);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure candidate computation for the `/rewind` command decoration: which user
|
|
3
|
+
* messages the harness's popupSelect shell offers, withdrawn-row exclusion,
|
|
4
|
+
* preview truncation, and the mapping to popupSelect rows. The listing is a
|
|
5
|
+
* pure function of the session chat snapshot (`rewindCandidatesOf`) so it
|
|
6
|
+
* stays unit-testable in a node environment. Surface user/steering messages
|
|
7
|
+
* only, withdrawn (hidden) rows excluded, newest first — the top row is the
|
|
8
|
+
* default highlight, i.e. the most recent message and the most common rewind
|
|
9
|
+
* target.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-rewind/client/candidates
|
|
12
|
+
*/
|
|
13
|
+
import type { SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client';
|
|
14
|
+
import type { RewindKey } from './locales.ts';
|
|
15
|
+
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
16
|
+
/** Preview length cap for candidate rows (matches the host's candidate list). */
|
|
17
|
+
export declare const PREVIEW_CHARS = 80;
|
|
18
|
+
/** One selectable rewind target. */
|
|
19
|
+
export interface RewindCandidate {
|
|
20
|
+
/** Absolute log seq of the `user/message` event. */
|
|
21
|
+
readonly seq: number;
|
|
22
|
+
/** Unix epoch ms of the event. */
|
|
23
|
+
readonly time: number;
|
|
24
|
+
/** Truncated plain-text preview of the message content. */
|
|
25
|
+
readonly preview: string;
|
|
26
|
+
}
|
|
27
|
+
/** A chat snapshot subset the candidate listing reads (structural). */
|
|
28
|
+
export interface CandidateChat {
|
|
29
|
+
readonly order: readonly string[];
|
|
30
|
+
readonly nodes: {
|
|
31
|
+
get(key: string): CandidateUserNode | undefined;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** A user/steering row subset; only fields the listing reads are real. */
|
|
35
|
+
export interface CandidateUserNode {
|
|
36
|
+
readonly kind: string;
|
|
37
|
+
readonly anchorSeq?: number;
|
|
38
|
+
readonly data: {
|
|
39
|
+
readonly seq: number;
|
|
40
|
+
readonly time: number;
|
|
41
|
+
readonly content: readonly {
|
|
42
|
+
type: string;
|
|
43
|
+
text?: unknown;
|
|
44
|
+
}[];
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Join the text blocks of a user message into one plain preview. */
|
|
48
|
+
export declare function messagePreviewOf(message: {
|
|
49
|
+
readonly content: readonly {
|
|
50
|
+
type: string;
|
|
51
|
+
text?: unknown;
|
|
52
|
+
}[];
|
|
53
|
+
}): string;
|
|
54
|
+
/** Format a candidate row's clock time (`HH:MM`), matching the host format. */
|
|
55
|
+
export declare function formatCandidateTime(time: number): string;
|
|
56
|
+
/**
|
|
57
|
+
* List the selectable rewind candidates of a session chat snapshot: user and
|
|
58
|
+
* steering rows still on the surface (not hidden by a previous rewind), the
|
|
59
|
+
* newest `limit` kept, newest first — the top row is the default highlight,
|
|
60
|
+
* i.e. the most recent message and the most common rewind target.
|
|
61
|
+
* @param snap - the session chat snapshot.
|
|
62
|
+
* @param hidden - anchor seqs withdrawn by rewinds (from `hiddenSeqsOf`).
|
|
63
|
+
* @param limit - maximum number of candidates to return.
|
|
64
|
+
*/
|
|
65
|
+
export declare function rewindCandidatesOf(snap: CandidateChat, hidden: ReadonlySet<number>, limit?: number): RewindCandidate[];
|
|
66
|
+
/** The candidates of a live chat snapshot, withdrawn rows already excluded. */
|
|
67
|
+
export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandidate[];
|
|
68
|
+
/**
|
|
69
|
+
* Map the candidates to popupSelect rows: the message preview as the row
|
|
70
|
+
* label (left) and the clock time as the detail (right) — the shell's native
|
|
71
|
+
* label/detail flex layout, with no recency numbers.
|
|
72
|
+
*/
|
|
73
|
+
export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
|
|
74
|
+
/** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
|
|
75
|
+
export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
|
|
76
|
+
export {};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-rewind client half: the
|
|
3
|
-
* registration, and the session-scoped
|
|
4
|
-
* per-message ↶ rewind button (see
|
|
2
|
+
* dsh-rewind client half: the `/rewind` command decoration, the
|
|
3
|
+
* parameterized-input guard, the locale registration, and the session-scoped
|
|
4
|
+
* portal bridge that renders the per-message ↶ rewind button (see
|
|
5
|
+
* `portals.tsx` for the button itself).
|
|
5
6
|
*
|
|
6
7
|
* The button is NOT injected by hand into the DOM anymore: the plugin
|
|
7
8
|
* registers a bridge into the harness's `conversation.session.header.actions`
|
|
@@ -12,10 +13,16 @@
|
|
|
12
13
|
* plugin never imports conversation UI types and survives harness version
|
|
13
14
|
* drift.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* The text-driven flow is the harness's STANDARD command decoration
|
|
17
|
+
* (`ctx.commandUi.decorate`): a bare `/rewind` — picked from the slash-menu
|
|
18
|
+
* completion, or typed in full and Entered — opens the harness's own
|
|
19
|
+
* popupSelect shell (search, ↑↓/Enter, Esc) listing the rewind candidates
|
|
20
|
+
* instead of executing the command. Picking one continues the SAME flow as
|
|
21
|
+
* the ↶ button: the mode popover, both-impact confirmation, execution, row
|
|
22
|
+
* hiding and the composer refill (`runRewindAndFill`). The parameterized
|
|
23
|
+
* forms (`/rewind @<seq> chat|both`, `/rewind preview …`) stay internal
|
|
24
|
+
* channels the ↶ button and the popover drive through `session.command`; a
|
|
25
|
+
* hand-typed parameterized line is stopped with a hint.
|
|
19
26
|
*
|
|
20
27
|
* @module dsh-rewind/client
|
|
21
28
|
*/
|
|
@@ -23,7 +30,8 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
|
23
30
|
export declare const name = "dsh-rewind";
|
|
24
31
|
export declare const inject: string[];
|
|
25
32
|
/**
|
|
26
|
-
* Client plugin body:
|
|
27
|
-
*
|
|
33
|
+
* Client plugin body: command decoration + parameterized guard + locale + the
|
|
34
|
+
* portal bridge.
|
|
35
|
+
* @param ctx - client root context carrying `slots`, `sessions`, `locale` and `commandUi`.
|
|
28
36
|
*/
|
|
29
37
|
export declare function apply(ctx: ClientContext): void;
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* offers the two modes. Choosing "both" first fetches the impact list through
|
|
5
5
|
* the `/rewind preview @seq both` command and shows it before confirming.
|
|
6
6
|
*
|
|
7
|
+
* Keyboard: ↑/↓ move focus across the step's ACTION buttons only (the two
|
|
8
|
+
* modes, or the confirm button on the impact step), Enter activates the
|
|
9
|
+
* focused button (native), Esc is the keyboard twin of the ghost back/cancel
|
|
10
|
+
* buttons — cancel on the modes step, back on the impact step; the ghosts are
|
|
11
|
+
* never in the arrow cycle. The listener runs in the document capture phase
|
|
12
|
+
* so the keys are stolen from the composer while the popover is open.
|
|
13
|
+
*
|
|
7
14
|
* @module dsh-rewind/client/popover
|
|
8
15
|
*/
|
|
9
16
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
@@ -44,6 +44,28 @@ export interface SlotsLike {
|
|
|
44
44
|
readonly sessionId: string;
|
|
45
45
|
}) => ReactNode): () => void;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Fill the dsh composer with `text` (React-controlled textarea: use the
|
|
49
|
+
* native setter so the value change is seen, then dispatch an input event).
|
|
50
|
+
* Best-effort — no composer match means the fill is skipped. Used by
|
|
51
|
+
* `runRewindAndFill` to put the withdrawn target message back into the
|
|
52
|
+
* composer after a rewind.
|
|
53
|
+
*/
|
|
54
|
+
export declare function fillComposer(text: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Execute one rewind from the popover and, when it settles successfully,
|
|
57
|
+
* put the withdrawn target message's text back into the composer so the
|
|
58
|
+
* user can edit and re-send.
|
|
59
|
+
*
|
|
60
|
+
* THE COMPOSER FILL IS EVENT-DRIVEN: it runs only when THIS page performed
|
|
61
|
+
* the rewind (the user clicked confirm moments ago). It must NEVER scan
|
|
62
|
+
* loaded history for rewind commands: a session window opens with only
|
|
63
|
+
* the tail page and grows via loadOlder, so a "command already in the
|
|
64
|
+
* snapshot" cannot be told apart from "command executed in this page" —
|
|
65
|
+
* the old baseline heuristic refilled withdrawn text into the composer
|
|
66
|
+
* after switching sessions or restarting dsh.
|
|
67
|
+
*/
|
|
68
|
+
export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined): Promise<void>;
|
|
47
69
|
interface RewindPortalsProps extends RewindBridgeDeps {
|
|
48
70
|
readonly sessionId: string;
|
|
49
71
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"inject": [
|
|
56
56
|
"@deepseek-ai/dsh-client-locale",
|
|
57
57
|
"@deepseek-ai/dsh-client-runtime",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-commands",
|
|
58
59
|
"@deepseek-ai/dsh-client-ui-conversation"
|
|
59
60
|
],
|
|
60
61
|
"platform": "web"
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
75
76
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
76
77
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-commands": "^0.1.0-rc.6",
|
|
77
79
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
78
80
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
|
|
79
81
|
"@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
|
|
@@ -92,6 +94,9 @@
|
|
|
92
94
|
"@deepseek-ai/dsh-client-runtime": {
|
|
93
95
|
"optional": true
|
|
94
96
|
},
|
|
97
|
+
"@deepseek-ai/dsh-client-ui-commands": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
95
100
|
"@deepseek-ai/dsh-client-ui-slots": {
|
|
96
101
|
"optional": true
|
|
97
102
|
},
|
|
@@ -119,6 +124,8 @@
|
|
|
119
124
|
"@deepseek-ai/dsh-agent": "^0.1.0-rc.7",
|
|
120
125
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
121
126
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
127
|
+
"@deepseek-ai/dsh-client-ui-commands": "^0.1.0-rc.7",
|
|
128
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.7",
|
|
122
129
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
123
130
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.7",
|
|
124
131
|
"@deepseek-ai/dsh-fs": "^0.1.0-rc.7",
|
|
Binary file
|