dsh-rewind-plugin 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,8 +13,12 @@ export interface HiddenChat {
13
13
  get(key: string): ChatConversationViewNode | undefined;
14
14
  };
15
15
  }
16
- /** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
17
- export declare function targetOfOutcome(text: string | undefined): number | undefined;
16
+ /**
17
+ * Extract the rewind target seq from a `/rewind` command's structured `args`
18
+ * (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
19
+ * host's human outcome copy.
20
+ */
21
+ export declare function targetSeqOfArgs(args: string | null | undefined): number | undefined;
18
22
  /**
19
23
  * True when a `/rewind` command node is an EXECUTED rewind for `seq` — the
20
24
  * admission form the popover drives (`@<seq> chat` / `both`) that settled
@@ -28,10 +32,10 @@ export declare function isExecutedRewindCommand(node: CommandNode, seq: number):
28
32
  * of the "rewind conversation and code" option (Claude Code hides the
29
33
  * code-restore options when the checkpoint has no tracked changes).
30
34
  *
31
- * Prefers the machine-readable `impact=<n>` trailer the current host appends
32
- * to preview text. Older host output (or a history-loaded preview row from
33
- * before the trailer existed) has none, so it falls back to the human copy
34
- * ("将影响 …") to keep mixed-version deployments correct.
35
+ * Reads ONLY the machine-readable `impact=<n>` trailer the host appends to
36
+ * preview text. Older host output without the trailer is treated as having no
37
+ * changes (never guesses from human copy). Unknown/absent text degrades to
38
+ * always-show so a working option is never hidden on a failed probe.
35
39
  */
36
40
  export declare function hasFileImpact(text: string | undefined): boolean;
37
41
  /**
@@ -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;
@@ -15,6 +15,8 @@ export declare const zh: {
15
15
  'popover.impact.loading': string;
16
16
  'popover.impact.failed': string;
17
17
  'popover.impact.none': string;
18
+ 'popover.impact.restore': string;
19
+ 'popover.impact.delete': string;
18
20
  'popover.confirm': string;
19
21
  'popover.back': string;
20
22
  'guard.hint': string;
@@ -43,6 +45,8 @@ export declare const en: {
43
45
  'popover.impact.loading': string;
44
46
  'popover.impact.failed': string;
45
47
  'popover.impact.none': string;
48
+ 'popover.impact.restore': string;
49
+ 'popover.impact.delete': string;
46
50
  'popover.confirm': string;
47
51
  'popover.back': string;
48
52
  'guard.hint': string;
@@ -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
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Host-side localization for dsh-rewind's `/rewind` command output and command
3
+ * description.
4
+ *
5
+ * Architecture (matches the dsh ecosystem): the HOST half of a dual-face
6
+ * plugin has no locale service — only the browser client carries one. The host
7
+ * therefore renders its command-adjacent copy from a durable user preference
8
+ * (`ctx.settings` → `locale.preference`, registered by dsh-client-locale),
9
+ * defaulting to English — the ecosystem's neutral default language (the harness
10
+ * `FALLBACK_LOCALE` and the language dsh's own host commands use, e.g.
11
+ * dsh-plan-mode). See packages/client/locale in deepseek-harness.
12
+ *
13
+ * The client half (`src/client/locales.ts`) owns all interactive UI copy via
14
+ * `ctx.locale` + `t()`; the host's human text is a machine channel the client
15
+ * renders through machine tokens (`impact=<n>`, `args` @seq), never by parsing
16
+ * host prose.
17
+ *
18
+ * English is the key-set source of truth; zh is checked complete against it.
19
+ *
20
+ * @module dsh-rewind/locales
21
+ */
22
+ /** Host-side supported locale ids, mirroring the harness's shipped locales. */
23
+ export type HostLocaleId = 'zh' | 'en';
24
+ /** English dictionary — the key-set source of truth (neutral default). */
25
+ export declare const en: {
26
+ 'usage.title': string;
27
+ 'usage.noArgs': string;
28
+ 'usage.seq': string;
29
+ 'usage.blocked': string;
30
+ 'describeTarget.seq': string;
31
+ 'describeTarget.index': string;
32
+ 'plan.rewinding': string;
33
+ 'plan.affects': string;
34
+ 'plan.restore': string;
35
+ 'plan.delete': string;
36
+ 'plan.noChanges': string;
37
+ 'error.invalidTarget': string;
38
+ 'failures.suffix': string;
39
+ 'failures.item': string;
40
+ inflight: string;
41
+ stopFailed: string;
42
+ cancelled: string;
43
+ failed: string;
44
+ 'restore.count': string;
45
+ 'delete.count': string;
46
+ 'skip.count': string;
47
+ noRestorable: string;
48
+ success: string;
49
+ noUserMessages: string;
50
+ chooseMode: string;
51
+ 'command.description': string;
52
+ };
53
+ /** The host rewind dictionary key union. */
54
+ export type HostKey = keyof typeof en;
55
+ /** Chinese dictionary, checked complete against the en key set. */
56
+ export declare const zh: Record<HostKey, string>;
57
+ /** The host dictionaries keyed by locale id. */
58
+ export declare const HOST_DICTS: Record<HostLocaleId, Record<HostKey, string>>;
59
+ /**
60
+ * Render one dictionary key with `{name}` template interpolation. Unknown
61
+ * params are ignored; a missing key falls back to the raw key so a dictionary
62
+ * gap is visible instead of blank.
63
+ * @param lang - the active locale.
64
+ * @param key - the dictionary key.
65
+ * @param params - `{name}` substitution values.
66
+ */
67
+ export declare function translate(lang: HostLocaleId, key: HostKey, params?: Record<string, string | number>): string;
@@ -65,6 +65,34 @@ export interface RestoreOutcome {
65
65
  }
66
66
  /** Deletes one file by its real path (node:fs, bypassing the fs service). */
67
67
  export type DeleteFile = (path: string) => Promise<void>;
68
+ /**
69
+ * Current-on-disk state probe used by restore planning. Injected so the plan
70
+ * logic runs against a fake FS in tests; the production default reads the
71
+ * real file system with plain `node:fs` (see {@link defaultProbe}).
72
+ */
73
+ export interface DiskProbe {
74
+ /**
75
+ * Full text of the file, or undefined when the file does not exist.
76
+ * Any thrown error is treated as a probe failure: restore planning then
77
+ * conservatively treats the file as DIFFERING from its record (a restore
78
+ * still attempts the write / a delete still attempts the unlink), so an
79
+ * unreadable file is never silently skipped.
80
+ */
81
+ readText(path: string): Promise<string | undefined>;
82
+ /** True when the path is a symlink or a hard link (never planned/restored). */
83
+ isLink(path: string): Promise<boolean>;
84
+ }
85
+ /** One restore action the planner derived from record + disk reconciliation. */
86
+ export type PlannedAction = {
87
+ readonly path: string;
88
+ readonly action: 'restore';
89
+ readonly before: string;
90
+ } | {
91
+ readonly path: string;
92
+ readonly action: 'delete';
93
+ };
94
+ /** Production probe: real reads via node:fs, links detected by lstat + nlink. */
95
+ export declare const defaultProbe: DiskProbe;
68
96
  /**
69
97
  * On-disk checkpoint store. Every write goes straight through `node:fs`, so a
70
98
  * restore reliably lands on the real file system.
@@ -94,18 +122,49 @@ export declare class SnapshotStore {
94
122
  * single source of truth for both restore and impact preview.
95
123
  */
96
124
  private earliestEntries;
97
- /** Per-file restore impact for the earliest entry at/after the target. */
98
- impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
99
125
  /**
100
- * Restore the workspace to the target message's checkpoint: for every path
101
- * with entries anchored at or after it, apply the EARLIEST entry — write the
102
- * before content back, or delete the file when it was created after the
103
- * target. Symlinked and hard-linked paths are skipped (reported, never
104
- * written through); a restored file's parent directory is created when it
105
- * was deleted after the backup. Failures are per-file and never abort the
106
- * pass.
126
+ * The single source of truth for BOTH the impact preview and the restore
127
+ * pass: reconcile the earliest recorded entry per path (at/after the
128
+ * target) against the CURRENT on-disk state, and plan only the actions
129
+ * that would actually change the disk. This is the Claude Code model
130
+ * `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
131
+ * live filesystem (`checkOriginFileChanged`) and count only real
132
+ * differences, so a rewind whose target state already matches the disk is
133
+ * a no-op with zero impact.
134
+ *
135
+ * - `before === null` (the file did not exist at the target) plans a
136
+ * `delete` ONLY when the file currently exists; an already-absent file
137
+ * is a no-op — this kills the "ghost impact" of replaying an entry a
138
+ * previous rewind already consumed.
139
+ * - `before === 'X'` plans a `restore` ONLY when the current content
140
+ * differs from X (or the file is missing); identical content is a no-op
141
+ * — this keeps repeated rewinds idempotent.
142
+ * - Symlinked / hard-linked paths are never planned (they are reported as
143
+ * skipped by the restore pass, never written through).
144
+ * - A probe failure (e.g. a permission error reading the file) plans the
145
+ * action conservatively as if the file differed, so an unreadable file
146
+ * is never silently dropped from the restore.
147
+ *
148
+ * @param sessionId - session whose snapshot store to plan against.
149
+ * @param targetSeq - rewind target; entries anchored at/after it apply.
150
+ * @param probe - current-disk state probe (defaults to the real FS).
151
+ * @returns the planned actions plus the link paths that were skipped.
152
+ */
153
+ private planRestore;
154
+ /** Per-file restore impact: only actions that would actually change the disk. */
155
+ impactsAfter(sessionId: string, targetSeq: number, probe?: DiskProbe): Promise<FileImpact[]>;
156
+ /**
157
+ * Restore the workspace to the target message's checkpoint: execute exactly
158
+ * the actions {@link planRestore} derived from the record + current disk
159
+ * reconciliation — write the before content back, or delete the file when
160
+ * it was created after the target and still exists. Symlinked and
161
+ * hard-linked paths are skipped (reported, never written through); a
162
+ * restored file's parent directory is created when it was deleted after
163
+ * the backup; a delete whose file is ALREADY absent is a silent no-op (not
164
+ * a failure — the target state is already reached). Failures are per-file
165
+ * and never abort the pass.
107
166
  */
108
- restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
167
+ restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile, probe?: DiskProbe): Promise<RestoreOutcome>;
109
168
  /**
110
169
  * Drop the session's oldest anchor groups beyond `keep` (default
111
170
  * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
@@ -113,4 +172,42 @@ export declare class SnapshotStore {
113
172
  prune(sessionId: string, keep?: number): Promise<void>;
114
173
  /** True when a path exists on disk (used by tests and diagnostics). */
115
174
  exists(path: string): Promise<boolean>;
175
+ /**
176
+ * All distinct paths ever recorded for a session — the "tracked files"
177
+ * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
178
+ * tracked once a write-class tool touched them), derived from the disk
179
+ * entries so no extra persistence is needed.
180
+ */
181
+ trackedPaths(sessionId: string): Promise<Set<string>>;
116
182
  }
183
+ /**
184
+ * Re-check every tracked file at a user-message boundary and record the
185
+ * current on-disk state for any file whose state changed since it was last
186
+ * seen — Claude Code's `fileHistoryMakeSnapshot` re-stats every tracked file
187
+ * at each user message and snapshots the new state (changed files get a new
188
+ * backup version, deleted files a null marker). Here the "new version" is a
189
+ * plain before-backup entry anchored at the boundary message, so an EXTERNAL
190
+ * edit or deletion (never seen by the write-class tool capture) enters the
191
+ * record and can be restored by a later rewind.
192
+ *
193
+ * Semantics: the recorded `before` is the file's state at the boundary —
194
+ * the state the boundary message's turn starts from, exactly like the
195
+ * tool-captured entries. An entry is written only when the state differs
196
+ * from the last-seen state (`states`); the FIRST sighting of a path always
197
+ * records (a restart leaves `states` empty, so the first boundary after a
198
+ * restart unconditionally records the current state — redundant but correct,
199
+ * mirroring Claude's resume-then-re-stat behavior).
200
+ *
201
+ * Symlinked / hard-linked paths are never re-checked (restores skip them).
202
+ * A probe failure skips the file with a warning-level no-op; it never
203
+ * aborts the boundary pass.
204
+ *
205
+ * @param store - the session's snapshot store.
206
+ * @param sessionId - session whose tracked files to re-check.
207
+ * @param anchorSeq - the boundary user-message seq (entry anchor).
208
+ * @param tracked - the session's tracked path set (read-only here).
209
+ * @param states - per-path last-seen state (path → content, null = absent).
210
+ * @param probe - current-disk state probe (defaults to the real FS).
211
+ * @returns the number of entries recorded.
212
+ */
213
+ export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, states: Map<string, string | null>, probe?: DiskProbe): Promise<number>;
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.1",
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,12 +75,14 @@
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",
80
82
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
81
83
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6",
82
84
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
85
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
83
86
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
84
87
  },
85
88
  "peerDependenciesMeta": {
@@ -92,6 +95,9 @@
92
95
  "@deepseek-ai/dsh-client-runtime": {
93
96
  "optional": true
94
97
  },
98
+ "@deepseek-ai/dsh-client-ui-commands": {
99
+ "optional": true
100
+ },
95
101
  "@deepseek-ai/dsh-client-ui-slots": {
96
102
  "optional": true
97
103
  },
@@ -110,6 +116,9 @@
110
116
  "@deepseek-ai/dsh-session": {
111
117
  "optional": true
112
118
  },
119
+ "@deepseek-ai/dsh-settings": {
120
+ "optional": true
121
+ },
113
122
  "@deepseek-ai/dsh-tools": {
114
123
  "optional": true
115
124
  }
@@ -119,12 +128,15 @@
119
128
  "@deepseek-ai/dsh-agent": "^0.1.0-rc.7",
120
129
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
121
130
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
131
+ "@deepseek-ai/dsh-client-ui-commands": "^0.1.0-rc.7",
132
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.7",
122
133
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
123
134
  "@deepseek-ai/dsh-commands": "^0.1.0-rc.7",
124
135
  "@deepseek-ai/dsh-fs": "^0.1.0-rc.7",
125
136
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
126
137
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.7",
127
138
  "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
139
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
128
140
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
129
141
  "@types/node": "^24.0.0",
130
142
  "@types/react": "^18.3.31",
Binary file