dsh-rewind-plugin 0.3.1 → 0.3.2

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 = {
@@ -823,6 +841,15 @@ function apply(ctx) {
823
841
  const chat = chatOf(sessionId);
824
842
  return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
825
843
  };
844
+ const fetchHostCandidates = async (face) => {
845
+ const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
846
+ const result = await face.command("/rewind __candidates");
847
+ if (!result.ok || result.value?.matched !== true) return void 0;
848
+ const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
849
+ if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
850
+ return rewindCandidatesFromHostText(outcome.text);
851
+ };
852
+ const hostCandidatesCache = /* @__PURE__ */ new Map();
826
853
  const composerAnchor = () => {
827
854
  const textarea = composerTextarea();
828
855
  const card = textarea?.closest("[data-composer-card]");
@@ -837,15 +864,20 @@ function apply(ctx) {
837
864
  available: (session) => hasCandidates(session.sessionId),
838
865
  ui: {
839
866
  kind: "popupSelect",
840
- options: (session) => {
841
- const chat = chatOf(session.sessionId);
842
- return Promise.resolve(chat === void 0 ? [] : rewindOptionsOf(chat, t));
867
+ options: async (session) => {
868
+ const face = sessionOf(session.sessionId);
869
+ if (face === void 0) return [];
870
+ const candidates = await fetchHostCandidates(face);
871
+ if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
872
+ return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
843
873
  },
844
874
  onSelect: (option, session) => {
845
875
  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;
876
+ if (face === void 0) return;
877
+ const candidate = hostCandidatesCache.get(session.sessionId)?.find(
878
+ (candidate2) => candidate2.seq === Number(option.id)
879
+ );
880
+ if (candidate === void 0) return;
849
881
  openPopover({
850
882
  session: face,
851
883
  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);
@@ -733,6 +745,10 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
733
745
  const impacts = await store.impactsAfter(session.id, plan.targetSeq);
734
746
  return { kind: "success", text: formatPlan(plan, impacts) };
735
747
  }
748
+ if (parts[0] === "__candidates") {
749
+ const candidates = listRewindCandidates(session.events, session.surface.nodes);
750
+ return { kind: "success", text: formatCandidateList(candidates) };
751
+ }
736
752
  const target = parts[0];
737
753
  const mode = parts[1];
738
754
  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
@@ -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.2",
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",