@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
@@ -1,5 +1,5 @@
1
1
  import { BorderedLoader, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { formatInteractionHints, runCustomInteraction } from "@narumitw/pi-tui-kit";
4
4
  import type { SetupPullOutcome } from "../sync/setup-switch.js";
5
5
  import type { SyncDecision } from "../sync/sync-decision.js";
@@ -16,12 +16,14 @@ export type RunRoute = (
16
16
  signal?: AbortSignal,
17
17
  onCommit?: () => void,
18
18
  target?: string,
19
+ onDialog?: (active: boolean) => void,
19
20
  ) => Promise<RunRouteResult | undefined>;
20
21
 
21
22
  export type CancellableOperationResult = RunRouteResult | { kind: "closed" } | { kind: "cancelled" };
22
23
 
23
24
  interface CancellableOperationOptions {
24
25
  commitAware?: boolean;
26
+ cancelAcrossDialogs?: boolean;
25
27
  cancelledMessage?: string | null;
26
28
  target?: string;
27
29
  signal?: AbortSignal;
@@ -36,6 +38,7 @@ export async function runCancellableOperation(
36
38
  ): Promise<CancellableOperationResult> {
37
39
  const {
38
40
  commitAware = false,
41
+ cancelAcrossDialogs = false,
39
42
  cancelledMessage = "Check cancelled; no settings or files were changed.",
40
43
  target,
41
44
  signal,
@@ -56,12 +59,45 @@ export async function runCancellableOperation(
56
59
  label: "cancel",
57
60
  },
58
61
  ]);
59
- const operation = runRoute(
60
- route,
61
- interactionSignal,
62
- commitAware ? () => (commitStarted = true) : undefined,
63
- target,
64
- ).then(
62
+ const cancel = (data: string) => {
63
+ // Raw terminal listeners run before Pi filters Kitty key releases.
64
+ if (isKeyRelease(data)) return false;
65
+ if (!matchesKey(data, Key.ctrl("c")) && !keybindings.matches(data, "tui.select.cancel")) return false;
66
+ if (commitStarted) {
67
+ ctx.ui.notify("Applying or publishing has started and cannot be cancelled safely.", "warning");
68
+ } else complete({ cancelled: true });
69
+ return true;
70
+ };
71
+ // Pi dialogs replace this loader and restore the normal editor. Defer
72
+ // ordinary keys while they are open; retain raw cancellation afterward.
73
+ // Ctrl+C remains the root flow's hard-cancel path regardless of focus.
74
+ let activeDialogs = 0;
75
+ const unsubscribe = cancelAcrossDialogs
76
+ ? ctx.ui.onTerminalInput((data) => {
77
+ if (isKeyRelease(data)) return;
78
+ if (activeDialogs > 0 && !matchesKey(data, Key.ctrl("c"))) return;
79
+ return cancel(data) ? { consume: true } : undefined;
80
+ })
81
+ : undefined;
82
+ let pendingRoute: ReturnType<RunRoute>;
83
+ try {
84
+ pendingRoute = runRoute(
85
+ route,
86
+ interactionSignal,
87
+ commitAware ? () => (commitStarted = true) : undefined,
88
+ target,
89
+ cancelAcrossDialogs
90
+ ? (active) => {
91
+ activeDialogs += active ? 1 : -1;
92
+ }
93
+ : undefined,
94
+ );
95
+ } catch (error) {
96
+ unsubscribe?.();
97
+ loader.dispose();
98
+ throw error;
99
+ }
100
+ const operation = pendingRoute.then(
65
101
  (result) => {
66
102
  routeResult = result;
67
103
  complete({});
@@ -80,17 +116,11 @@ export async function runCancellableOperation(
80
116
  ];
81
117
  },
82
118
  invalidate: () => loader.invalidate(),
83
- handleInput(data: string) {
84
- if (!matchesKey(data, Key.ctrl("c")) && !keybindings.matches(data, "tui.select.cancel")) {
85
- return;
86
- }
87
- if (commitStarted) {
88
- ctx.ui.notify("Applying or publishing has started and cannot be cancelled safely.", "warning");
89
- return;
90
- }
91
- complete({ cancelled: true });
119
+ handleInput: cancel,
120
+ dispose() {
121
+ unsubscribe?.();
122
+ loader.dispose();
92
123
  },
93
- dispose: () => loader.dispose(),
94
124
  waitForPending: () => operation,
95
125
  };
96
126
  },
@@ -13,9 +13,7 @@ export interface SyncManagerAttentionOptions {
13
13
  onSelectionResolved?: (expected: SyncAttentionState) => void;
14
14
  }
15
15
 
16
- export function attentionMainMenuItems(
17
- manager: ManagerDescription,
18
- ): ActionMenuItem<"main" | "more" | "recovery", "review-attention">[] {
16
+ export function attentionMainMenuItems(manager: ManagerDescription): ActionMenuItem<"main", "review-attention">[] {
19
17
  if (!manager.attention && manager.observation?.inspection.selectionState?.kind !== "different") return [];
20
18
  const disabled = manager.attentionReviewDisabled === true;
21
19
  return [
@@ -36,8 +34,8 @@ export function attentionMainMenuItems(
36
34
  export function blockedSyncMenuItem(
37
35
  label: string,
38
36
  manager: ManagerDescription,
39
- ): ActionMenuItem<"main" | "more" | "recovery", "sync"> | undefined {
40
- if (label !== "Sync now (recommended)" || !manager.attentionBlocksSync) return undefined;
37
+ ): ActionMenuItem<"main", "sync"> | undefined {
38
+ if (label !== "Sync now" || !manager.attentionBlocksSync) return undefined;
41
39
  return {
42
40
  id: "sync",
43
41
  label,
@@ -25,11 +25,12 @@ import { countValidSyncSetups } from "./sync-setups-ui.js";
25
25
  import { safeTerminalText } from "./terminal-text.js";
26
26
 
27
27
  export const MAIN_MENU_ACTIONS = [
28
- "Sync now (recommended)",
29
- "Switch sync setup",
30
- "Status & changes",
28
+ "Sync now",
29
+ "Pull from remote…",
30
+ "Push to remote…",
31
31
  "Settings",
32
- "More…",
32
+ "History",
33
+ "Diagnostics",
33
34
  ] as const;
34
35
 
35
36
  export interface ManagerDescription {
@@ -125,7 +126,9 @@ export async function describeManagerState(
125
126
  ? "Never synced"
126
127
  : "Unavailable";
127
128
  const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
128
- const mainActions = MAIN_MENU_ACTIONS.filter((action) => action !== "Switch sync setup" || canSwitch);
129
+ const mainActions: string[] = [...MAIN_MENU_ACTIONS];
130
+ if (canSwitch) mainActions.push("Switch sync setup");
131
+ if (syncState?.unresolved?.length) mainActions.unshift("Review unresolved conflicts");
129
132
  const ordinaryTitle = [
130
133
  "Manage sync",
131
134
  "",
@@ -137,7 +140,7 @@ export async function describeManagerState(
137
140
  `Last applied: ${lastAppliedSnapshot}`,
138
141
  ...(syncState?.unresolved?.length
139
142
  ? [
140
- `Unresolved: ${syncState.unresolved.length} dependency groups · review History & recovery`,
143
+ `Unresolved: ${syncState.unresolved.length} dependency groups · choose Review unresolved conflicts`,
141
144
  `Last observed: ${safeTerminalText(syncState.lastObservedSnapshot ?? "unknown")}`,
142
145
  ]
143
146
  : []),
@@ -189,7 +192,7 @@ export async function describeManagerState(
189
192
  "",
190
193
  "What do you want to do?",
191
194
  ].join("\n"),
192
- actions: ["Sync setups…", "Storage connections…", "History & recovery…", "Help"],
195
+ actions: ["Sync setups…", "Storage connections…", "History", "Help"],
193
196
  };
194
197
  }
195
198
  }
@@ -201,13 +204,19 @@ function operationActions(
201
204
  mainActions: string[],
202
205
  ) {
203
206
  if (operationCanRecover(operation)) {
204
- return ["Restore sync access… (recommended)", "Status & changes", "History & recovery…", "Help"];
207
+ return ["Restore sync access… (recommended)", "History", "Help"];
205
208
  }
206
209
  if (operation.kind !== "free") {
207
- return ["Refresh operation status", "Status & changes", "History & recovery…", "Help"];
210
+ return ["Refresh operation status", "History", "Help"];
208
211
  }
209
212
  return noSyncedContent
210
- ? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
213
+ ? [
214
+ ...(mainActions.includes("Review unresolved conflicts") ? ["Review unresolved conflicts"] : []),
215
+ "Settings",
216
+ ...(canSwitch ? ["Switch sync setup"] : []),
217
+ "History",
218
+ "Diagnostics",
219
+ ]
211
220
  : mainActions;
212
221
  }
213
222
 
@@ -217,16 +226,24 @@ function operationStatusLines(operation: OperationAvailability): string[] {
217
226
  return [];
218
227
  case "live": {
219
228
  const command = truncateToWidth(safeTerminalText(operation.lock.command), 16, "…");
220
- return [`Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Settings and More return.`];
229
+ return [
230
+ `Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Transfer actions and Settings return.`,
231
+ ];
221
232
  }
222
233
  case "busy":
223
- return ["Pi-sync may be starting or finishing. Wait, then refresh; Settings and More remain unavailable."];
234
+ return [
235
+ "Pi-sync may be starting or finishing. Wait, then refresh; Transfer actions and Settings remain unavailable.",
236
+ ];
224
237
  case "recoverable-stale":
225
- return ["Sync paused: old lock remains. Close other Pi sessions then restore; Settings and More return."];
238
+ return [
239
+ "Sync paused: old lock remains. Close other Pi sessions then restore; Transfer actions and Settings return.",
240
+ ];
226
241
  case "recoverable-unreadable":
227
- return ["Sync paused: owner unknown. Close other Pi sessions then restore; Settings and More return."];
242
+ return [
243
+ "Sync paused: owner unknown. Close other Pi sessions then restore; Transfer actions and Settings return.",
244
+ ];
228
245
  case "inspection-error":
229
- return ["Lock check failed. Fix path access, then refresh; Settings and More remain unavailable."];
246
+ return ["Lock check failed. Fix path access, then refresh; Transfer actions and Settings remain unavailable."];
230
247
  }
231
248
  }
232
249
 
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
3
  import { loadConfig } from "../settings/config.js";
4
- import { operationBlocksChanges, operationCanRecover } from "../state/operation-availability.js";
4
+ import { operationBlocksChanges } from "../state/operation-availability.js";
5
5
  import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
6
6
  import {
7
7
  attentionMainMenuItems,
@@ -28,12 +28,11 @@ export async function showSyncManager(
28
28
  await runRoute("help");
29
29
  return;
30
30
  }
31
- type Screen = "main" | "more" | "recovery";
31
+ type Screen = "main";
32
32
  type Action =
33
33
  | "review-attention"
34
34
  | "sync"
35
35
  | "switch"
36
- | "diff"
37
36
  | "settings"
38
37
  | "pull"
39
38
  | "push"
@@ -42,12 +41,10 @@ export async function showSyncManager(
42
41
  | "history"
43
42
  | "conflicts"
44
43
  | "doctor"
45
- | "unlock"
46
44
  | "recover"
47
45
  | "refresh"
48
46
  | "help"
49
- | "init"
50
- | "back";
47
+ | "init";
51
48
  interface State {
52
49
  manager: Awaited<ReturnType<typeof describeManagerState>>;
53
50
  }
@@ -63,54 +60,15 @@ export async function showSyncManager(
63
60
  return {
64
61
  kind: "actions",
65
62
  title: "Manage sync",
66
- lines: state.manager.title.split("\n").slice(1),
63
+ // Frequent transfers stay visible; status is advisory and Help remains a direct route.
64
+ lines: [
65
+ ...state.manager.title.split("\n").slice(1),
66
+ "Help: /sync help · Fresh status: /sync status · Differences: /sync diff",
67
+ ],
67
68
  items: operationFirst ? [...managerItems, ...attentionItems] : [...attentionItems, ...managerItems],
68
69
  hint: "close",
69
70
  };
70
71
  },
71
- more: () => ({
72
- kind: "actions",
73
- title: "More options",
74
- items: [
75
- { id: "pull", label: "Pull from remote…", action: "pull" },
76
- { id: "push", label: "Push to remote…", action: "push" },
77
- { id: "setups", label: "Sync setups…", action: "setups" },
78
- {
79
- id: "connections",
80
- label: "Storage connections…",
81
- action: "connections",
82
- },
83
- {
84
- id: "doctor",
85
- label: "Check setup",
86
- description: "Check current setup access. WebDAV also probes writes and repairs history.",
87
- action: "doctor",
88
- },
89
- { id: "recovery", label: "History & recovery…", to: "recovery" },
90
- { id: "help", label: "Help", action: "help" },
91
- { id: "back", label: "Back", action: "back" },
92
- ],
93
- hint: "back",
94
- }),
95
- recovery: ({ state }) => ({
96
- kind: "actions",
97
- title: "History & recovery",
98
- items: [
99
- { id: "history", label: "Browse history", action: "history" },
100
- { id: "conflicts", label: "Review unresolved conflicts", action: "conflicts" },
101
- {
102
- id: "doctor",
103
- label: "Check setup",
104
- description: "WebDAV also probes writes and repairs history.",
105
- action: "doctor",
106
- },
107
- ...(state.manager.operation && operationCanRecover(state.manager.operation)
108
- ? [{ id: "unlock", label: "Recover stale operation", action: "unlock" as const }]
109
- : []),
110
- { id: "back", label: "Back", action: "back" },
111
- ],
112
- hint: "back",
113
- }),
114
72
  },
115
73
  actions: {
116
74
  "review-attention": async () => {
@@ -170,15 +128,9 @@ export async function showSyncManager(
170
128
  const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
171
129
  return result === "pull-attempted" || result === "closed" ? { kind: "close" } : { kind: "stay" };
172
130
  },
173
- diff: async () => {
174
- const result = await runCancellableOperation(ctx, "Checking current sync setup…", "diff", runRoute, {
175
- signal: sessionSignal,
176
- });
177
- return result.kind === "closed" ? { kind: "close" } : { kind: "stay" };
178
- },
179
131
  settings: async () => {
180
- await showSyncSettings(ctx, runRoute, sessionSignal);
181
- return { kind: "stay" };
132
+ const result = await showSyncSettings(ctx, runRoute, sessionSignal);
133
+ return { kind: result === "exit" ? "close" : "stay" };
182
134
  },
183
135
  pull: async () => {
184
136
  const result = await runCancellableOperation(ctx, "Checking remote changes…", "pull", runRoute, {
@@ -207,8 +159,13 @@ export async function showSyncManager(
207
159
  return { kind: "stay" };
208
160
  },
209
161
  history: async () => {
210
- await runRoute("history");
211
- return { kind: "stay" };
162
+ const result = await runCancellableOperation(ctx, "Loading sync history…", "history", runRoute, {
163
+ signal: sessionSignal,
164
+ commitAware: true,
165
+ cancelAcrossDialogs: true,
166
+ cancelledMessage: "History review cancelled; no files were changed.",
167
+ });
168
+ return { kind: result.kind === "closed" ? "close" : "stay" };
212
169
  },
213
170
  doctor: async () => {
214
171
  const result = await runCancellableOperation(ctx, "Checking setup access…", "doctor", runRoute, {
@@ -216,11 +173,6 @@ export async function showSyncManager(
216
173
  });
217
174
  return { kind: result.kind === "closed" ? "close" : "stay" };
218
175
  },
219
- unlock: async ({ state, signal: actionSignal }) => {
220
- const result = await recoverSyncAccess(ctx, state.manager, runRoute, sessionSignal, actionSignal);
221
- if (result === "close") return { kind: "close" };
222
- return result === "restored" ? { kind: "to", screen: "main" } : { kind: "stay" };
223
- },
224
176
  recover: async ({ state, signal: actionSignal }) => {
225
177
  const result = await recoverSyncAccess(ctx, state.manager, runRoute, sessionSignal, actionSignal);
226
178
  return { kind: result === "close" ? "close" : "stay" };
@@ -242,7 +194,6 @@ export async function showSyncManager(
242
194
  const disposition = await dispatchManagerResult(ctx, result, "sync", runRoute, sessionSignal);
243
195
  return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
244
196
  },
245
- back: async () => ({ kind: "back" }),
246
197
  },
247
198
  });
248
199
  await runMenu(ctx, menu, {
@@ -273,26 +224,47 @@ export async function showSyncManager(
273
224
  function syncMainMenuItem(
274
225
  label: string,
275
226
  ): ActionMenuItem<
276
- "main" | "more" | "recovery",
277
- "sync" | "switch" | "diff" | "settings" | "setups" | "connections" | "recover" | "refresh" | "help" | "init"
227
+ "main",
228
+ | "sync"
229
+ | "pull"
230
+ | "push"
231
+ | "switch"
232
+ | "settings"
233
+ | "setups"
234
+ | "connections"
235
+ | "history"
236
+ | "conflicts"
237
+ | "doctor"
238
+ | "recover"
239
+ | "refresh"
240
+ | "help"
241
+ | "init"
278
242
  > {
279
- if (label === "More…") return { id: "more", label, to: "more" };
280
- if (label === "History & recovery…") return { id: "recovery", label, to: "recovery" };
281
- const actions = new Map<
282
- string,
283
- "sync" | "switch" | "diff" | "settings" | "setups" | "connections" | "recover" | "refresh" | "help" | "init"
284
- >([
285
- ["Sync now (recommended)", "sync"],
286
- ["Switch sync setup", "switch"],
287
- ["Status & changes", "diff"],
288
- ["Settings", "settings"],
289
- ["Restore sync access… (recommended)", "recover"],
290
- ["Refresh operation status", "refresh"],
291
- ["Sync setups…", "setups"],
292
- ["Storage connections…", "connections"],
293
- ["Help", "help"],
294
- ["Set up sync", "init"],
295
- ["Use existing settings", "init"],
296
- ]);
297
- return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" };
243
+ const actions = {
244
+ "Sync now": "sync",
245
+ "Pull from remote…": "pull",
246
+ "Push to remote…": "push",
247
+ "Switch sync setup": "switch",
248
+ Settings: "settings",
249
+ History: "history",
250
+ Diagnostics: "doctor",
251
+ "Review unresolved conflicts": "conflicts",
252
+ "Restore sync access… (recommended)": "recover",
253
+ "Refresh operation status": "refresh",
254
+ "Sync setups…": "setups",
255
+ "Storage connections…": "connections",
256
+ Help: "help",
257
+ "Set up sync": "init",
258
+ "Use existing settings": "init",
259
+ } as const;
260
+ const action = actions[label as keyof typeof actions];
261
+ if (!action) throw new Error(`Unknown sync manager action: ${label}`);
262
+ return {
263
+ id: action,
264
+ label,
265
+ action,
266
+ ...(action === "doctor"
267
+ ? { description: "Check setup access; WebDAV also probes writes and repairs history." }
268
+ : {}),
269
+ };
298
270
  }
@@ -1,28 +1,88 @@
1
1
  import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { runDocumentReview } from "@narumitw/pi-tui-kit";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { defineMenu, runCustomInteraction, runDocumentReview, runMenu } from "@narumitw/pi-tui-kit";
4
+ import { createSyncConfirmation, type SyncConfirmationChoice } from "./sync-confirmation.js";
5
+ import { safeTerminalText } from "./terminal-text.js";
3
6
 
4
- /** Cell-exact scrolling in TUI and paginated observable confirmation in RPC. */
7
+ type ReviewContext = ExtensionCommandContext | ExtensionContext;
8
+
9
+ /** One approval screen; full details are optional and never authorize a transfer. */
5
10
  export async function confirmMergeReview(
6
- ctx: ExtensionCommandContext | ExtensionContext,
11
+ ctx: ReviewContext,
7
12
  title: string,
8
13
  content: string,
9
14
  signal: AbortSignal | undefined,
10
15
  isCurrent: () => boolean,
11
- confirmationLabel = "Apply merged transfer",
16
+ confirmationLabel = "apply changes",
17
+ summaryLines?: readonly string[],
12
18
  ) {
13
- const result = await runDocumentReview(ctx, {
14
- title,
15
- content,
16
- format: { kind: "text" },
17
- viewportSize: "adaptive",
18
- confirmation: { label: confirmationLabel },
19
- hint: "close",
20
- signal,
21
- isCurrent,
22
- onError: () => {},
23
- });
24
- if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
25
- if (result.kind === "unsupported")
26
- throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
27
- return result.kind === "confirmed";
19
+ const lines = summaryLines ?? [
20
+ ...content
21
+ .split("\n")
22
+ .slice(0, 6)
23
+ .map((line) => truncateToWidth(safeTerminalText(line), 100)),
24
+ "View details for the complete review.",
25
+ ];
26
+ while (isCurrent() && !signal?.aborted) {
27
+ const choice: { value: "cancel" | "approve" | "details" } = { value: "cancel" };
28
+ const menu = defineMenu<undefined, "confirm", "choose", ReviewContext>({
29
+ start: "confirm",
30
+ screens: {
31
+ confirm: () => ({
32
+ kind: "choice",
33
+ title,
34
+ lines,
35
+ initialItemId: "cancel",
36
+ items: [
37
+ { id: "cancel", label: "No, cancel" },
38
+ { id: "approve", label: `Yes, ${confirmationLabel}` },
39
+ { id: "details", label: "View details" },
40
+ ],
41
+ action: "choose",
42
+ hint: "close",
43
+ }),
44
+ },
45
+ actions: {
46
+ choose: ({ itemId }) => {
47
+ if (itemId === "approve" || itemId === "details") choice.value = itemId;
48
+ return { kind: "close" };
49
+ },
50
+ },
51
+ });
52
+ const result =
53
+ ctx.mode === "tui" && ctx.hasUI
54
+ ? await runCustomInteraction<SyncConfirmationChoice, ReviewContext>(ctx, {
55
+ create: ({ tui, theme, keybindings, complete }) =>
56
+ createSyncConfirmation({ title, lines, confirmationLabel, tui, theme, keybindings, complete }),
57
+ signal,
58
+ isCurrent,
59
+ onError: () => {},
60
+ })
61
+ : await runMenu(ctx, menu, { getState: () => undefined, signal, isCurrent, onError: () => {} });
62
+ if (!isCurrent() || signal?.aborted) return false;
63
+ if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
64
+ if (result.kind === "unsupported")
65
+ throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
66
+ if (result.kind === "completed") choice.value = result.value;
67
+ else if (result.kind !== "closed") return false;
68
+ // Keep authorization separate from UI termination in both adapters.
69
+ if (choice.value === "approve") return true;
70
+ if (choice.value !== "details") return false;
71
+ const detail = await runDocumentReview(ctx, {
72
+ title: "Sync change details",
73
+ content,
74
+ format: { kind: "text" },
75
+ viewportSize: "adaptive",
76
+ hint: "back",
77
+ signal,
78
+ isCurrent,
79
+ onError: () => {},
80
+ });
81
+ if (!isCurrent() || signal?.aborted) return false;
82
+ if (detail.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
83
+ if (detail.kind !== "cancelled" || detail.reason !== "back") return false;
84
+ // A fresh menu discards remembered selection: returning from details always selects No.
85
+ // Unlike Pi's native Yes-first dialog, approval requires moving off the safe default.
86
+ }
87
+ return false;
28
88
  }