@narumitw/pi-sync 0.53.1 → 0.54.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.
Files changed (38) hide show
  1. package/README.md +14 -4
  2. package/dist/chunks/{chunk-TRCSEIAQ.ts → chunk-5ZWQ33XL.ts} +38 -18
  3. package/dist/chunks/{chunk-TRCSEIAQ.ts.map → chunk-5ZWQ33XL.ts.map} +2 -2
  4. package/dist/chunks/{chunk-QDPED3XJ.ts → chunk-HUTYAPFP.ts} +407 -142
  5. package/dist/chunks/chunk-HUTYAPFP.ts.map +7 -0
  6. package/dist/chunks/{chunk-FJGNDYGC.ts → chunk-VHXBCEVF.ts} +2 -2
  7. package/dist/chunks/{conflict-review-JANNPKKS.ts → conflict-review-JRFJIBEN.ts} +2 -2
  8. package/dist/chunks/{manager-result-dispatcher-AAEAYX57.ts → manager-result-dispatcher-6MQKFCWB.ts} +3 -3
  9. package/dist/chunks/{manager-ui-MHAHAKG3.ts → manager-ui-IURS4WXY.ts} +674 -609
  10. package/dist/chunks/manager-ui-IURS4WXY.ts.map +7 -0
  11. package/dist/chunks/{remote-selection-ui-Q2HUIBTL.ts → remote-selection-ui-6LJJ4IMQ.ts} +2 -2
  12. package/dist/chunks/{sync-operations-Y27F5T7W.ts → sync-operations-QQ2GXKVC.ts} +15 -9
  13. package/dist/chunks/{sync-operations-Y27F5T7W.ts.map → sync-operations-QQ2GXKVC.ts.map} +2 -2
  14. package/dist/index.ts +32 -7
  15. package/dist/index.ts.map +3 -3
  16. package/docs/settings.md +11 -3
  17. package/package.json +4 -4
  18. package/src/commands/command-execution.ts +3 -0
  19. package/src/commands/command-handler.ts +8 -1
  20. package/src/sync/merged-sync.ts +28 -24
  21. package/src/sync/sync-mutations.ts +9 -7
  22. package/src/sync/sync-queries.ts +1 -0
  23. package/src/ui/cancellable-operation.ts +47 -17
  24. package/src/ui/manager-attention.ts +3 -5
  25. package/src/ui/manager-state.ts +32 -15
  26. package/src/ui/manager-ui.ts +59 -87
  27. package/src/ui/merge-review.ts +79 -19
  28. package/src/ui/merged-sync-summary.ts +128 -0
  29. package/src/ui/operation-dialogs.ts +29 -0
  30. package/src/ui/settings-ui.ts +140 -46
  31. package/src/ui/setup/setup-actions.ts +2 -2
  32. package/src/ui/sync-confirmation.ts +153 -0
  33. package/dist/chunks/chunk-QDPED3XJ.ts.map +0 -7
  34. package/dist/chunks/manager-ui-MHAHAKG3.ts.map +0 -7
  35. /package/dist/chunks/{chunk-FJGNDYGC.ts.map → chunk-VHXBCEVF.ts.map} +0 -0
  36. /package/dist/chunks/{conflict-review-JANNPKKS.ts.map → conflict-review-JRFJIBEN.ts.map} +0 -0
  37. /package/dist/chunks/{manager-result-dispatcher-AAEAYX57.ts.map → manager-result-dispatcher-6MQKFCWB.ts.map} +0 -0
  38. /package/dist/chunks/{remote-selection-ui-Q2HUIBTL.ts.map → remote-selection-ui-6LJJ4IMQ.ts.map} +0 -0
@@ -111,135 +111,284 @@ import {
111
111
  } from "./chunk-HUZ5CR4W.ts";
112
112
 
113
113
  // src/ui/merge-review.ts
114
- import { runDocumentReview } from "@narumitw/pi-tui-kit";
115
- async function confirmMergeReview(ctx, title, content, signal, isCurrent, confirmationLabel = "Apply merged transfer") {
116
- const result = await runDocumentReview(ctx, {
117
- title,
118
- content,
119
- format: { kind: "text" },
120
- viewportSize: "adaptive",
121
- confirmation: { label: confirmationLabel },
122
- hint: "close",
123
- signal,
124
- isCurrent,
125
- onError: () => {
114
+ import { truncateToWidth as truncateToWidth2 } from "@earendil-works/pi-tui";
115
+ import { defineMenu, runCustomInteraction, runDocumentReview, runMenu } from "@narumitw/pi-tui-kit";
116
+
117
+ // src/ui/sync-confirmation.ts
118
+ import { Key, matchesKey, SelectList, truncateToWidth } from "@earendil-works/pi-tui";
119
+ import { HorizontalRule, hardWrapTerminalDocument, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
120
+ function createSyncConfirmation(options) {
121
+ const { tui, theme, keybindings } = options;
122
+ const choices = ["cancel", "approve", "details"];
123
+ const list = new SelectList(
124
+ [
125
+ { value: "cancel", label: "No, cancel" },
126
+ { value: "approve", label: `Yes, ${sanitizeTerminalText(options.confirmationLabel)}` },
127
+ { value: "details", label: "View details" }
128
+ ],
129
+ 3,
130
+ {
131
+ selectedPrefix: (text) => theme.fg("accent", text),
132
+ selectedText: (text) => theme.fg("accent", text),
133
+ description: (text) => theme.fg("muted", text),
134
+ scrollInfo: (text) => theme.fg("dim", text),
135
+ noMatch: (text) => theme.fg("muted", text)
126
136
  }
127
- });
128
- if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
129
- if (result.kind === "unsupported")
130
- throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
131
- return result.kind === "confirmed";
137
+ );
138
+ const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
139
+ let selected = 0;
140
+ let offset = 0;
141
+ let maximumOffset = 0;
142
+ let lastWidth = 0;
143
+ let lastRows = 0;
144
+ let seen = /* @__PURE__ */ new Set();
145
+ let summary = [];
146
+ let reviewed = false;
147
+ let disposed = false;
148
+ let finished = false;
149
+ let summaryStart = 0;
150
+ let summaryEnd = 0;
151
+ let actionsStart = 0;
152
+ const finish = (choice) => {
153
+ if (disposed || finished) return;
154
+ finished = true;
155
+ options.complete(choice);
156
+ };
157
+ const select = (index) => {
158
+ selected = (index + choices.length) % choices.length;
159
+ list.setSelectedIndex(selected);
160
+ };
161
+ list.onSelectionChange = (item) => {
162
+ selected = Math.max(0, choices.indexOf(item.value));
163
+ };
164
+ list.onSelect = (item) => {
165
+ const choice = choices[selected] ?? "cancel";
166
+ if (item.value === choice && (choice !== "approve" || reviewed)) finish(choice);
167
+ };
168
+ const component = {
169
+ render(width, expose = true) {
170
+ if (!Number.isFinite(width) || width <= 0) {
171
+ reviewed = false;
172
+ return [];
173
+ }
174
+ const cells = Math.floor(width);
175
+ const rows = Math.max(1, Math.floor(tui.terminal.rows) - 3);
176
+ if (cells !== lastWidth || rows !== lastRows) {
177
+ summary = options.lines.flatMap((line) => hardWrapTerminalDocument(line, cells));
178
+ seen = /* @__PURE__ */ new Set();
179
+ offset = 0;
180
+ select(0);
181
+ lastWidth = cells;
182
+ lastRows = rows;
183
+ }
184
+ const framed = rows >= 9;
185
+ const actions = list.render(cells);
186
+ const viewport = Math.max(0, rows - actions.length - 3 - (framed ? 2 : 0));
187
+ maximumOffset = Math.max(0, summary.length - viewport);
188
+ offset = Math.min(offset, maximumOffset);
189
+ const visible = summary.slice(offset, offset + viewport);
190
+ if (expose) for (let index = offset; index < offset + visible.length; index++) seen.add(index);
191
+ reviewed = viewport > 0 && seen.size === summary.length;
192
+ const hint = reviewed ? "Select an action." : "Navigation keys scroll the summary before selecting Yes.";
193
+ const body = [
194
+ theme.fg("accent", theme.bold(hardWrapTerminalDocument(options.title, cells).join(" "))),
195
+ ...visible.map((line) => theme.fg("text", line)),
196
+ theme.fg(
197
+ "muted",
198
+ `Summary ${visible.length ? offset + 1 : 0}-${offset + visible.length}/${summary.length}${reviewed ? "" : " \u2014 review before Yes"}`
199
+ ),
200
+ ...actions,
201
+ theme.fg("dim", hint)
202
+ ];
203
+ summaryStart = Number(framed) + 1;
204
+ summaryEnd = summaryStart + visible.length;
205
+ actionsStart = summaryEnd + 1;
206
+ const frame = framed ? [...rule.render(cells), ...body, ...rule.render(cells)] : body;
207
+ return frame.slice(0, rows).map((line) => truncateToWidth(line, cells, ""));
208
+ },
209
+ invalidate() {
210
+ list.invalidate();
211
+ rule.invalidate();
212
+ },
213
+ handleInput(data) {
214
+ if (disposed || finished) return;
215
+ if (matchesKey(data, Key.ctrl("c")) || keybindings.matches(data, "tui.select.cancel")) {
216
+ finish("cancel");
217
+ return;
218
+ }
219
+ component.render(tui.terminal.columns ?? lastWidth, false);
220
+ if (keybindings.matches(data, "tui.select.up")) {
221
+ if (!reviewed || selected === 0 && offset > 0) offset = Math.max(0, offset - 1);
222
+ else select(selected - 1);
223
+ } else if (keybindings.matches(data, "tui.select.down")) {
224
+ if (!reviewed || selected === 0 && offset < maximumOffset) offset = Math.min(maximumOffset, offset + 1);
225
+ else select(selected + 1);
226
+ } else if (keybindings.matches(data, "tui.select.confirm")) {
227
+ const choice = choices[selected] ?? "cancel";
228
+ if (choice !== "approve" || reviewed) finish(choice);
229
+ }
230
+ tui.requestRender();
231
+ },
232
+ handleMouse(event) {
233
+ if (disposed || finished || event.width !== lastWidth) return void 0;
234
+ component.render(event.width, false);
235
+ if (event.y >= summaryStart && event.y < summaryEnd && event.type === "wheel" && event.wheelDelta) {
236
+ offset = Math.max(0, Math.min(maximumOffset, offset + (event.wheelDelta < 0 ? -1 : 1)));
237
+ select(0);
238
+ tui.requestRender();
239
+ return { handled: true, render: true };
240
+ }
241
+ if (event.y < actionsStart || event.y >= actionsStart + choices.length || event.y >= lastRows) return void 0;
242
+ return list.handleMouse({ ...event, y: event.y - actionsStart, height: choices.length });
243
+ },
244
+ dispose() {
245
+ disposed = true;
246
+ seen.clear();
247
+ }
248
+ };
249
+ return component;
132
250
  }
133
251
 
134
- // src/ui/sync-format.ts
135
- function formatDiff(local, remote) {
136
- const localMap = fileHashMap(local);
137
- const remoteMap = fileHashMap(remote);
138
- const allPaths = [.../* @__PURE__ */ new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
139
- const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
140
- let changed = 0;
141
- for (const filePath of allPaths) {
142
- if (!Object.hasOwn(localMap, filePath)) {
143
- lines.push(`Remote only: ${snapshotPathLabel(filePath)}`);
144
- changed += 1;
145
- } else if (!Object.hasOwn(remoteMap, filePath)) {
146
- lines.push(`Local only: ${snapshotPathLabel(filePath)}`);
147
- changed += 1;
148
- } else if (localMap[filePath] !== remoteMap[filePath]) {
149
- lines.push(`Different: ${snapshotPathLabel(filePath)}`);
150
- changed += 1;
151
- }
252
+ // src/ui/merge-review.ts
253
+ async function confirmMergeReview(ctx, title, content, signal, isCurrent, confirmationLabel = "apply changes", summaryLines) {
254
+ const lines = summaryLines ?? [
255
+ ...content.split("\n").slice(0, 6).map((line) => truncateToWidth2(safeTerminalText(line), 100)),
256
+ "View details for the complete review."
257
+ ];
258
+ while (isCurrent() && !signal?.aborted) {
259
+ const choice = { value: "cancel" };
260
+ const menu = defineMenu({
261
+ start: "confirm",
262
+ screens: {
263
+ confirm: () => ({
264
+ kind: "choice",
265
+ title,
266
+ lines,
267
+ initialItemId: "cancel",
268
+ items: [
269
+ { id: "cancel", label: "No, cancel" },
270
+ { id: "approve", label: `Yes, ${confirmationLabel}` },
271
+ { id: "details", label: "View details" }
272
+ ],
273
+ action: "choose",
274
+ hint: "close"
275
+ })
276
+ },
277
+ actions: {
278
+ choose: ({ itemId }) => {
279
+ if (itemId === "approve" || itemId === "details") choice.value = itemId;
280
+ return { kind: "close" };
281
+ }
282
+ }
283
+ });
284
+ const result = ctx.mode === "tui" && ctx.hasUI ? await runCustomInteraction(ctx, {
285
+ create: ({ tui, theme, keybindings, complete }) => createSyncConfirmation({ title, lines, confirmationLabel, tui, theme, keybindings, complete }),
286
+ signal,
287
+ isCurrent,
288
+ onError: () => {
289
+ }
290
+ }) : await runMenu(ctx, menu, { getState: () => void 0, signal, isCurrent, onError: () => {
291
+ } });
292
+ if (!isCurrent() || signal?.aborted) return false;
293
+ if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
294
+ if (result.kind === "unsupported")
295
+ throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
296
+ if (result.kind === "completed") choice.value = result.value;
297
+ else if (result.kind !== "closed") return false;
298
+ if (choice.value === "approve") return true;
299
+ if (choice.value !== "details") return false;
300
+ const detail = await runDocumentReview(ctx, {
301
+ title: "Sync change details",
302
+ content,
303
+ format: { kind: "text" },
304
+ viewportSize: "adaptive",
305
+ hint: "back",
306
+ signal,
307
+ isCurrent,
308
+ onError: () => {
309
+ }
310
+ });
311
+ if (!isCurrent() || signal?.aborted) return false;
312
+ if (detail.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
313
+ if (detail.kind !== "cancelled" || detail.reason !== "back") return false;
152
314
  }
153
- if (changed === 0) lines.push("No file differences.");
154
- return lines.join("\n");
315
+ return false;
155
316
  }
156
- function formatSnapshotOnlyDiff(title, snapshot) {
157
- return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `Add: ${snapshotPathLabel(file.path)}`)].join(
158
- "\n"
317
+
318
+ // src/ui/merged-sync-summary.ts
319
+ import { truncateToWidth as truncateToWidth3 } from "@earendil-works/pi-tui";
320
+ function mergedSyncSummary(input) {
321
+ const local = changes(input.localBefore, input.localAfter);
322
+ const remote = changes(input.remoteBefore, input.remoteAfter);
323
+ const all = [...local, ...remote];
324
+ const updatesOnly = all.length > 0 && all.every((change) => change.action === "Update");
325
+ const verb = updatesOnly ? "Update" : "Apply changes to";
326
+ const title = local.length && remote.length ? "Apply local and remote changes?" : all.length ? `${verb} ${all.length} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}?` : input.publish ? "Publish a new remote snapshot?" : "Pi Sync is already up to date.";
327
+ const confirmationLabel = local.length && remote.length ? "apply local and remote changes" : all.length ? `${updatesOnly ? "update" : "apply changes to"} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}` : "publish remote snapshot";
328
+ const sources = new Map(
329
+ input.decisions.filter((item) => item.kind === "accepted").map((item) => [item.path, item.source])
159
330
  );
331
+ const label = (change, side) => {
332
+ const source = sources.get(change.path);
333
+ const suffix = source === "merged" ? " (merged content)" : side === "local" && source === "remote" ? change.action === "Delete" ? " (deleted remotely)" : " from remote" : "";
334
+ return `${change.action} ${snapshotPathLabel(change.path)}${suffix}`;
335
+ };
336
+ const section = (items, side, compact) => {
337
+ const heading = side === "local" ? "Local changes" : "Remote file changes";
338
+ if (!items.length) return [`${heading}: none`];
339
+ const counts = ["Add", "Update", "Delete"].flatMap((action) => {
340
+ const count = items.filter((item) => item.action === action).length;
341
+ return count ? [`${count} ${action.toLowerCase()}`] : [];
342
+ });
343
+ return [
344
+ `${heading}: ${counts.join(", ")}`,
345
+ ...(compact ? items.slice(0, 3) : items).map(
346
+ (item) => ` ${compact ? truncateToWidth3(label(item, side), 80) : label(item, side)}`
347
+ ),
348
+ ...compact && items.length > 3 ? [` ${items.length - 3} more files \u2014 View details`] : []
349
+ ];
350
+ };
351
+ const context = [`Setup: ${safeTerminalText(input.setupName)}`, `Remote: ${safeTerminalText(input.destination)}`];
352
+ const safety = [
353
+ `Remote snapshot publication: ${input.publish ? "yes" : "no"}`,
354
+ `Sessions: ${input.sessions ? "included \u2014 may contain private conversations" : "not included"}`,
355
+ input.groups.length ? `Unresolved groups: ${input.groups.length} \u2014 these paths stay unchanged on each side` : "Conflicts: none",
356
+ ...input.publish && input.capability === "read-check-write-verify" ? ["Warning: simultaneous remote writes can still race; visible changes are rejected."] : [],
357
+ ...input.publish && input.capability === "conditional-required" ? ["Remote publication requires verified conditional writes; verification failure stops the transfer."] : [],
358
+ "The current session is protected. A backup and recovery journal will be saved.",
359
+ "Resources will not reload automatically."
360
+ ];
361
+ return {
362
+ title,
363
+ confirmationLabel,
364
+ lines: [
365
+ ...context.map((line) => truncateToWidth3(line, 100)),
366
+ ...section(local, "local", true),
367
+ ...section(remote, "remote", true),
368
+ ...safety
369
+ ],
370
+ content: [
371
+ ...context,
372
+ "",
373
+ ...section(local, "local", false),
374
+ "",
375
+ ...section(remote, "remote", false),
376
+ "",
377
+ ...safety,
378
+ ...input.groups.flatMap(
379
+ (group) => group.paths.map((filePath) => `Unchanged (unresolved): ${snapshotPathLabel(filePath)}`)
380
+ )
381
+ ].join("\n")
382
+ };
160
383
  }
161
- function formatPushSummary(config, destination, upload, head, preservedRemoteFileCount = 0, remote) {
162
- return [
163
- `Sync setup: ${safeTerminalText(config.setupName)}`,
164
- `Storage location: ${safeTerminalText(destination)}`,
165
- `Upload ${upload.files.length} files from ${safeTerminalText(agentDir())}.`,
166
- `Sessions: ${upload.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
167
- head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
168
- "Publication effect: the backend's active head will reference the new immutable snapshot.",
169
- ...remote && inspectRemoteSelection(config.include, remote).kind === "different" ? ["Included-content policy effect: replace the differing remote selection with this setup's local selection."] : [],
170
- formatPublicationPreview(remote, upload),
171
- preservedRemoteFileCount > 0 ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.` : "Possible secrets were scanned before this prompt."
172
- ].join("\n");
173
- }
174
- function formatApplyPreview(local, remote) {
175
- return formatDirectionalChanges(local, remote, {
176
- add: "Add locally",
177
- update: "Update locally",
178
- remove: "Remove locally"
179
- });
180
- }
181
- function formatPullSummary(config, destination, local, remote, protectedSessionCount) {
182
- return [
183
- `Sync setup: ${safeTerminalText(config.setupName)}`,
184
- `Storage location: ${safeTerminalText(destination)}`,
185
- `Snapshot: ${safeTerminalText(remote.id)}`,
186
- `Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
187
- `Protected live sessions: ${protectedSessionCount || "none"}`,
188
- formatApplyPreview(local, remote),
189
- "A local backup is created before these writes/deletes. The remote active snapshot is unchanged."
190
- ].join("\n");
191
- }
192
- function formatRollbackSummary(config, destination, local, remote, requestedSnapshot, protectedSessionCount) {
193
- return [
194
- `Sync setup: ${safeTerminalText(config.setupName)}`,
195
- `Storage location: ${safeTerminalText(destination)}`,
196
- `Snapshot: ${safeTerminalText(requestedSnapshot)}`,
197
- `Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
198
- `Protected live sessions: ${protectedSessionCount || "none"}`,
199
- formatApplyPreview(local, remote),
200
- "A local backup is created before applying; the backend's active head will change."
201
- ].join("\n");
202
- }
203
- function formatPublicationPreview(remote, upload) {
204
- if (!remote) {
205
- return ["Remote is empty.", ...upload.files.map((file) => `Add remotely: ${snapshotPathLabel(file.path)}`)].join(
206
- "\n"
207
- );
208
- }
209
- return formatDirectionalChanges(remote, upload, {
210
- add: "Add remotely",
211
- update: "Update remotely",
212
- remove: "Remove remotely"
213
- });
214
- }
215
- function formatDirectionalChanges(before, after, labels) {
384
+ function changes(before, after) {
216
385
  const beforeMap = fileHashMap(before);
217
386
  const afterMap = fileHashMap(after);
218
- const paths = [.../* @__PURE__ */ new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])].sort();
219
- const lines = [];
220
- for (const filePath of paths) {
221
- if (!Object.hasOwn(beforeMap, filePath)) lines.push(`${labels.add}: ${snapshotPathLabel(filePath)}`);
222
- else if (!Object.hasOwn(afterMap, filePath)) lines.push(`${labels.remove}: ${snapshotPathLabel(filePath)}`);
223
- else if (beforeMap[filePath] !== afterMap[filePath]) lines.push(`${labels.update}: ${snapshotPathLabel(filePath)}`);
224
- }
225
- if (lines.length === 0) lines.push("No file changes.");
226
- return lines.join("\n");
227
- }
228
- function countPreservedRemoteFiles(local, upload) {
229
- const localPaths = new Set(local.files.map((file) => file.path));
230
- return upload.files.filter((file) => !localPaths.has(file.path)).length;
231
- }
232
- function publicationCapabilityDescription(capability) {
233
- switch (capability) {
234
- case "lease-protected":
235
- return "lease-protected (exact expected-ref update)";
236
- case "atomic-conditional":
237
- return "atomic-conditional (verified atomic precondition)";
238
- case "conditional-required":
239
- return "conditional-required (read-only until atomic preconditions are verified)";
240
- case "read-check-write-verify":
241
- return "read-check-write-verify (visible races rejected; simultaneous unseen races remain possible)";
242
- }
387
+ return [.../* @__PURE__ */ new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])].sort().flatMap((filePath) => {
388
+ if (!Object.hasOwn(beforeMap, filePath)) return [{ path: filePath, action: "Add" }];
389
+ if (!Object.hasOwn(afterMap, filePath)) return [{ path: filePath, action: "Delete" }];
390
+ return beforeMap[filePath] !== afterMap[filePath] ? [{ path: filePath, action: "Update" }] : [];
391
+ });
243
392
  }
244
393
 
245
394
  // src/sync/conflict-artifact-ownership.ts
@@ -1197,6 +1346,117 @@ async function resolveSettingsConflicts(config, state, local, remote, plan) {
1197
1346
  };
1198
1347
  }
1199
1348
 
1349
+ // src/ui/sync-format.ts
1350
+ function formatDiff(local, remote) {
1351
+ const localMap = fileHashMap(local);
1352
+ const remoteMap = fileHashMap(remote);
1353
+ const allPaths = [.../* @__PURE__ */ new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
1354
+ const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
1355
+ let changed = 0;
1356
+ for (const filePath of allPaths) {
1357
+ if (!Object.hasOwn(localMap, filePath)) {
1358
+ lines.push(`Remote only: ${snapshotPathLabel(filePath)}`);
1359
+ changed += 1;
1360
+ } else if (!Object.hasOwn(remoteMap, filePath)) {
1361
+ lines.push(`Local only: ${snapshotPathLabel(filePath)}`);
1362
+ changed += 1;
1363
+ } else if (localMap[filePath] !== remoteMap[filePath]) {
1364
+ lines.push(`Different: ${snapshotPathLabel(filePath)}`);
1365
+ changed += 1;
1366
+ }
1367
+ }
1368
+ if (changed === 0) lines.push("No file differences.");
1369
+ return lines.join("\n");
1370
+ }
1371
+ function formatSnapshotOnlyDiff(title, snapshot) {
1372
+ return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `Add: ${snapshotPathLabel(file.path)}`)].join(
1373
+ "\n"
1374
+ );
1375
+ }
1376
+ function formatPushSummary(config, destination, upload, head, preservedRemoteFileCount = 0, remote) {
1377
+ return [
1378
+ `Sync setup: ${safeTerminalText(config.setupName)}`,
1379
+ `Storage location: ${safeTerminalText(destination)}`,
1380
+ `Upload ${upload.files.length} files from ${safeTerminalText(agentDir())}.`,
1381
+ `Sessions: ${upload.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
1382
+ head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
1383
+ "Publication effect: the backend's active head will reference the new immutable snapshot.",
1384
+ ...remote && inspectRemoteSelection(config.include, remote).kind === "different" ? ["Included-content policy effect: replace the differing remote selection with this setup's local selection."] : [],
1385
+ formatPublicationPreview(remote, upload),
1386
+ preservedRemoteFileCount > 0 ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.` : "Possible secrets were scanned before this prompt."
1387
+ ].join("\n");
1388
+ }
1389
+ function formatApplyPreview(local, remote) {
1390
+ return formatDirectionalChanges(local, remote, {
1391
+ add: "Add locally",
1392
+ update: "Update locally",
1393
+ remove: "Remove locally"
1394
+ });
1395
+ }
1396
+ function formatPullSummary(config, destination, local, remote, protectedSessionCount) {
1397
+ return [
1398
+ `Sync setup: ${safeTerminalText(config.setupName)}`,
1399
+ `Storage location: ${safeTerminalText(destination)}`,
1400
+ `Snapshot: ${safeTerminalText(remote.id)}`,
1401
+ `Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
1402
+ `Protected live sessions: ${protectedSessionCount || "none"}`,
1403
+ formatApplyPreview(local, remote),
1404
+ "A local backup is created before these writes/deletes. The remote active snapshot is unchanged."
1405
+ ].join("\n");
1406
+ }
1407
+ function formatRollbackSummary(config, destination, local, remote, requestedSnapshot, protectedSessionCount) {
1408
+ return [
1409
+ `Sync setup: ${safeTerminalText(config.setupName)}`,
1410
+ `Storage location: ${safeTerminalText(destination)}`,
1411
+ `Snapshot: ${safeTerminalText(requestedSnapshot)}`,
1412
+ `Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
1413
+ `Protected live sessions: ${protectedSessionCount || "none"}`,
1414
+ formatApplyPreview(local, remote),
1415
+ "A local backup is created before applying; the backend's active head will change."
1416
+ ].join("\n");
1417
+ }
1418
+ function formatPublicationPreview(remote, upload) {
1419
+ if (!remote) {
1420
+ return ["Remote is empty.", ...upload.files.map((file) => `Add remotely: ${snapshotPathLabel(file.path)}`)].join(
1421
+ "\n"
1422
+ );
1423
+ }
1424
+ return formatDirectionalChanges(remote, upload, {
1425
+ add: "Add remotely",
1426
+ update: "Update remotely",
1427
+ remove: "Remove remotely"
1428
+ });
1429
+ }
1430
+ function formatDirectionalChanges(before, after, labels) {
1431
+ const beforeMap = fileHashMap(before);
1432
+ const afterMap = fileHashMap(after);
1433
+ const paths = [.../* @__PURE__ */ new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])].sort();
1434
+ const lines = [];
1435
+ for (const filePath of paths) {
1436
+ if (!Object.hasOwn(beforeMap, filePath)) lines.push(`${labels.add}: ${snapshotPathLabel(filePath)}`);
1437
+ else if (!Object.hasOwn(afterMap, filePath)) lines.push(`${labels.remove}: ${snapshotPathLabel(filePath)}`);
1438
+ else if (beforeMap[filePath] !== afterMap[filePath]) lines.push(`${labels.update}: ${snapshotPathLabel(filePath)}`);
1439
+ }
1440
+ if (lines.length === 0) lines.push("No file changes.");
1441
+ return lines.join("\n");
1442
+ }
1443
+ function countPreservedRemoteFiles(local, upload) {
1444
+ const localPaths = new Set(local.files.map((file) => file.path));
1445
+ return upload.files.filter((file) => !localPaths.has(file.path)).length;
1446
+ }
1447
+ function publicationCapabilityDescription(capability) {
1448
+ switch (capability) {
1449
+ case "lease-protected":
1450
+ return "lease-protected (exact expected-ref update)";
1451
+ case "atomic-conditional":
1452
+ return "atomic-conditional (verified atomic precondition)";
1453
+ case "conditional-required":
1454
+ return "conditional-required (read-only until atomic preconditions are verified)";
1455
+ case "read-check-write-verify":
1456
+ return "read-check-write-verify (visible races rejected; simultaneous unseen races remain possible)";
1457
+ }
1458
+ }
1459
+
1200
1460
  // src/sync/sync-decision.ts
1201
1461
  function createSyncDecision(options) {
1202
1462
  const { config, state, local, remote, kind } = options;
@@ -1536,26 +1796,31 @@ An explicit direction adopts this setup's included-content policy; no automatic
1536
1796
  await validate();
1537
1797
  if (!config.skipSecretScan && scanSnapshot(upload).length)
1538
1798
  throw new Error("Refusing to merge possible secrets. Review managed content before syncing.");
1539
- if (!options.yes && !await confirmMergeReview(
1540
- ctx,
1541
- "Merge independent Pi changes?",
1542
- [
1543
- `Sync setup: ${safeTerminalText(config.setupName)}`,
1544
- `Storage location: ${safeTerminalText(backend.destination)}`,
1545
- `Sessions: ${config.include.includes("sessions") ? "included \u2014 may contain private conversations" : "not included"}`,
1546
- `Local writes/deletions: ${plan.decisions.filter((item) => item.kind === "accepted" && (item.source === "remote" || item.source === "merged")).length}`,
1547
- formatApplyPreview(localRaw, after).split("\n").map(safeTerminalText).join("\n"),
1548
- `Unresolved dependency groups: ${groups.length} (withheld versions stay on each side; baselines do not advance)`,
1549
- ...groups.flatMap((group) => group.paths.map((filePath) => `Withheld: ${safeTerminalText(filePath)}`)),
1550
- `Remote publication: ${publish ? "yes" : "no"}`,
1551
- formatPublicationPreview(rawRemote, upload).split("\n").map(safeTerminalText).join("\n"),
1552
- `Backend publication: ${backend.capability}`,
1553
- "The current session is protected. A backup and recovery journal are retained. Resources are not reloaded."
1554
- ].join("\n"),
1555
- options.signal,
1556
- isCurrent
1557
- ))
1558
- return "cancelled";
1799
+ if (!options.yes) {
1800
+ const summary = mergedSyncSummary({
1801
+ setupName: config.setupName,
1802
+ destination: backend.destination,
1803
+ localBefore: localRaw,
1804
+ localAfter: after,
1805
+ remoteBefore: rawRemote,
1806
+ remoteAfter: upload,
1807
+ publish,
1808
+ sessions: config.include.includes("sessions"),
1809
+ groups,
1810
+ decisions: plan.decisions,
1811
+ capability: backend.capability
1812
+ });
1813
+ if (!await confirmMergeReview(
1814
+ ctx,
1815
+ summary.title,
1816
+ summary.content,
1817
+ options.signal,
1818
+ isCurrent,
1819
+ summary.confirmationLabel,
1820
+ summary.lines
1821
+ ))
1822
+ return "cancelled";
1823
+ }
1559
1824
  await validate();
1560
1825
  const refreshed = await createSnapshot(config.snapshotIdentity, snapshotOptions);
1561
1826
  if (!sameHashes(fileHashMap(refreshed), fileHashMap(localRaw)))
@@ -1791,6 +2056,7 @@ async function writeAcceptedState(config, head, snapshot, validate, progress) {
1791
2056
 
1792
2057
  export {
1793
2058
  confirmMergeReview,
2059
+ applySnapshot,
1794
2060
  formatDiff,
1795
2061
  formatSnapshotOnlyDiff,
1796
2062
  formatPushSummary,
@@ -1798,8 +2064,7 @@ export {
1798
2064
  formatRollbackSummary,
1799
2065
  countPreservedRemoteFiles,
1800
2066
  publicationCapabilityDescription,
1801
- applySnapshot,
1802
2067
  createSyncDecision,
1803
2068
  mergeSync
1804
2069
  };
1805
- //# sourceMappingURL=chunk-QDPED3XJ.ts.map
2070
+ //# sourceMappingURL=chunk-HUTYAPFP.ts.map