@narumitw/pi-sync 0.54.1 → 0.54.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.
Files changed (102) hide show
  1. package/README.md +19 -11
  2. package/dist/chunks/{chunk-ORQRHEEO.ts → chunk-26PMXY7E.ts} +3 -3
  3. package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-2FETCGGT.ts} +2 -2
  4. package/dist/chunks/{chunk-HUTYAPFP.ts → chunk-35GM6XKM.ts} +275 -151
  5. package/dist/chunks/chunk-35GM6XKM.ts.map +7 -0
  6. package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-A3TFXY4O.ts} +2 -2
  7. package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-AAZDBIBJ.ts} +2 -2
  8. package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-CAGJOKB7.ts} +5 -5
  9. package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-E6ONFG57.ts} +15 -2
  10. package/dist/chunks/{chunk-QE7NZZWT.ts.map → chunk-E6ONFG57.ts.map} +2 -2
  11. package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-GC3QOL6J.ts} +2 -2
  12. package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-HMXUKU43.ts} +3 -3
  13. package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-I4JYVFUS.ts} +5 -6
  14. package/dist/chunks/chunk-I4JYVFUS.ts.map +7 -0
  15. package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-ITFR6NNQ.ts} +14 -2
  16. package/dist/chunks/chunk-ITFR6NNQ.ts.map +7 -0
  17. package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-KATKRR3D.ts} +18 -11
  18. package/dist/chunks/chunk-KATKRR3D.ts.map +7 -0
  19. package/dist/chunks/{chunk-ANWNQIR7.ts → chunk-KGE47GCZ.ts} +4 -4
  20. package/dist/chunks/chunk-KGE47GCZ.ts.map +7 -0
  21. package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-R35N4CL3.ts} +2 -2
  22. package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
  23. package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
  24. package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-VCL7TZYW.ts} +2 -2
  25. package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-XU2VXGQV.ts} +14 -8
  26. package/dist/chunks/chunk-XU2VXGQV.ts.map +7 -0
  27. package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-YTHQEW35.ts} +3 -3
  28. package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-4CZFB7WI.ts} +3 -3
  29. package/dist/chunks/{conflict-review-JRFJIBEN.ts → conflict-review-ZOTOHL2E.ts} +11 -10
  30. package/dist/chunks/{conflict-review-JRFJIBEN.ts.map → conflict-review-ZOTOHL2E.ts.map} +1 -1
  31. package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-UNHBVVON.ts} +3 -3
  32. package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-HVRTPEVK.ts} +7 -7
  33. package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-ZIN4BUSF.ts} +297 -136
  34. package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +7 -0
  35. package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-ICXVTCML.ts} +5 -3
  36. package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-H6AEVA3Q.ts} +6 -6
  37. package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y4P3O5B5.ts} +2 -2
  38. package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-ZMW5C4M7.ts} +4 -4
  39. package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-VW7F3JN4.ts} +5 -5
  40. package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-3EMS2J5B.ts} +5 -4
  41. package/dist/chunks/{sync-operations-QQ2GXKVC.ts → sync-operations-2CWST333.ts} +140 -24
  42. package/dist/chunks/sync-operations-2CWST333.ts.map +7 -0
  43. package/dist/index.ts +103 -68
  44. package/dist/index.ts.map +2 -2
  45. package/docs/settings.md +47 -10
  46. package/docs/tui-contract.md +61 -0
  47. package/package.json +1 -1
  48. package/src/commands/command-execution.ts +2 -1
  49. package/src/commands/command-types.ts +4 -0
  50. package/src/commands/command.ts +3 -1
  51. package/src/settings/config.ts +13 -10
  52. package/src/settings/settings-store.ts +1 -0
  53. package/src/settings/settings-types.ts +2 -0
  54. package/src/settings/settings-validation.ts +3 -0
  55. package/src/snapshot/snapshot-transaction.ts +4 -2
  56. package/src/state/merge-baseline-store.ts +18 -0
  57. package/src/sync/apply-review.ts +63 -0
  58. package/src/sync/automatic-sync.ts +45 -33
  59. package/src/sync/merged-sync.ts +202 -104
  60. package/src/sync/startup-check.ts +39 -21
  61. package/src/sync/startup-timings.ts +90 -0
  62. package/src/sync/sync-inspection.ts +8 -4
  63. package/src/sync/sync-mutations.ts +49 -11
  64. package/src/sync/sync-queries.ts +14 -3
  65. package/src/ui/manager-state.ts +10 -6
  66. package/src/ui/manager-ui.ts +57 -7
  67. package/src/ui/merge-review.ts +3 -3
  68. package/src/ui/merged-sync-summary.ts +2 -18
  69. package/src/ui/settings-frame.ts +55 -0
  70. package/src/ui/settings-ui.ts +141 -43
  71. package/src/ui/setup/setup-prompts.ts +4 -3
  72. package/src/ui/sync-behavior.ts +15 -0
  73. package/src/ui/sync-format.ts +69 -38
  74. package/src/ui/transfer-changes.ts +33 -0
  75. package/src/ui/transfer-review.ts +27 -0
  76. package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
  77. package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
  78. package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
  79. package/dist/chunks/chunk-ANWNQIR7.ts.map +0 -7
  80. package/dist/chunks/chunk-HUTYAPFP.ts.map +0 -7
  81. package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
  82. package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
  83. package/dist/chunks/sync-operations-QQ2GXKVC.ts.map +0 -7
  84. /package/dist/chunks/{chunk-ORQRHEEO.ts.map → chunk-26PMXY7E.ts.map} +0 -0
  85. /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-2FETCGGT.ts.map} +0 -0
  86. /package/dist/chunks/{chunk-V6VZ5TLG.ts.map → chunk-A3TFXY4O.ts.map} +0 -0
  87. /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-AAZDBIBJ.ts.map} +0 -0
  88. /package/dist/chunks/{chunk-5ZWQ33XL.ts.map → chunk-CAGJOKB7.ts.map} +0 -0
  89. /package/dist/chunks/{chunk-IJ6VV6FN.ts.map → chunk-GC3QOL6J.ts.map} +0 -0
  90. /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-HMXUKU43.ts.map} +0 -0
  91. /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-R35N4CL3.ts.map} +0 -0
  92. /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-VCL7TZYW.ts.map} +0 -0
  93. /package/dist/chunks/{chunk-UQ3ALBIB.ts.map → chunk-YTHQEW35.ts.map} +0 -0
  94. /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → conflict-artifacts-4CZFB7WI.ts.map} +0 -0
  95. /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-UNHBVVON.ts.map} +0 -0
  96. /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → manager-result-dispatcher-HVRTPEVK.ts.map} +0 -0
  97. /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → merge-baseline-store-ICXVTCML.ts.map} +0 -0
  98. /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → remote-selection-ui-H6AEVA3Q.ts.map} +0 -0
  99. /package/dist/chunks/{s3-backend-AQGV6LFT.ts.map → s3-backend-Y4P3O5B5.ts.map} +0 -0
  100. /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → setup-switch-ZMW5C4M7.ts.map} +0 -0
  101. /package/dist/chunks/{setup-wizard-GSDPDFC4.ts.map → setup-wizard-VW7F3JN4.ts.map} +0 -0
  102. /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → sync-inspection-3EMS2J5B.ts.map} +0 -0
@@ -0,0 +1,90 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ type StartupStage =
5
+ | "transaction recovery"
6
+ | "settings read"
7
+ | "config resolution"
8
+ | "inspection import"
9
+ | "inspection and guards"
10
+ | "backend initialization"
11
+ | "local snapshot"
12
+ | "sync state read"
13
+ | "remote head"
14
+ | "operations import"
15
+ | "automatic transfer"
16
+ | "transfer authorization"
17
+ | "transfer backend initialization"
18
+ | "transfer journal read"
19
+ | "transfer journal recovery"
20
+ | "transfer state read"
21
+ | "transfer local snapshot"
22
+ | "transfer remote head"
23
+ | "transfer snapshot download"
24
+ | "transfer unchanged verification"
25
+ | "transfer merge planning"
26
+ | "transfer settings merge"
27
+ | "transfer content merge"
28
+ | "transfer preflight"
29
+ | "transfer backup"
30
+ | "transfer journal write"
31
+ | "transfer remote publication"
32
+ | "transfer local apply"
33
+ | "transfer baseline write"
34
+ | "transfer cleanup";
35
+
36
+ /** Fixed labels and durations only: never retain errors, paths, settings, or payloads. */
37
+ export function createStartupTimings() {
38
+ const started = performance.now();
39
+ const stages = new Map<StartupStage, { ms: number; calls: number; interrupted: boolean }>();
40
+ let unchanged = false;
41
+ return {
42
+ markUnchanged() {
43
+ unchanged = true;
44
+ },
45
+ async measure<T>(stage: StartupStage, run: () => T | Promise<T>): Promise<T> {
46
+ const start = performance.now();
47
+ let completed = false;
48
+ try {
49
+ const result = await run();
50
+ completed = true;
51
+ return result;
52
+ } finally {
53
+ const previous = stages.get(stage);
54
+ stages.set(stage, {
55
+ ms: (previous?.ms ?? 0) + performance.now() - start,
56
+ calls: (previous?.calls ?? 0) + 1,
57
+ interrupted: Boolean(previous?.interrupted || !completed),
58
+ });
59
+ }
60
+ },
61
+ publish(
62
+ ctx: ExtensionContext,
63
+ enabled: boolean,
64
+ sessionSignal: AbortSignal,
65
+ scope: "local initialization" | "background startup",
66
+ outcome: "completed" | "stopped" = "completed",
67
+ ) {
68
+ if (!enabled || !ctx.hasUI || sessionSignal.aborted) return;
69
+ try {
70
+ ctx.ui.notify(
71
+ [
72
+ `pi-sync debug: ${scope} (${outcome})`,
73
+ ...Array.from(
74
+ stages,
75
+ ([stage, value]) =>
76
+ `${stage}: ${value.ms.toFixed(1)} ms${value.calls > 1 ? ` (${value.calls} calls)` : ""}${value.interrupted ? " (interrupted)" : ""}`,
77
+ ),
78
+ ...(unchanged ? ["transfer result: unchanged (fast path)"] : []),
79
+ `total: ${(performance.now() - started).toFixed(1)} ms`,
80
+ ].join("\n"),
81
+ "info",
82
+ );
83
+ } catch {
84
+ // Diagnostics must not change sync behavior if the UI is unavailable during teardown.
85
+ }
86
+ },
87
+ };
88
+ }
89
+
90
+ export type StartupTimings = ReturnType<typeof createStartupTimings>;
@@ -4,6 +4,7 @@ import { createSnapshot } from "../snapshot/snapshot.js";
4
4
  import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
5
5
  import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
6
6
  import { throwIfAborted } from "./signals.js";
7
+ import { createStartupTimings, type StartupTimings } from "./startup-timings.js";
7
8
  import { inspectRemoteSelection } from "./sync-policy.js";
8
9
  import { hasLocalChanges, remoteChangedSinceState } from "./sync-state.js";
9
10
 
@@ -13,15 +14,18 @@ export async function inspectSync(
13
14
  options: SnapshotOptions,
14
15
  signal?: AbortSignal,
15
16
  factory: SyncBackendFactory = createSyncBackend,
17
+ timings: StartupTimings = createStartupTimings(),
16
18
  ) {
17
19
  throwIfAborted(signal);
18
- const backend = await factory(config);
20
+ const backend = await timings.measure("backend initialization", () => factory(config));
19
21
  throwIfAborted(signal);
20
- const local = await createSnapshot(config.snapshotIdentity, { ...options, signal });
22
+ const local = await timings.measure("local snapshot", () =>
23
+ createSnapshot(config.snapshotIdentity, { ...options, signal }),
24
+ );
21
25
  throwIfAborted(signal);
22
- const state = await readStateForConfig(config);
26
+ const state = await timings.measure("sync state read", () => readStateForConfig(config));
23
27
  throwIfAborted(signal);
24
- const head = await backend.readHead(signal);
28
+ const head = await timings.measure("remote head", () => backend.readHead(signal));
25
29
  throwIfAborted(signal);
26
30
  return {
27
31
  head,
@@ -23,7 +23,7 @@ import { applySnapshot } from "../snapshot/snapshot-apply.js";
23
23
  import type { Snapshot } from "../snapshot/snapshot-types.js";
24
24
  import { pruneMergeBaselines } from "../state/merge-baseline-store.js";
25
25
  import type { SyncState } from "../state/state-types.js";
26
- import { readStateForConfig, writeStateForConfig } from "../state/sync-state-store.js";
26
+ import { readStateForConfig, syncStateFingerprint, writeStateForConfig } from "../state/sync-state-store.js";
27
27
  import {
28
28
  countPreservedRemoteFiles,
29
29
  formatPullSummary,
@@ -31,6 +31,8 @@ import {
31
31
  formatRollbackSummary,
32
32
  } from "../ui/sync-format.js";
33
33
  import { setSyncStatus } from "../ui/sync-status.js";
34
+ import { confirmTransferReview } from "../ui/transfer-review.js";
35
+ import { inspectCaseCleanup, revalidateCaseCleanup } from "./apply-review.js";
34
36
  import { confirmFieldMigration } from "./field-migration.js";
35
37
  import { overlayLocalFields, portableSnapshot, sameLocalFields, validatePortableSnapshot } from "./local-fields.js";
36
38
  import { readMergeJournal, requireNoMergeJournal, retireMergeJournal } from "./merge-journal.js";
@@ -155,7 +157,7 @@ export async function push(
155
157
  }
156
158
  }
157
159
 
158
- if (!remoteForUpload && head && config.localFields !== undefined)
160
+ if (!remoteForUpload && head && (config.localFields !== undefined || !options.yes))
159
161
  remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
160
162
  if (!(await confirmFieldMigration(ctx, config, state, remoteForUpload, options.force, options.signal))) {
161
163
  setSyncStatus(ctx, undefined);
@@ -208,12 +210,15 @@ export async function push(
208
210
  if (configIdentity !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
209
211
  throw new Error("Settings changed during push review.");
210
212
  validateMutation();
211
- if (config.localFields !== undefined || (await readMergeJournal(config))) {
213
+ if (!options.yes || config.localFields !== undefined || (await readMergeJournal(config))) {
212
214
  const current = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
213
215
  if (!sameHashes(fileHashMap(current), fileHashMap(localRaw)))
214
- throw new Error("Local content changed during recovery review; retry from current bytes.");
216
+ throw new Error("Local content changed during push review; retry from current bytes.");
215
217
  }
216
- requireResolvedDirectionalProgress(await readStateForConfig(config), options);
218
+ const currentState = await readStateForConfig(config);
219
+ if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
220
+ throw new Error("Baseline changed during push review.");
221
+ requireResolvedDirectionalProgress(currentState, options);
217
222
  validateMutation();
218
223
  const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
219
224
  signal: options.signal,
@@ -316,11 +321,23 @@ export async function pull(
316
321
  });
317
322
  }
318
323
 
324
+ const caseCleanup = options.yes ? [] : await inspectCaseCleanup(physical, options.signal);
325
+ validateMutation();
319
326
  if (
320
327
  !options.yes &&
321
- !(await ctx.ui.confirm(
328
+ !(await confirmTransferReview(
329
+ ctx,
322
330
  snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
323
- formatPullSummary(config, backend.destination, local, physical, protectedSessionPaths(ctx).size),
331
+ formatPullSummary(
332
+ config,
333
+ backend.destination,
334
+ local,
335
+ physical,
336
+ protectedSessionPaths(ctx),
337
+ caseCleanup.map((item) => item.path),
338
+ ),
339
+ options.signal,
340
+ validateMutation,
324
341
  ))
325
342
  ) {
326
343
  setSyncStatus(ctx, undefined);
@@ -353,7 +370,11 @@ export async function pull(
353
370
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
354
371
  const applySessionDir = await sessionDirForApply(ctx, physical);
355
372
  if (recoveringMerge) await requireFreshRecovery();
356
- requireResolvedDirectionalProgress(await readStateForConfig(config), options);
373
+ const currentState = await readStateForConfig(config);
374
+ if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
375
+ throw new Error("Baseline changed during pull review.");
376
+ requireResolvedDirectionalProgress(currentState, options);
377
+ if (!options.yes) await revalidateCaseCleanup(caseCleanup, physical, options.signal);
357
378
  validateMutation();
358
379
  options.onCommit?.();
359
380
  const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
@@ -573,11 +594,21 @@ export async function rollback(
573
594
  if (!(await confirmFieldMigration(ctx, config, state, currentRemote, true, options.signal))) return;
574
595
  validateMutation();
575
596
  const physical = overlayLocalFields(remote, local, config.localFields);
597
+ const caseCleanup = options.yes ? [] : await inspectCaseCleanup(physical, options.signal);
598
+ validateMutation();
576
599
 
577
600
  if (!options.yes) {
578
601
  const confirmed = await ctx.ui.confirm(
579
602
  snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
580
- formatRollbackSummary(config, backend.destination, local, physical, target, protectedSessionPaths(ctx).size),
603
+ formatRollbackSummary(
604
+ config,
605
+ backend.destination,
606
+ local,
607
+ physical,
608
+ target,
609
+ protectedSessionPaths(ctx),
610
+ caseCleanup.map((item) => item.path),
611
+ ),
581
612
  { signal: options.signal },
582
613
  );
583
614
  validateMutation();
@@ -599,7 +630,11 @@ export async function rollback(
599
630
  throwIfAborted(options.signal);
600
631
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
601
632
  const applySessionDir = await sessionDirForApply(ctx, remote);
602
- requireResolvedDirectionalProgress(await readStateForConfig(config), options, false);
633
+ const currentState = await readStateForConfig(config);
634
+ if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
635
+ throw new Error("Baseline changed during rollback review.");
636
+ requireResolvedDirectionalProgress(currentState, options, false);
637
+ if (!options.yes) await revalidateCaseCleanup(caseCleanup, physical, options.signal);
603
638
  validateMutation();
604
639
  options.onCommit?.();
605
640
  const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
@@ -724,9 +759,12 @@ async function confirmPush(
724
759
  ) {
725
760
  throwIfAborted(options.signal);
726
761
  if (options.yes) return true;
727
- const confirmed = await ctx.ui.confirm(
762
+ const confirmed = await confirmTransferReview(
763
+ ctx,
728
764
  title,
729
765
  formatPushSummary(config, backend.destination, upload, head, countPreservedRemoteFiles(local, upload), remote),
766
+ options.signal,
767
+ captureMutationOwner(ctx, options.signal),
730
768
  );
731
769
  throwIfAborted(options.signal);
732
770
  if (confirmed) return true;
@@ -7,6 +7,7 @@ import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
7
7
  import { createSnapshot, scanSnapshot } from "../snapshot/snapshot.js";
8
8
  import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
9
9
  import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "../state/lock.js";
10
+ import { readStateForConfig } from "../state/sync-state-store.js";
10
11
  import { formatDiff, formatSnapshotOnlyDiff, publicationCapabilityDescription } from "../ui/sync-format.js";
11
12
  import { setSyncStatus } from "../ui/sync-status.js";
12
13
  import { safeTerminalText } from "../ui/terminal-text.js";
@@ -185,8 +186,15 @@ export async function history(
185
186
  const backend = await factory(config);
186
187
  const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse();
187
188
  throwIfAborted(options.signal);
189
+ const lastApplied = await readStateForConfig(config)
190
+ .then((state) => state.lastAppliedSnapshot)
191
+ .catch(() => undefined);
192
+ throwIfAborted(options.signal);
193
+ const appliedDetails = lastApplied
194
+ ? `Last applied snapshot ID: ${safeTerminalText(lastApplied)}\nApplication time: unavailable. Snapshot dates are creation times.`
195
+ : "Last applied snapshot ID: unavailable.";
188
196
  if (snapshots.length === 0) {
189
- ctx.ui.notify("No remote pi-sync history found.", "info");
197
+ ctx.ui.notify(`No remote pi-sync history found.\n${appliedDetails}`, "info");
190
198
  return;
191
199
  }
192
200
 
@@ -197,7 +205,7 @@ export async function history(
197
205
  `${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshotId}${item.snapshotId === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
198
206
  );
199
207
  const selected = await ctx.ui.select(
200
- `History for sync setup “${safeTerminalText(config.setupName)}”\n\nChoose a snapshot to preview rollback.`,
208
+ `History for sync setup “${safeTerminalText(config.setupName)}”\n\n${appliedDetails}\n\nChoose a snapshot to preview rollback.`,
201
209
  [...labels, "Back"],
202
210
  { signal: options.signal },
203
211
  );
@@ -215,7 +223,10 @@ export async function history(
215
223
  return;
216
224
  }
217
225
  ctx.ui.notify(
218
- snapshots.map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`).join("\n"),
226
+ [
227
+ appliedDetails,
228
+ ...snapshots.map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`),
229
+ ].join("\n"),
219
230
  "info",
220
231
  );
221
232
  }
@@ -21,6 +21,7 @@ import {
21
21
  type SyncAttentionState,
22
22
  syncAttentionMatchesConfig,
23
23
  } from "./sync-attention.js";
24
+ import { startupTransferSummary } from "./sync-behavior.js";
24
25
  import { countValidSyncSetups } from "./sync-setups-ui.js";
25
26
  import { safeTerminalText } from "./terminal-text.js";
26
27
 
@@ -28,6 +29,8 @@ export const MAIN_MENU_ACTIONS = [
28
29
  "Sync now",
29
30
  "Pull from remote…",
30
31
  "Push to remote…",
32
+ "View differences",
33
+ "Check remote status",
31
34
  "Settings",
32
35
  "History",
33
36
  "Diagnostics",
@@ -121,7 +124,7 @@ export async function describeManagerState(
121
124
  const lastAppliedSnapshot = changesBlocked
122
125
  ? "Unavailable while operations are locked"
123
126
  : syncState?.lastAppliedSnapshot
124
- ? safeTerminalText(syncState.lastAppliedSnapshot)
127
+ ? `${truncateToWidth(safeTerminalText(syncState.lastAppliedSnapshot), 20, "…")} (application time unavailable; full ID in History)`
125
128
  : syncState
126
129
  ? "Never synced"
127
130
  : "Unavailable";
@@ -134,10 +137,7 @@ export async function describeManagerState(
134
137
  "",
135
138
  `Current sync setup: ${safeTerminalText(config.setupName)}`,
136
139
  `Storage: ${backendStorageDescription(config)}`,
137
- `Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} · ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
138
- `Automatic sync: ${automaticSyncSummary(config.automatic)}`,
139
- `Automatic transfer at startup: ${config.automaticTransfer ? "On — upload/replace/delete at idle; no reload" : "Off"}`,
140
- `Last applied: ${lastAppliedSnapshot}`,
140
+ "CURRENT STATE",
141
141
  ...(syncState?.unresolved?.length
142
142
  ? [
143
143
  `Unresolved: ${syncState.unresolved.length} dependency groups · choose Review unresolved conflicts`,
@@ -160,11 +160,15 @@ export async function describeManagerState(
160
160
  "Check-time observation only; synchronization rechecks current content.",
161
161
  ]
162
162
  : ["Remote status: Not checked"]),
163
+ `Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} · ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
164
+ `Automatic sync: ${automaticSyncSummary(config.automatic)}`,
165
+ `Startup transfer: ${startupTransferSummary(config)}`,
166
+ `Last applied: ${lastAppliedSnapshot}`,
163
167
  ...(noSyncedContent
164
168
  ? ["", "No included content is selected. Choose included content in Settings before syncing."]
165
169
  : []),
166
170
  "",
167
- "What do you want to do?",
171
+ "ACTIONS · transfer, inspect, or manage",
168
172
  ];
169
173
  return {
170
174
  title: (changesBlocked ? ["Manage sync", ...operationStatusLines(operation)] : ordinaryTitle).join("\n"),
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
- import { loadConfig } from "../settings/config.js";
3
+ import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
4
4
  import { operationBlocksChanges } from "../state/operation-availability.js";
5
+ import { captureMutationOwner } from "../sync/sync-local.js";
5
6
  import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
6
7
  import {
7
8
  attentionMainMenuItems,
@@ -11,12 +12,13 @@ import {
11
12
  } from "./manager-attention.js";
12
13
  import { recoverSyncAccess } from "./manager-recovery.js";
13
14
  import { dispatchManagerResult } from "./manager-result-dispatcher.js";
14
- import { describeManagerState } from "./manager-state.js";
15
+ import { backendStorageDescription, describeManagerState } from "./manager-state.js";
15
16
  import { showSyncSettings } from "./settings-ui.js";
16
17
  import { showSyncSetupManager } from "./setup/setup-actions.js";
17
18
  import { showSetupSwitcher } from "./setup/setup-switcher.js";
18
19
  import { showStorageConnections } from "./storage-connections-ui.js";
19
20
  import { observationMatchesConfig } from "./sync-attention.js";
21
+ import { safeTerminalText } from "./terminal-text.js";
20
22
 
21
23
  export async function showSyncManager(
22
24
  ctx: ExtensionCommandContext,
@@ -36,6 +38,8 @@ export async function showSyncManager(
36
38
  | "settings"
37
39
  | "pull"
38
40
  | "push"
41
+ | "diff"
42
+ | "status"
39
43
  | "setups"
40
44
  | "connections"
41
45
  | "history"
@@ -167,12 +171,43 @@ export async function showSyncManager(
167
171
  });
168
172
  return { kind: result.kind === "closed" ? "close" : "stay" };
169
173
  },
170
- doctor: async () => {
171
- const result = await runCancellableOperation(ctx, "Checking setup access…", "doctor", runRoute, {
174
+ diff: async () => {
175
+ const result = await runCancellableOperation(ctx, "Comparing local and remote…", "diff", runRoute, {
176
+ signal: sessionSignal,
177
+ });
178
+ return { kind: result.kind === "closed" ? "close" : "stay" };
179
+ },
180
+ status: async () => {
181
+ const result = await runCancellableOperation(ctx, "Checking remote status…", "status", runRoute, {
172
182
  signal: sessionSignal,
173
183
  });
174
184
  return { kind: result.kind === "closed" ? "close" : "stay" };
175
185
  },
186
+ doctor: async ({ signal: actionSignal }) => {
187
+ const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
188
+ const validate = captureMutationOwner(ctx, signal);
189
+ const config = await loadConfig();
190
+ validate();
191
+ const confirmed = await ctx.ui.confirm(
192
+ "Run diagnostics?",
193
+ `Setup: ${safeTerminalText(config.setupName)}\nStorage: ${backendStorageDescription(config)}\nS3/R2 reads the latest pointer without writing. Git reads remote snapshots and checks its cache. WebDAV probes conditional writes and cleanup, and can repair a missing history entry. Possible-secret checks still run. This is not the read-only Check remote status action.`,
194
+ { signal },
195
+ );
196
+ if (signal.aborted) return { kind: "close" };
197
+ validate();
198
+ if (!confirmed) return { kind: "stay" };
199
+ const latest = await loadConfig();
200
+ validate();
201
+ if (syncCheckConfigFingerprint(config) !== syncCheckConfigFingerprint(latest)) {
202
+ ctx.ui.notify("Setup changed during diagnostics review; reopen Diagnostics.", "warning");
203
+ return { kind: "stay" };
204
+ }
205
+ const result = await runCancellableOperation(ctx, "Checking setup access…", "doctor", runRoute, {
206
+ signal,
207
+ target: config.setupName,
208
+ });
209
+ return { kind: result.kind === "closed" ? "close" : "stay" };
210
+ },
176
211
  recover: async ({ state, signal: actionSignal }) => {
177
212
  const result = await recoverSyncAccess(ctx, state.manager, runRoute, sessionSignal, actionSignal);
178
213
  return { kind: result === "close" ? "close" : "stay" };
@@ -228,6 +263,8 @@ function syncMainMenuItem(
228
263
  | "sync"
229
264
  | "pull"
230
265
  | "push"
266
+ | "diff"
267
+ | "status"
231
268
  | "switch"
232
269
  | "settings"
233
270
  | "setups"
@@ -244,6 +281,8 @@ function syncMainMenuItem(
244
281
  "Sync now": "sync",
245
282
  "Pull from remote…": "pull",
246
283
  "Push to remote…": "push",
284
+ "View differences": "diff",
285
+ "Check remote status": "status",
247
286
  "Switch sync setup": "switch",
248
287
  Settings: "settings",
249
288
  History: "history",
@@ -263,8 +302,19 @@ function syncMainMenuItem(
263
302
  id: action,
264
303
  label,
265
304
  action,
266
- ...(action === "doctor"
267
- ? { description: "Check setup access; WebDAV also probes writes and repairs history." }
268
- : {}),
305
+ description:
306
+ action === "doctor"
307
+ ? "Manage: review backend checks before running."
308
+ : action === "settings" || action === "history" || action === "switch"
309
+ ? "Manage"
310
+ : action === "sync"
311
+ ? "Transfer: choose a safe direction or review conflicts."
312
+ : action === "pull"
313
+ ? "Transfer: remote → local; review writes and removals."
314
+ : action === "push"
315
+ ? "Transfer: local → remote active snapshot; review publication."
316
+ : action === "status" || action === "diff"
317
+ ? "Inspect: read current local and remote state without transferring files."
318
+ : undefined,
269
319
  };
270
320
  }
@@ -60,9 +60,9 @@ export async function confirmMergeReview(
60
60
  })
61
61
  : await runMenu(ctx, menu, { getState: () => undefined, signal, isCurrent, onError: () => {} });
62
62
  if (!isCurrent() || signal?.aborted) return false;
63
- if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
63
+ if (result.kind === "error") throw new Error("Sync review failed; no transfer was performed.");
64
64
  if (result.kind === "unsupported")
65
- throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
65
+ throw new Error("Sync review requires observable TUI or RPC; review the plan before using --yes.");
66
66
  if (result.kind === "completed") choice.value = result.value;
67
67
  else if (result.kind !== "closed") return false;
68
68
  // Keep authorization separate from UI termination in both adapters.
@@ -79,7 +79,7 @@ export async function confirmMergeReview(
79
79
  onError: () => {},
80
80
  });
81
81
  if (!isCurrent() || signal?.aborted) return false;
82
- if (detail.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
82
+ if (detail.kind === "error") throw new Error("Sync review failed; no transfer was performed.");
83
83
  if (detail.kind !== "cancelled" || detail.reason !== "back") return false;
84
84
  // A fresh menu discards remembered selection: returning from details always selects No.
85
85
  // Unlike Pi's native Yes-first dialog, approval requires moving off the safe default.
@@ -2,13 +2,8 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import type { PublicationCapability } from "../backends/sync-backend.js";
3
3
  import type { Snapshot } from "../snapshot/snapshot-types.js";
4
4
  import type { FileMergeDecision } from "../sync/file-merge-planner.js";
5
- import { fileHashMap } from "../sync/sync-state.js";
6
5
  import { safeTerminalText, snapshotPathLabel } from "./terminal-text.js";
7
-
8
- interface FileChange {
9
- path: string;
10
- action: "Add" | "Update" | "Delete";
11
- }
6
+ import { transferChanges as changes, type TransferChange as FileChange } from "./transfer-changes.js";
12
7
 
13
8
  export interface MergedSyncSummaryInput {
14
9
  setupName: string;
@@ -78,6 +73,7 @@ export function mergedSyncSummary(input: MergedSyncSummaryInput) {
78
73
  const context = [`Setup: ${safeTerminalText(input.setupName)}`, `Remote: ${safeTerminalText(input.destination)}`];
79
74
  const safety = [
80
75
  `Remote snapshot publication: ${input.publish ? "yes" : "no"}`,
76
+ "Remote removals affect the active snapshot; retained history is not erased.",
81
77
  `Sessions: ${input.sessions ? "included — may contain private conversations" : "not included"}`,
82
78
  input.groups.length
83
79
  ? `Unresolved groups: ${input.groups.length} — these paths stay unchanged on each side`
@@ -114,15 +110,3 @@ export function mergedSyncSummary(input: MergedSyncSummaryInput) {
114
110
  ].join("\n"),
115
111
  };
116
112
  }
117
-
118
- function changes(before: Snapshot, after: Snapshot): FileChange[] {
119
- const beforeMap = fileHashMap(before);
120
- const afterMap = fileHashMap(after);
121
- return [...new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])]
122
- .sort()
123
- .flatMap((filePath): FileChange[] => {
124
- if (!Object.hasOwn(beforeMap, filePath)) return [{ path: filePath, action: "Add" }];
125
- if (!Object.hasOwn(afterMap, filePath)) return [{ path: filePath, action: "Delete" }];
126
- return beforeMap[filePath] !== afterMap[filePath] ? [{ path: filePath, action: "Update" }] : [];
127
- });
128
- }
@@ -0,0 +1,55 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Adapt only public rendering/input boundaries; Kit still owns search, saves and focus. */
5
+ export function boundedSettingsUI(ctx: ExtensionCommandContext): ExtensionCommandContext["ui"] {
6
+ return {
7
+ ...ctx.ui,
8
+ custom: (factory, options) =>
9
+ ctx.ui.custom((tui, ...args) => {
10
+ // Kit pins search before the selected setting at a one-row budget. Render its
11
+ // smallest complete frame, then retain the active row when the real budget is smaller.
12
+ const terminal = new Proxy(tui.terminal, {
13
+ get: (target, key) => (key === "rows" ? Math.max(8, target.rows) : Reflect.get(target, key)),
14
+ });
15
+ const screenTui = new Proxy(tui, {
16
+ get(target, key) {
17
+ if (key === "terminal") return terminal;
18
+ const value = Reflect.get(target, key);
19
+ return typeof value === "function" ? value.bind(target) : value;
20
+ },
21
+ });
22
+ const guard = <
23
+ T extends {
24
+ render(width: number): string[];
25
+ handleMouse?: import("@earendil-works/pi-tui").Component["handleMouse"];
26
+ },
27
+ >(
28
+ component: T,
29
+ ): T => {
30
+ const render = component.render.bind(component);
31
+ const handleMouse = component.handleMouse?.bind(component);
32
+ let sourceRow = 0;
33
+ const compact = () => tui.terminal.rows < 8;
34
+ component.render = (width) => {
35
+ if (width <= 0) return [];
36
+ const lines = render(width);
37
+ if (!compact()) return lines;
38
+ const active = lines.findIndex((line) => stripVTControlCharacters(line).startsWith("→"));
39
+ const search = lines.findIndex((line) => stripVTControlCharacters(line).startsWith(">"));
40
+ sourceRow = active < 0 ? Math.max(0, search) : active;
41
+ return lines.slice(sourceRow, sourceRow + 1);
42
+ };
43
+ if (handleMouse)
44
+ component.handleMouse = (event) => {
45
+ if (event.width <= 0 || (compact() && event.y !== 0)) return undefined;
46
+ component.render(event.width);
47
+ return handleMouse(compact() ? { ...event, y: event.y + sourceRow } : event);
48
+ };
49
+ return component;
50
+ };
51
+ const component = factory(screenTui, ...args);
52
+ return component instanceof Promise ? component.then(guard) : guard(component);
53
+ }, options),
54
+ };
55
+ }