dsh-rewind-plugin 0.2.9 → 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 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/guard-hint.png" width="440" alt="Manual /rewind guard hint"><br><sub>Manual /rewind guard hint</sub></td>
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,9 @@ 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
- The button appears on user messages rendered in the **current session view** — switch to another session before rewinding it. A rewind can itself be rewound (its marker enters the log), but the file-restore action is not re-backed up.
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.
57
57
 
58
58
  ## How it works
59
59
 
@@ -118,6 +118,8 @@ Full instructions: [docs/troubleshooting.md](docs/troubleshooting.md)
118
118
 
119
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.
120
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
+
121
123
  ## Development
122
124
 
123
125
  ```sh
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/guard-hint.png" width="440" alt="手动 /rewind 拦截提示"><br><sub>手动 /rewind 拦截提示</sub></td>
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,9 @@ dsh plugin --profile web add dsh-rewind-plugin
53
51
  2. **点击它。** 目标即这条消息;小浮层提供两种模式(目标之后没有跟踪的变更时,「回退对话和代码」不显示)。
54
52
  3. 回退以一条会话内命令执行;结果消息确认,被撤回消息的文本自动填入输入框,可编辑后重发。
55
53
 
56
- 按钮只出现在**当前会话视图**渲染的用户消息行上——回退其他会话前先切换到该会话。回退本身可再回退(标记进入日志),但文件还原动作不再记录新备份。
54
+ **命令行入口**:输入裸 `/rewind` 回车打开候选面板,选择目标后流程与按钮一致。
55
+
56
+ 回退可重复进行(每次追加一条标记到日志)。回退无法通过插件撤销,但可以手动编辑会话日志恢复。文件还原动作不再记录新备份。
57
57
 
58
58
  ## 原理
59
59
 
@@ -118,6 +118,8 @@ dsh plugin --profile web add dsh-rewind-plugin
118
118
 
119
119
  本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。文件写入仅在你选择「回退对话和代码」时发生,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
120
120
 
121
+ > **注意:** 回退只是把消息从视图中隐藏——导出的会话日志(`/export`)仍包含撤回前的内容,本插件无法改动导出。要彻底删除对话,请删除对应的会话文件。
122
+
121
123
  ## 开发
122
124
 
123
125
  ```sh
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 === "Escape") closePopover();
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) {
@@ -665,7 +740,7 @@ var zh = {
665
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",
666
741
  "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
667
742
  "popover.back": "\u8FD4\u56DE",
668
- "guard.hint": "/rewind \u547D\u4EE4\u4E0D\u652F\u6301\u624B\u52A8\u8F93\u5165\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684 \u21B6 \u56DE\u9000\u6309\u94AE"
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"
669
744
  };
670
745
  var en = {
671
746
  "button.aria": "Rewind to this message",
@@ -684,12 +759,12 @@ var en = {
684
759
  "popover.impact.none": "No tracked file changes after the target; nothing to restore.",
685
760
  "popover.confirm": "Confirm rewind",
686
761
  "popover.back": "Back",
687
- "guard.hint": "/rewind cannot be typed manually \u2014 use the \u21B6 button on a message to rewind."
762
+ "guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
688
763
  };
689
764
 
690
765
  // src/client/index.ts
691
766
  var name = "dsh-rewind";
692
- var inject = ["slots", "sessions", "locale"];
767
+ var inject = ["slots", "sessions", "locale", "commandUi"];
693
768
  var NS = "rewind";
694
769
  var HEADER_ACTIONS_SLOT = "conversation.session.header.actions";
695
770
  var COMPOSER_SELECTOR2 = "[data-input-scroll] textarea, textarea[data-phase]";
@@ -715,11 +790,58 @@ function apply(ctx) {
715
790
  },
716
791
  createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
717
792
  ));
718
- const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
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;
719
841
  const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
720
- const hasBlockedRewindDraft = () => {
842
+ const hasParamRewindDraft = () => {
721
843
  const textarea = composerTextarea();
722
- return textarea !== null && MANUAL_REWIND.test(textarea.value);
844
+ return textarea !== null && PARAM_REWIND.test(textarea.value);
723
845
  };
724
846
  let guardHintEl = null;
725
847
  let guardHintTimer;
@@ -744,15 +866,18 @@ function apply(ctx) {
744
866
  guardHintTimer = void 0;
745
867
  }, 3200);
746
868
  };
747
- const onKeyDownGuard = (event) => {
748
- if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
749
- if (!hasBlockedRewindDraft()) return;
869
+ const onParamRewindSubmit = (event) => {
870
+ if (!hasParamRewindDraft()) return;
750
871
  event.preventDefault();
751
872
  event.stopPropagation();
752
873
  showGuardHint();
753
874
  };
875
+ const onKeyDownGuard = (event) => {
876
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
877
+ onParamRewindSubmit(event);
878
+ };
754
879
  const onClickGuard = (event) => {
755
- if (event.button !== 0 || !hasBlockedRewindDraft()) return;
880
+ if (event.button !== 0 || !hasParamRewindDraft()) return;
756
881
  const target = event.target;
757
882
  if (!(target instanceof Element)) return;
758
883
  const button = target.closest("button");
@@ -762,9 +887,7 @@ function apply(ctx) {
762
887
  const all = card.querySelectorAll("button");
763
888
  if (all[all.length - 1] !== button) return;
764
889
  if (button.querySelector("rect") !== null) return;
765
- event.preventDefault();
766
- event.stopPropagation();
767
- showGuardHint();
890
+ onParamRewindSubmit(event);
768
891
  };
769
892
  document.addEventListener("keydown", onKeyDownGuard, true);
770
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 manual `/rewind` composer guard, the locale
3
- * registration, and the session-scoped portal bridge that renders the
4
- * per-message ↶ rewind button (see `portals.tsx` for the button itself).
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
- * Manual composer input of `/rewind` is deliberately blocked (the guard
16
- * below): the command exists only as the per-message ↶ button's internal
17
- * channel, so any `/rewind` line typed by hand bare or with arguments — is
18
- * stopped with a hint pointing at the button.
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 Enteredopens 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: composer guard + locale + the portal bridge.
27
- * @param ctx - client root context carrying `slots`, `sessions` and `locale`.
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.2.9",
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