dsh-rewind-plugin 0.3.1 → 0.3.3

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.
Binary file
Binary file
package/lib/client.js CHANGED
@@ -53,6 +53,9 @@ function hasFileImpact(text) {
53
53
  function isPreviewCommand(command) {
54
54
  return (command.args ?? "").includes("preview");
55
55
  }
56
+ function isCandidateCommand(command) {
57
+ return (command.args ?? "").includes("__candidates");
58
+ }
56
59
  function hiddenSeqsOf(snap) {
57
60
  const hidden = /* @__PURE__ */ new Set();
58
61
  const spans = [];
@@ -61,7 +64,7 @@ function hiddenSeqsOf(snap) {
61
64
  if (node === void 0 || node.kind !== "command") continue;
62
65
  const command = node.data;
63
66
  if (command.name !== "rewind") continue;
64
- if (isPreviewCommand(command)) {
67
+ if (isPreviewCommand(command) || isCandidateCommand(command)) {
65
68
  hidden.add(command.seq);
66
69
  continue;
67
70
  }
@@ -87,6 +90,7 @@ function hiddenSeqsOf(snap) {
87
90
 
88
91
  // src/client/candidates.ts
89
92
  var PREVIEW_CHARS = 80;
93
+ var DEFAULT_CANDIDATE_LIMIT = 50;
90
94
  function messagePreviewOf(message) {
91
95
  const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
92
96
  return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
@@ -97,7 +101,7 @@ function formatCandidateTime(time) {
97
101
  const mm = String(d.getMinutes()).padStart(2, "0");
98
102
  return `${hh}:${mm}`;
99
103
  }
100
- function rewindCandidatesOf(snap, hidden, limit = 10) {
104
+ function rewindCandidatesOf(snap, hidden, limit = DEFAULT_CANDIDATE_LIMIT) {
101
105
  const candidates = [];
102
106
  for (let i = snap.order.length - 1; i >= 0 && candidates.length < limit; i--) {
103
107
  const key = snap.order[i];
@@ -116,16 +120,30 @@ function rewindCandidatesOf(snap, hidden, limit = 10) {
116
120
  function rewindCandidatesOfChat(snap) {
117
121
  return rewindCandidatesOf(snap, hiddenSeqsOf(snap));
118
122
  }
119
- function rewindOptionsOf(snap, t) {
120
- return rewindCandidatesOfChat(snap).map((candidate) => ({
123
+ var CANDIDATE_LIST_HEADER = "candidates=";
124
+ function rewindCandidatesFromHostText(text) {
125
+ if (!text.startsWith(CANDIDATE_LIST_HEADER)) return [];
126
+ const lines = text.split("\n").slice(1);
127
+ const candidates = [];
128
+ for (const line of lines) {
129
+ if (line === "") continue;
130
+ const parts = line.split(" ");
131
+ if (parts.length !== 3) continue;
132
+ const seq = Number(parts[0]);
133
+ const time = Number(parts[1]);
134
+ const preview = parts[2] ?? "";
135
+ if (!Number.isSafeInteger(seq) || !Number.isFinite(time)) continue;
136
+ candidates.push({ seq, time, preview });
137
+ }
138
+ return candidates;
139
+ }
140
+ function rewindOptionsFromCandidates(candidates, t) {
141
+ return candidates.map((candidate) => ({
121
142
  id: String(candidate.seq),
122
143
  label: candidate.preview || t("popover.noText"),
123
144
  detail: formatCandidateTime(candidate.time)
124
145
  }));
125
146
  }
126
- function candidateBySeq(snap, seq) {
127
- return rewindCandidatesOfChat(snap).find((candidate) => candidate.seq === seq);
128
- }
129
147
 
130
148
  // src/client/styles.ts
131
149
  var CLASS = {
@@ -449,9 +467,96 @@ function renderImpactStep(root, opts, back, cached) {
449
467
  impact.textContent = t("popover.impact.failed", { message: "unexpected error" });
450
468
  });
451
469
  }
470
+ function mountShell(root, anchor, onKeyDown) {
471
+ const position = () => {
472
+ const rect = anchor.getBoundingClientRect();
473
+ const gap = 4;
474
+ const height = root.offsetHeight;
475
+ const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
476
+ root.style.top = `${Math.round(top)}px`;
477
+ root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
478
+ };
479
+ const onPointerDown = (event) => {
480
+ const target = event.target;
481
+ if (root.contains(target) || anchor.contains(target)) return;
482
+ closePopover();
483
+ };
484
+ const deferred = setTimeout(() => {
485
+ document.addEventListener("pointerdown", onPointerDown);
486
+ document.addEventListener("keydown", onKeyDown, true);
487
+ }, 0);
488
+ const dispose = () => {
489
+ clearTimeout(deferred);
490
+ document.removeEventListener("pointerdown", onPointerDown);
491
+ document.removeEventListener("keydown", onKeyDown, true);
492
+ };
493
+ document.body.append(root);
494
+ position();
495
+ return { position, dispose };
496
+ }
497
+ function openRetractPopover(opts) {
498
+ closePopover();
499
+ const { preview, anchor, t, retract, onRetract } = opts;
500
+ if (retract === void 0 || onRetract === void 0) return;
501
+ const root = el("div", CLASS.popover);
502
+ root.setAttribute("role", "dialog");
503
+ root.setAttribute("aria-label", t("popover.retract.title"));
504
+ const onKeyDown = (event) => {
505
+ if (event.key === "ArrowDown") {
506
+ event.preventDefault();
507
+ event.stopPropagation();
508
+ moveFocus(root, 1);
509
+ return;
510
+ }
511
+ if (event.key === "ArrowUp") {
512
+ event.preventDefault();
513
+ event.stopPropagation();
514
+ moveFocus(root, -1);
515
+ return;
516
+ }
517
+ if (event.key === "Escape") {
518
+ event.preventDefault();
519
+ event.stopPropagation();
520
+ closePopover();
521
+ }
522
+ };
523
+ const previewText = preview.length > 0 ? preview : t("popover.noText");
524
+ const actions = el("div", CLASS.popoverActions);
525
+ const confirm = document.createElement("button");
526
+ confirm.type = "button";
527
+ confirm.className = CLASS.popoverPrimary;
528
+ confirm.textContent = t("popover.retract.confirm");
529
+ confirm.addEventListener("click", () => {
530
+ closePopover();
531
+ onRetract();
532
+ });
533
+ const cancel = document.createElement("button");
534
+ cancel.type = "button";
535
+ cancel.className = CLASS.popoverGhost;
536
+ cancel.textContent = t("popover.cancel");
537
+ cancel.addEventListener("click", closePopover);
538
+ actions.append(confirm, cancel);
539
+ root.replaceChildren(
540
+ el("div", CLASS.popoverTitle, t("popover.retract.title")),
541
+ el("div", CLASS.popoverTarget, t("popover.retract.target", { preview: previewText })),
542
+ el("div", CLASS.popoverImpact, t("popover.retract.hint")),
543
+ actions
544
+ );
545
+ const shell = mountShell(root, anchor, onKeyDown);
546
+ popoverEl = root;
547
+ disposeOutside = shell.dispose;
548
+ focusFirst(root);
549
+ }
452
550
  function openPopover(opts) {
453
551
  closePopover();
552
+ if (opts.retract !== void 0) {
553
+ openRetractPopover(opts);
554
+ return;
555
+ }
454
556
  const { session, seq, time, preview, anchor, t } = opts;
557
+ const onRewind = opts.onRewind;
558
+ if (seq === void 0 || time === void 0 || onRewind === void 0) return;
559
+ const durableOpts = { session, seq, time, preview, anchor, t, onRewind };
455
560
  const root = el("div", CLASS.popover);
456
561
  root.setAttribute("role", "dialog");
457
562
  root.setAttribute("aria-label", t("popover.title"));
@@ -465,7 +570,7 @@ function openPopover(opts) {
465
570
  el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
466
571
  modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
467
572
  closePopover();
468
- opts.onRewind("chat");
573
+ durableOpts.onRewind("chat");
469
574
  })
470
575
  ];
471
576
  if (bothState.state === "noChanges") {
@@ -492,38 +597,7 @@ function openPopover(opts) {
492
597
  };
493
598
  const renderImpact = () => {
494
599
  step = "impact";
495
- renderImpactStep(root, opts, renderModes, impactOutcome);
496
- };
497
- const position = () => {
498
- const rect = anchor.getBoundingClientRect();
499
- const gap = 4;
500
- const height = root.offsetHeight;
501
- const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
502
- root.style.top = `${Math.round(top)}px`;
503
- root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
504
- };
505
- renderModes();
506
- document.body.append(root);
507
- position();
508
- focusFirst(root);
509
- void (async () => {
510
- const outcome = await previewImpact(session, seq);
511
- impactOutcome = outcome;
512
- if (outcome !== null && outcome.kind === "success") {
513
- bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
514
- }
515
- renderModes();
516
- position();
517
- })().catch(() => {
518
- bothState = { state: "hasChanges" };
519
- renderModes();
520
- position();
521
- });
522
- popoverEl = root;
523
- const onPointerDown = (event) => {
524
- const target = event.target;
525
- if (root.contains(target) || anchor.contains(target)) return;
526
- closePopover();
600
+ renderImpactStep(root, durableOpts, renderModes, impactOutcome);
527
601
  };
528
602
  const onKeyDown = (event) => {
529
603
  if (event.key === "ArrowDown") {
@@ -545,20 +619,50 @@ function openPopover(opts) {
545
619
  else closePopover();
546
620
  }
547
621
  };
548
- const deferred = setTimeout(() => {
549
- document.addEventListener("pointerdown", onPointerDown);
550
- document.addEventListener("keydown", onKeyDown, true);
551
- }, 0);
552
- disposeOutside = () => {
553
- clearTimeout(deferred);
554
- document.removeEventListener("pointerdown", onPointerDown);
555
- document.removeEventListener("keydown", onKeyDown, true);
556
- };
622
+ renderModes();
623
+ const shell = mountShell(root, anchor, onKeyDown);
624
+ popoverEl = root;
625
+ disposeOutside = shell.dispose;
626
+ void (async () => {
627
+ const outcome = await previewImpact(session, seq);
628
+ impactOutcome = outcome;
629
+ if (outcome !== null && outcome.kind === "success") {
630
+ bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
631
+ }
632
+ renderModes();
633
+ shell.position();
634
+ })().catch(() => {
635
+ bothState = { state: "hasChanges" };
636
+ renderModes();
637
+ shell.position();
638
+ });
557
639
  }
558
640
 
559
641
  // src/client/portals.tsx
560
642
  var import_react = require("react");
561
643
  var import_react_dom = require("react-dom");
644
+
645
+ // src/client/pending.ts
646
+ function matchPendingRows(rows, steering) {
647
+ const matched = [];
648
+ for (let i = 0; i < rows.length; i++) {
649
+ const row = rows[i];
650
+ const item = steering[i];
651
+ if (item !== void 0 && row.text === (item.text ?? "")) {
652
+ matched.push(item.id);
653
+ } else {
654
+ matched.push(null);
655
+ }
656
+ }
657
+ return matched;
658
+ }
659
+ function retractSpan(steering, targetId) {
660
+ const index = steering.findIndex((item) => item.id === targetId);
661
+ if (index === -1) return [];
662
+ return steering.slice(index).map((item) => item.id);
663
+ }
664
+
665
+ // src/client/portals.tsx
562
666
  var import_jsx_runtime = require("react/jsx-runtime");
563
667
  function userTextAt(session, seq) {
564
668
  const snap = session.getSnapshot();
@@ -601,6 +705,7 @@ var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
601
705
  var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
602
706
  var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
603
707
  var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
708
+ var PENDING_SEAT_SELECTOR = "[data-pending-steering][data-time-hover-root]";
604
709
  function collectTargets(chat, hiddenSeqs) {
605
710
  const rows = /* @__PURE__ */ new Map();
606
711
  for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
@@ -617,14 +722,51 @@ function collectTargets(chat, hiddenSeqs) {
617
722
  const messageRoot = row?.querySelector(ACTIONS_ROOT_SELECTOR);
618
723
  const actions = messageRoot?.lastElementChild;
619
724
  if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
620
- targets.push({ key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
725
+ targets.push({ kind: "durable", key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
726
+ }
727
+ return targets;
728
+ }
729
+ function bubbleTextOf(row) {
730
+ const clone = row.cloneNode(true);
731
+ clone.lastElementChild?.remove();
732
+ return clone.textContent ?? "";
733
+ }
734
+ function collectPendingTargets(snapshot) {
735
+ if (snapshot.subagent !== null) return [];
736
+ const steering = snapshot.queue.filter((item) => item.placement === "steering");
737
+ if (steering.length === 0) return [];
738
+ const rows = Array.from(document.querySelectorAll(PENDING_SEAT_SELECTOR));
739
+ const matched = matchPendingRows(
740
+ rows.map((row) => ({ text: bubbleTextOf(row) })),
741
+ steering.map((item) => ({ id: item.id, text: item.text }))
742
+ );
743
+ const targets = [];
744
+ for (let i = 0; i < matched.length; i++) {
745
+ const itemId = matched[i];
746
+ if (itemId === null) continue;
747
+ const row = rows[i];
748
+ if (row === void 0) continue;
749
+ const messageRoot = row.matches(ACTIONS_ROOT_SELECTOR) ? row : row.querySelector(ACTIONS_ROOT_SELECTOR);
750
+ const actions = messageRoot?.lastElementChild;
751
+ if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
752
+ const item = steering[i];
753
+ targets.push({
754
+ kind: "pending",
755
+ key: `pending:${itemId}`,
756
+ container: actions,
757
+ itemId,
758
+ text: item.text,
759
+ preview: item.preview
760
+ });
621
761
  }
622
762
  return targets;
623
763
  }
624
764
  function sameTargets(left, right) {
625
765
  return left.length === right.length && left.every((target, index) => {
626
766
  const other = right[index];
627
- return other !== void 0 && target.key === other.key && target.container === other.container && target.seq === other.seq;
767
+ if (other === void 0 || target.key !== other.key || target.container !== other.container) return false;
768
+ if (target.kind === "durable") return other.kind === "durable" && target.seq === other.seq;
769
+ return other.kind === "pending" && target.itemId === other.itemId;
628
770
  });
629
771
  }
630
772
  function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
@@ -644,7 +786,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
644
786
  setTargets([]);
645
787
  return;
646
788
  }
647
- const chat = session.getSnapshot().chat;
789
+ const snapshot = session.getSnapshot();
790
+ const chat = snapshot.chat;
648
791
  const hiddenSeqs = hiddenSeqsOf(chat);
649
792
  let hiddenCount = 0;
650
793
  for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
@@ -666,7 +809,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
666
809
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
667
810
  );
668
811
  }
669
- const next = collectTargets(chat, hiddenSeqs);
812
+ const next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
670
813
  setTargets((current) => sameTargets(current, next) ? current : next);
671
814
  };
672
815
  const queueRefresh = () => {
@@ -686,7 +829,16 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
686
829
  };
687
830
  }, [sessionId, sessionOf]);
688
831
  return targets.map((target) => (0, import_react_dom.createPortal)(
689
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
832
+ target.kind === "pending" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
833
+ RetractButton,
834
+ {
835
+ target,
836
+ sessionId,
837
+ sessionOf,
838
+ t
839
+ },
840
+ target.key
841
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
690
842
  RewindButton,
691
843
  {
692
844
  target,
@@ -735,6 +887,49 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
735
887
  }
736
888
  );
737
889
  }
890
+ function composerText() {
891
+ const textarea = document.querySelector(COMPOSER_SELECTOR);
892
+ return textarea === null ? "" : textarea.value;
893
+ }
894
+ async function retractPending(session, itemId, text) {
895
+ await session.cancel();
896
+ const queue = session.getSnapshot().queue;
897
+ const steering = queue.filter((item) => item.placement === "steering");
898
+ for (const id of retractSpan(steering, itemId)) {
899
+ await session.updateQueue(id, { kind: "remove" });
900
+ }
901
+ if (text !== null && text !== "" && composerText().trim() === "") {
902
+ fillComposer(text);
903
+ }
904
+ }
905
+ function RetractButton({ target, sessionId, sessionOf, t }) {
906
+ const onClick = (event) => {
907
+ event.stopPropagation();
908
+ const session = sessionOf(sessionId);
909
+ if (session === void 0) return;
910
+ openPopover({
911
+ session,
912
+ preview: target.preview,
913
+ anchor: event.currentTarget,
914
+ t,
915
+ retract: { itemId: target.itemId, text: target.text },
916
+ onRetract: () => {
917
+ void retractPending(session, target.itemId, target.text);
918
+ }
919
+ });
920
+ };
921
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
922
+ "button",
923
+ {
924
+ type: "button",
925
+ className: CLASS.button,
926
+ "aria-label": t("button.retract.aria"),
927
+ title: t("button.retract.title"),
928
+ onClick,
929
+ dangerouslySetInnerHTML: { __html: REWIND_ICON_SVG }
930
+ }
931
+ );
932
+ }
738
933
  function createRewindBridge(deps) {
739
934
  return function RewindBridge({ sessionId }) {
740
935
  return (0, import_react.createElement)(RewindPortals, { sessionId, ...deps });
@@ -745,8 +940,14 @@ function createRewindBridge(deps) {
745
940
  var zh = {
746
941
  "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
747
942
  "button.title": "\u56DE\u9000",
943
+ "button.retract.aria": "\u56DE\u9000\u5230\u6B64\u63D2\u8BDD\u6D88\u606F",
944
+ "button.retract.title": "\u56DE\u9000",
748
945
  "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
749
946
  "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
947
+ "popover.retract.title": "\u56DE\u9000\u5230\u8FD9\u6761\u63D2\u8BDD\u6D88\u606F",
948
+ "popover.retract.target": "\u63D2\u8BDD\u4E2D \xB7 {preview}",
949
+ "popover.retract.hint": "\u5C06\u505C\u6B62\u5F53\u524D\u751F\u6210\uFF0C\u5E76\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D",
950
+ "popover.retract.confirm": "\u786E\u8BA4\u56DE\u9000",
750
951
  "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
751
952
  "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
752
953
  "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
@@ -766,8 +967,14 @@ var zh = {
766
967
  var en = {
767
968
  "button.aria": "Rewind to this message",
768
969
  "button.title": "Rewind",
970
+ "button.retract.aria": "Rewind to this pending message",
971
+ "button.retract.title": "Rewind",
769
972
  "popover.title": "Rewind to this message",
770
973
  "popover.noText": "(no text)",
974
+ "popover.retract.title": "Rewind to this pending message",
975
+ "popover.retract.target": "Pending \xB7 {preview}",
976
+ "popover.retract.hint": "Stops the current run and rewinds to before this message",
977
+ "popover.retract.confirm": "Confirm rewind",
771
978
  "popover.chat": "Rewind conversation only",
772
979
  "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
773
980
  "popover.both": "Rewind conversation and code",
@@ -823,6 +1030,15 @@ function apply(ctx) {
823
1030
  const chat = chatOf(sessionId);
824
1031
  return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
825
1032
  };
1033
+ const fetchHostCandidates = async (face) => {
1034
+ const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
1035
+ const result = await face.command("/rewind __candidates");
1036
+ if (!result.ok || result.value?.matched !== true) return void 0;
1037
+ const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
1038
+ if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
1039
+ return rewindCandidatesFromHostText(outcome.text);
1040
+ };
1041
+ const hostCandidatesCache = /* @__PURE__ */ new Map();
826
1042
  const composerAnchor = () => {
827
1043
  const textarea = composerTextarea();
828
1044
  const card = textarea?.closest("[data-composer-card]");
@@ -837,15 +1053,20 @@ function apply(ctx) {
837
1053
  available: (session) => hasCandidates(session.sessionId),
838
1054
  ui: {
839
1055
  kind: "popupSelect",
840
- options: (session) => {
841
- const chat = chatOf(session.sessionId);
842
- return Promise.resolve(chat === void 0 ? [] : rewindOptionsOf(chat, t));
1056
+ options: async (session) => {
1057
+ const face = sessionOf(session.sessionId);
1058
+ if (face === void 0) return [];
1059
+ const candidates = await fetchHostCandidates(face);
1060
+ if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
1061
+ return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
843
1062
  },
844
1063
  onSelect: (option, session) => {
845
1064
  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;
1065
+ if (face === void 0) return;
1066
+ const candidate = hostCandidatesCache.get(session.sessionId)?.find(
1067
+ (candidate2) => candidate2.seq === Number(option.id)
1068
+ );
1069
+ if (candidate === void 0) return;
849
1070
  openPopover({
850
1071
  session: face,
851
1072
  seq: candidate.seq,
package/lib/index.js CHANGED
@@ -80,6 +80,7 @@ var RewindError = class extends Error {
80
80
  code;
81
81
  };
82
82
  var CANDIDATE_PREVIEW_CHARS = 80;
83
+ var DEFAULT_CANDIDATE_LIMIT = 50;
83
84
  function markerTurnOf(events) {
84
85
  let lastStarted = 0;
85
86
  for (const event of events) {
@@ -92,6 +93,9 @@ function markerTurnOf(events) {
92
93
  function isUserMessageEvent(event) {
93
94
  return event.type === "user/message";
94
95
  }
96
+ function isHumanUserMessageEvent(event) {
97
+ return isUserMessageEvent(event) && event.data.source.kind === "user";
98
+ }
95
99
  function messagePreview(message) {
96
100
  const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
97
101
  return text.length <= CANDIDATE_PREVIEW_CHARS ? text : `${text.slice(0, CANDIDATE_PREVIEW_CHARS - 1)}\u2026`;
@@ -106,13 +110,13 @@ function parseRewindTarget(raw) {
106
110
  const index = Number(token);
107
111
  return Number.isSafeInteger(index) && index >= 1 ? { kind: "index", index } : void 0;
108
112
  }
109
- function listRewindCandidates(events, surface, limit = 10) {
113
+ function listRewindCandidates(events, surface, limit = DEFAULT_CANDIDATE_LIMIT) {
110
114
  const surfaceIndexes = /* @__PURE__ */ new Map();
111
115
  for (let i = 0; i < surface.length; i++) surfaceIndexes.set(surface[i], i);
112
116
  const candidates = [];
113
117
  for (let i = events.length - 1; i >= 0 && candidates.length < limit; i--) {
114
118
  const event = events[i];
115
- if (!isUserMessageEvent(event)) continue;
119
+ if (!isHumanUserMessageEvent(event)) continue;
116
120
  if (!surfaceIndexes.has(event.seq)) continue;
117
121
  candidates.push({
118
122
  seq: event.seq,
@@ -123,6 +127,14 @@ function listRewindCandidates(events, surface, limit = 10) {
123
127
  }
124
128
  return candidates;
125
129
  }
130
+ var CANDIDATE_LIST_HEADER = "candidates=";
131
+ function formatCandidateList(candidates) {
132
+ const lines = [`${CANDIDATE_LIST_HEADER}${candidates.length}`];
133
+ for (const candidate of candidates) {
134
+ lines.push(`${candidate.seq} ${candidate.time} ${candidate.preview}`);
135
+ }
136
+ return lines.join("\n");
137
+ }
126
138
  function planRewind(events, surface, target) {
127
139
  let targetSeq;
128
140
  if (target.kind === "seq") {
@@ -138,10 +150,10 @@ function planRewind(events, surface, target) {
138
150
  if (targetEvent === void 0) {
139
151
  throw new RewindError("not-a-user-message", `no session event at seq ${targetSeq}`);
140
152
  }
141
- if (!isUserMessageEvent(targetEvent)) {
153
+ if (!isHumanUserMessageEvent(targetEvent)) {
142
154
  throw new RewindError(
143
155
  "not-a-user-message",
144
- `session event at seq ${targetSeq} is not a user message (${targetEvent.type})`
156
+ `session event at seq ${targetSeq} is not a human user message (${targetEvent.type})`
145
157
  );
146
158
  }
147
159
  const targetIndex = surface.indexOf(targetSeq);
@@ -641,6 +653,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
641
653
  if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
642
654
  }
643
655
  }
656
+ function dropPendingSteering(agent) {
657
+ for (const message of [...agent.inbox.nextStep]) {
658
+ agent.inbox.remove(message.id);
659
+ }
660
+ }
644
661
  async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
645
662
  const { agent } = invocation;
646
663
  const sessionId = agent.session.id;
@@ -650,12 +667,13 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
650
667
  inflight.add(sessionId);
651
668
  try {
652
669
  if (agent.status !== "idle") {
653
- agent.cancel({ kind: "user" });
670
+ agent.cancel({ kind: "user" }, { keepInbox: true });
654
671
  const stopped = await waitForAgentIdle(agent, invocation.signal);
655
672
  if (!stopped) {
656
673
  return { kind: "error", text: t("stopFailed") };
657
674
  }
658
675
  }
676
+ dropPendingSteering(agent);
659
677
  if (invocation.signal.aborted) {
660
678
  return { kind: "error", text: t("cancelled") };
661
679
  }
@@ -733,6 +751,10 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
733
751
  const impacts = await store.impactsAfter(session.id, plan.targetSeq);
734
752
  return { kind: "success", text: formatPlan(plan, impacts) };
735
753
  }
754
+ if (parts[0] === "__candidates") {
755
+ const candidates = listRewindCandidates(session.events, session.surface.nodes);
756
+ return { kind: "success", text: formatCandidateList(candidates) };
757
+ }
736
758
  const target = parts[0];
737
759
  const mode = parts[1];
738
760
  if (mode !== void 0 && mode !== "chat" && mode !== "both") {
@@ -15,6 +15,14 @@ import type { RewindKey } from './locales.ts';
15
15
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
16
16
  /** Preview length cap for candidate rows (matches the host's candidate list). */
17
17
  export declare const PREVIEW_CHARS = 80;
18
+ /**
19
+ * Default cap on how many user messages the rewind picker lists (newest kept).
20
+ *
21
+ * A fixed 10 made long sessions look "incomplete" (only the newest 10 shown).
22
+ * 50 keeps the picker scrollable/searchable via the popupSelect shell while
23
+ * covering far longer sessions; callers can still pass an explicit `limit`.
24
+ */
25
+ export declare const DEFAULT_CANDIDATE_LIMIT = 50;
18
26
  /** One selectable rewind target. */
19
27
  export interface RewindCandidate {
20
28
  /** Absolute log seq of the `user/message` event. */
@@ -73,4 +81,21 @@ export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandi
73
81
  export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
74
82
  /** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
75
83
  export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
84
+ /**
85
+ * Parse the host's candidate-list encoding (see `formatCandidateList` in
86
+ * src/rewind.ts) into typed candidates. Malformed lines are skipped; a
87
+ * missing/zero header yields an empty list.
88
+ */
89
+ export declare function rewindCandidatesFromHostText(text: string): RewindCandidate[];
90
+ /**
91
+ * Map typed candidates to popupSelect rows (the host-derived path). The
92
+ * popupSelect sources its options from the FULL host surface via the
93
+ * `__candidates` channel instead of the windowed chat snapshot.
94
+ */
95
+ export declare function rewindOptionsFromCandidates(candidates: readonly RewindCandidate[], t: Translate): SelectOption[];
96
+ /**
97
+ * Parse the host's candidate-list encoding (see `formatCandidateList` in
98
+ * src/rewind.ts) into popupSelect rows.
99
+ */
100
+ export declare function rewindOptionsFromHostText(text: string, t: Translate): SelectOption[];
76
101
  export {};
@@ -38,6 +38,13 @@ export declare function isExecutedRewindCommand(node: CommandNode, seq: number):
38
38
  * always-show so a working option is never hidden on a failed probe.
39
39
  */
40
40
  export declare function hasFileImpact(text: string | undefined): boolean;
41
+ /**
42
+ * True when a `/rewind` command node is the internal candidate-list probe
43
+ * (`/rewind __candidates`) the popupSelect runs to fetch the FULL candidate
44
+ * list from the host. Like previews, its flow node never surfaces in the
45
+ * transcript — it only feeds the popup — so it is hidden in every state.
46
+ */
47
+ export declare function isCandidateCommand(command: CommandNode): boolean;
41
48
  /**
42
49
  * Anchor seqs that must be hidden from the rendered transcript so the user
43
50
  * sees the conversation as the agent sees it: every impact-preview flow node
@@ -3,8 +3,14 @@
3
3
  export declare const zh: {
4
4
  'button.aria': string;
5
5
  'button.title': string;
6
+ 'button.retract.aria': string;
7
+ 'button.retract.title': string;
6
8
  'popover.title': string;
7
9
  'popover.noText': string;
10
+ 'popover.retract.title': string;
11
+ 'popover.retract.target': string;
12
+ 'popover.retract.hint': string;
13
+ 'popover.retract.confirm': string;
8
14
  'popover.chat': string;
9
15
  'popover.chat.hint': string;
10
16
  'popover.both': string;
@@ -33,8 +39,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
39
  export declare const en: {
34
40
  'button.aria': string;
35
41
  'button.title': string;
42
+ 'button.retract.aria': string;
43
+ 'button.retract.title': string;
36
44
  'popover.title': string;
37
45
  'popover.noText': string;
46
+ 'popover.retract.title': string;
47
+ 'popover.retract.target': string;
48
+ 'popover.retract.hint': string;
49
+ 'popover.retract.confirm': string;
38
50
  'popover.chat': string;
39
51
  'popover.chat.hint': string;
40
52
  'popover.both': string;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure pending-message matching: pairs the rendered pending-steering bubble
3
+ * rows with the session's transient queue mirror rows (`placement === 'steering'`).
4
+ *
5
+ * Both sides derive from the host's next-step inbox order — the ChatView
6
+ * renders `pendingSteering` in array order and the queue mirror keeps the same
7
+ * host order — so index-primary matching is reliable. Text equality is still
8
+ * verified as a per-row cross-check, and a row that fails (or a row with no
9
+ * mirror item, or a mirror item with no row) is skipped INDIVIDUALLY: one bad
10
+ * row never takes down the other rows' buttons. The matching text is the
11
+ * bubble's message text WITHOUT its actions container — the harness copy
12
+ * button's Tooltip mounts a label bubble inside that container on hover, so
13
+ * the full row textContent would flip between "message" and "message+Copy"
14
+ * with the mouse, flickering the button (see `bubbleTextOf` in portals.tsx).
15
+ *
16
+ * The browser half lives in `portals.tsx`; this module stays DOM-free so the
17
+ * matching contract is unit-testable in a plain node environment.
18
+ *
19
+ * @module dsh-rewind/client/pending
20
+ */
21
+ /** One rendered pending-steering bubble row (only the fields matching reads). */
22
+ export interface PendingRow {
23
+ /** The bubble's message text, excluding the actions container (see module doc). */
24
+ readonly text: string;
25
+ }
26
+ /** One steering occurrence from the session queue mirror. */
27
+ export interface PendingSteeringItem {
28
+ readonly id: string;
29
+ /** Complete editable text; null when the message contains non-text blocks. */
30
+ readonly text: string | null;
31
+ }
32
+ /**
33
+ * Pair rows to steering items by index, verifying text equality per row.
34
+ * @param rows - pending bubble rows in DOM order (== render order).
35
+ * @param steering - steering queue items in host order (== render order).
36
+ * @returns the item id for each row, or null for rows that cannot be matched
37
+ * safely (missing counterpart, text mismatch). A bad row never affects the
38
+ * other rows.
39
+ */
40
+ export declare function matchPendingRows(rows: readonly PendingRow[], steering: readonly PendingSteeringItem[]): readonly (string | null)[];
41
+ /**
42
+ * The pending-steering ids a "rewind to this pre-sent message" retracts: the
43
+ * target occurrence and every steering message after it, in inbox (FIFO)
44
+ * order. Queued (next-turn) messages are deliberately NOT included — the
45
+ * harness QueueDock already offers the user per-item edit/remove, so a rewind
46
+ * must not silently drop messages the user may still want to send.
47
+ * @param steering - steering queue items in host order (== render order).
48
+ * @param targetId - the rewind target's inbox occurrence id.
49
+ * @returns the ids to remove, oldest-first; empty when the target is no
50
+ * longer pending (already claimed/consumed).
51
+ */
52
+ export declare function retractSpan(steering: readonly {
53
+ readonly id: string;
54
+ }[], targetId: string): readonly string[];
@@ -19,8 +19,17 @@ import type { RewindKey } from './locales.ts';
19
19
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
20
20
  export interface PopoverOptions {
21
21
  readonly session: SessionFace;
22
- readonly seq: number;
23
- readonly time: number;
22
+ /** Durable variant: the target message seq (mode-selection flow). */
23
+ readonly seq?: number;
24
+ /** Durable variant: the target message time. */
25
+ readonly time?: number;
26
+ /** Pending variant: retract a pre-sent steering message (single-confirm flow). */
27
+ readonly retract?: {
28
+ readonly itemId: string;
29
+ readonly text: string | null;
30
+ };
31
+ /** Pending variant: executed after the retract confirm closes the popover. */
32
+ readonly onRetract?: () => void;
24
33
  readonly preview: string;
25
34
  /** The button that opened the popover (outside-click ignore target). */
26
35
  readonly anchor: HTMLElement;
@@ -30,7 +39,7 @@ export interface PopoverOptions {
30
39
  * the callback owns the command + composer-refill lifecycle (see
31
40
  * runRewindAndFill in index.ts).
32
41
  */
33
- readonly onRewind: (mode: 'chat' | 'both') => void;
42
+ readonly onRewind?: (mode: 'chat' | 'both') => void;
34
43
  }
35
44
  /** Close the current popover, if any. */
36
45
  export declare function closePopover(): void;
@@ -26,6 +26,28 @@ import { type ReactNode } from 'react';
26
26
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
27
27
  import type { RewindKey } from './locales.ts';
28
28
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
29
+ /** One portal target: the actions row of a user/steering seat + its durable node. */
30
+ export type PortalTarget = {
31
+ readonly kind: 'durable';
32
+ /** The seat's chat node key (React reconciliation + diff identity). */
33
+ readonly key: string;
34
+ /** The row's actions container (React portal target). */
35
+ readonly container: HTMLElement;
36
+ readonly seq: number;
37
+ readonly time: number;
38
+ readonly preview: string;
39
+ } | {
40
+ readonly kind: 'pending';
41
+ /** `pending:${itemId}` — stable per inbox occurrence. */
42
+ readonly key: string;
43
+ /** The row's actions container (React portal target). */
44
+ readonly container: HTMLElement;
45
+ /** The host inbox occurrence the retract button addresses. */
46
+ readonly itemId: string;
47
+ /** Complete editable text; null when the message contains non-text blocks. */
48
+ readonly text: string | null;
49
+ readonly preview: string;
50
+ };
29
51
  /** Capabilities the session-scoped bridge receives from the plugin apply(). */
30
52
  export interface RewindBridgeDeps {
31
53
  readonly sessionOf: (sessionId: string) => SessionFace | undefined;
@@ -60,6 +60,12 @@ export interface RewindPlan {
60
60
  }
61
61
  /** Preview length cap for candidate listings. */
62
62
  export declare const CANDIDATE_PREVIEW_CHARS = 80;
63
+ /**
64
+ * Default cap on how many user messages a candidate listing returns (newest
65
+ * kept). Raised from 10 so long sessions don't look incomplete; callers can
66
+ * still pass an explicit `limit`.
67
+ */
68
+ export declare const DEFAULT_CANDIDATE_LIMIT = 50;
63
69
  /**
64
70
  * Turn number for the rewind marker.
65
71
  *
@@ -85,6 +91,18 @@ export declare const CANDIDATE_PREVIEW_CHARS = 80;
85
91
  export declare function markerTurnOf(events: readonly SessionEvent[]): number;
86
92
  /** Narrow an event to a user message. */
87
93
  export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
94
+ /**
95
+ * True for a HUMAN user message event — one whose `source.kind` is `'user'`.
96
+ *
97
+ * The surface can carry `user/message` events whose source is NOT the user:
98
+ * plugin/system context injection (including compaction checkpoints) and
99
+ * tool-result backfill all arrive as `user/message` with a non-`'user'`
100
+ * source, and the client renders those as `context` nodes, never as a user
101
+ * bubble. Only genuine user messages (and user steering during a running
102
+ * turn, which keeps `source.kind: 'user'`) are valid rewind targets — a
103
+ * rewind boundary must land on a human prompt, not on injected context.
104
+ */
105
+ export declare function isHumanUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
88
106
  /** Join the text blocks of a message into one plain string. */
89
107
  export declare function messagePreview(message: UserMessage): string;
90
108
  /**
@@ -104,6 +122,23 @@ export declare function parseRewindTarget(raw: string): RewindTarget | undefined
104
122
  * @returns candidates numbered 1..N by recency.
105
123
  */
106
124
  export declare function listRewindCandidates(events: readonly SessionEvent[], surface: readonly number[], limit?: number): RewindCandidate[];
125
+ /** Header line of the machine-readable candidate list (locale-independent). */
126
+ export declare const CANDIDATE_LIST_HEADER = "candidates=";
127
+ /**
128
+ * Encode a candidate list as the host→client machine channel (the same
129
+ * trailer pattern `formatPlan` uses for `impact=`). The client popupSelect
130
+ * parses this instead of reading the windowed chat snapshot, so the candidate
131
+ * list reflects the FULL host surface — not just the already-loaded history.
132
+ *
133
+ * Lines (each preview is already whitespace-collapsed and tab-free by
134
+ * `messagePreview`):
135
+ * candidates=<n>
136
+ * <seq>\t<time>\t<preview>
137
+ * … (one line per candidate, newest first, matching `listRewindCandidates`)
138
+ *
139
+ * A list with no candidates is just `candidates=0`.
140
+ */
141
+ export declare function formatCandidateList(candidates: readonly RewindCandidate[]): string;
107
142
  /**
108
143
  * Resolve a target against the session log and surface into a validated plan.
109
144
  * @param events - the full session event log.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",