@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
@@ -0,0 +1,128 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { PublicationCapability } from "../backends/sync-backend.js";
3
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
4
+ import type { FileMergeDecision } from "../sync/file-merge-planner.js";
5
+ import { fileHashMap } from "../sync/sync-state.js";
6
+ import { safeTerminalText, snapshotPathLabel } from "./terminal-text.js";
7
+
8
+ interface FileChange {
9
+ path: string;
10
+ action: "Add" | "Update" | "Delete";
11
+ }
12
+
13
+ export interface MergedSyncSummaryInput {
14
+ setupName: string;
15
+ destination: string;
16
+ localBefore: Snapshot;
17
+ localAfter: Snapshot;
18
+ remoteBefore: Snapshot;
19
+ remoteAfter: Snapshot;
20
+ publish: boolean;
21
+ sessions: boolean;
22
+ groups: readonly { paths: readonly string[] }[];
23
+ decisions: readonly FileMergeDecision[];
24
+ capability: PublicationCapability;
25
+ }
26
+
27
+ /** Presentation only: counts describe snapshot differences, not planner decisions. */
28
+ export function mergedSyncSummary(input: MergedSyncSummaryInput) {
29
+ const local = changes(input.localBefore, input.localAfter);
30
+ const remote = changes(input.remoteBefore, input.remoteAfter);
31
+ const all = [...local, ...remote];
32
+ const updatesOnly = all.length > 0 && all.every((change) => change.action === "Update");
33
+ const verb = updatesOnly ? "Update" : "Apply changes to";
34
+ const title =
35
+ local.length && remote.length
36
+ ? "Apply local and remote changes?"
37
+ : all.length
38
+ ? `${verb} ${all.length} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}?`
39
+ : input.publish
40
+ ? "Publish a new remote snapshot?"
41
+ : "Pi Sync is already up to date.";
42
+ const confirmationLabel =
43
+ local.length && remote.length
44
+ ? "apply local and remote changes"
45
+ : all.length
46
+ ? `${updatesOnly ? "update" : "apply changes to"} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}`
47
+ : "publish remote snapshot";
48
+ const sources = new Map(
49
+ input.decisions.filter((item) => item.kind === "accepted").map((item) => [item.path, item.source]),
50
+ );
51
+ const label = (change: FileChange, side: "local" | "remote") => {
52
+ const source = sources.get(change.path);
53
+ const suffix =
54
+ source === "merged"
55
+ ? " (merged content)"
56
+ : side === "local" && source === "remote"
57
+ ? change.action === "Delete"
58
+ ? " (deleted remotely)"
59
+ : " from remote"
60
+ : "";
61
+ return `${change.action} ${snapshotPathLabel(change.path)}${suffix}`;
62
+ };
63
+ const section = (items: FileChange[], side: "local" | "remote", compact: boolean) => {
64
+ const heading = side === "local" ? "Local changes" : "Remote file changes";
65
+ if (!items.length) return [`${heading}: none`];
66
+ const counts = ["Add", "Update", "Delete"].flatMap((action) => {
67
+ const count = items.filter((item) => item.action === action).length;
68
+ return count ? [`${count} ${action.toLowerCase()}`] : [];
69
+ });
70
+ return [
71
+ `${heading}: ${counts.join(", ")}`,
72
+ ...(compact ? items.slice(0, 3) : items).map(
73
+ (item) => ` ${compact ? truncateToWidth(label(item, side), 80) : label(item, side)}`,
74
+ ),
75
+ ...(compact && items.length > 3 ? [` ${items.length - 3} more files — View details`] : []),
76
+ ];
77
+ };
78
+ const context = [`Setup: ${safeTerminalText(input.setupName)}`, `Remote: ${safeTerminalText(input.destination)}`];
79
+ const safety = [
80
+ `Remote snapshot publication: ${input.publish ? "yes" : "no"}`,
81
+ `Sessions: ${input.sessions ? "included — may contain private conversations" : "not included"}`,
82
+ input.groups.length
83
+ ? `Unresolved groups: ${input.groups.length} — these paths stay unchanged on each side`
84
+ : "Conflicts: none",
85
+ ...(input.publish && input.capability === "read-check-write-verify"
86
+ ? ["Warning: simultaneous remote writes can still race; visible changes are rejected."]
87
+ : []),
88
+ ...(input.publish && input.capability === "conditional-required"
89
+ ? ["Remote publication requires verified conditional writes; verification failure stops the transfer."]
90
+ : []),
91
+ "The current session is protected. A backup and recovery journal will be saved.",
92
+ "Resources will not reload automatically.",
93
+ ];
94
+ return {
95
+ title,
96
+ confirmationLabel,
97
+ lines: [
98
+ ...context.map((line) => truncateToWidth(line, 100)),
99
+ ...section(local, "local", true),
100
+ ...section(remote, "remote", true),
101
+ ...safety,
102
+ ],
103
+ content: [
104
+ ...context,
105
+ "",
106
+ ...section(local, "local", false),
107
+ "",
108
+ ...section(remote, "remote", false),
109
+ "",
110
+ ...safety,
111
+ ...input.groups.flatMap((group) =>
112
+ group.paths.map((filePath) => `Unchanged (unresolved): ${snapshotPathLabel(filePath)}`),
113
+ ),
114
+ ].join("\n"),
115
+ };
116
+ }
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,29 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Track History's Pi-owned selector/confirmation lifetime without inspecting renderer internals. */
4
+ export function trackOperationDialogs<Context extends ExtensionContext>(
5
+ ctx: Context,
6
+ onDialog?: (active: boolean) => void,
7
+ ): Context {
8
+ if (!onDialog) return ctx;
9
+ const runDialog = async <Value>(task: () => Promise<Value>): Promise<Value> => {
10
+ onDialog(true);
11
+ try {
12
+ return await task();
13
+ } finally {
14
+ onDialog(false);
15
+ }
16
+ };
17
+ const ui: ExtensionContext["ui"] = {
18
+ ...ctx.ui,
19
+ select: (...args) => runDialog(() => ctx.ui.select(...args)),
20
+ confirm: (...args) => runDialog(() => ctx.ui.confirm(...args)),
21
+ };
22
+ // Forward live context properties: copying the context would hide session
23
+ // replacement from mutation-owner revalidation after a dialog settles.
24
+ return new Proxy(ctx, {
25
+ get(target, key, receiver) {
26
+ return key === "ui" ? ui : Reflect.get(target, key, receiver);
27
+ },
28
+ });
29
+ }
@@ -14,7 +14,9 @@ import {
14
14
  import { captureMutationOwner } from "../sync/sync-local.js";
15
15
  import type { RunRoute } from "./cancellable-operation.js";
16
16
  import { dispatchManagerResult } from "./manager-result-dispatcher.js";
17
+ import { showEditSyncSetupStorage, showSyncSetupManager } from "./setup/setup-actions.js";
17
18
  import { AUTOMATIC_SYNC_DESCRIPTION } from "./setup/setup-prompts.js";
19
+ import { showStorageConnections } from "./storage-connections-ui.js";
18
20
  import { configureSyncStatus } from "./sync-status.js";
19
21
  import { safeTerminalText } from "./terminal-text.js";
20
22
 
@@ -24,13 +26,46 @@ export async function showSyncSettings(
24
26
  ctx: ExtensionCommandContext,
25
27
  runRoute: SyncSettingsRoute,
26
28
  signal?: AbortSignal,
27
- ) {
29
+ ): Promise<"back" | "exit"> {
30
+ let disposition: "back" | "exit" = "back";
31
+ const exitSettings = () => {
32
+ disposition = "exit";
33
+ return { kind: "close" as const };
34
+ };
28
35
  if (ctx.mode !== "tui") {
29
36
  ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info");
30
- return;
37
+ if (ctx.mode === "rpc" && ctx.hasUI) {
38
+ // Preserve RPC catalog management; preference/credential editing stays TUI-only.
39
+ const management = defineMenu<undefined, "management", "setups" | "connections", ExtensionCommandContext>({
40
+ start: "management",
41
+ screens: {
42
+ management: () => ({
43
+ kind: "actions",
44
+ title: "Sync settings management",
45
+ items: [
46
+ { id: "setups", label: "Manage sync setups", action: "setups" },
47
+ { id: "connections", label: "Manage storage connections", action: "connections" },
48
+ ],
49
+ hint: "back",
50
+ }),
51
+ },
52
+ actions: {
53
+ setups: async ({ signal: actionSignal }) => {
54
+ const result = await showSyncSetupManager(ctx, runRoute, actionSignal);
55
+ return result === "exit" ? exitSettings() : { kind: "stay" };
56
+ },
57
+ connections: async ({ signal: actionSignal }) => {
58
+ await showStorageConnections(ctx, actionSignal);
59
+ return { kind: "stay" };
60
+ },
61
+ },
62
+ });
63
+ await runMenu(ctx, management, { getState: () => undefined, signal, isCurrent: () => !signal?.aborted });
64
+ }
65
+ return disposition;
31
66
  }
32
67
  const initial = await loadConfig();
33
- if (signal?.aborted) return;
68
+ if (signal?.aborted) return "back";
34
69
  const setupName = initial.setupName;
35
70
  type Action =
36
71
  | "automatic"
@@ -42,7 +77,10 @@ export async function showSyncSettings(
42
77
  | "show-status"
43
78
  | "on-switch"
44
79
  | "include"
45
- | "remote-include";
80
+ | "remote-include"
81
+ | "storage-location"
82
+ | "setups"
83
+ | "connections";
46
84
  const menu = defineMenu<Awaited<ReturnType<typeof loadConfig>>, "settings", Action, ExtensionCommandContext>({
47
85
  start: "settings",
48
86
  screens: {
@@ -52,39 +90,9 @@ export async function showSyncSettings(
52
90
  lines: [
53
91
  `Sync setup: ${safeTerminalText(state.setupName)} · Storage connection: ${safeTerminalText(state.connectionName)}`,
54
92
  ],
93
+ // Flat, searchable task ordering: Content, Destination, Automation, Merge, Other.
94
+ // Kit has no heading rows; avoid focusable fake headings or a new component.
55
95
  items: [
56
- {
57
- id: "automatic",
58
- label: "Automatic sync",
59
- description: AUTOMATIC_SYNC_DESCRIPTION,
60
- currentValue: state.automatic ? "On" : "Off",
61
- values: ["On", "Off"],
62
- action: "automatic",
63
- },
64
- {
65
- id: "skipSecretScan",
66
- label: "Skip secret scan (all setups)",
67
- description: "All setups: allow pushes without checking managed local files for possible secrets.",
68
- currentValue: state.skipSecretScan ? "On" : "Off",
69
- values: ["On", "Off"],
70
- action: "skip-secret-scan",
71
- },
72
- {
73
- id: "showStatus",
74
- label: "Show status (all setups)",
75
- description: "All setups: show sync progress and attention in Pi status.",
76
- currentValue: state.showStatus ? "On" : "Off",
77
- values: ["On", "Off"],
78
- action: "show-status",
79
- },
80
- {
81
- id: "onSwitch",
82
- label: "After switching setup (all setups)",
83
- description: "All setups: ask before a pull review, open it automatically, or switch without pulling.",
84
- currentValue: setupSwitchActionLabel(state.onSwitch),
85
- values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
86
- action: "on-switch",
87
- },
88
96
  {
89
97
  id: "include",
90
98
  label: "Included content",
@@ -99,6 +107,49 @@ export async function showSyncSettings(
99
107
  currentValue: "Review",
100
108
  action: "remote-include",
101
109
  },
110
+ {
111
+ id: "localFields",
112
+ label: "Machine-local settings fields",
113
+ description:
114
+ "Root field names omitted from future portable snapshots. Policy changes require directional migration; old history remains.",
115
+ currentValue: `${state.localFields?.length ?? 0} fields · Edit`,
116
+ action: "local-fields",
117
+ },
118
+ {
119
+ id: "storageLocation",
120
+ label: "Storage location",
121
+ description: "Destination: edit this setup's bucket, branch, or path; remote history is not moved.",
122
+ currentValue:
123
+ state.backend.type === "s3"
124
+ ? `${safeTerminalText(state.backend.destination.bucket)} · ${safeTerminalText(state.backend.destination.prefix)}`
125
+ : state.backend.type === "git"
126
+ ? `${safeTerminalText(state.backend.destination.branch)} · ${safeTerminalText(state.backend.destination.directory)}`
127
+ : safeTerminalText(state.backend.destination.path),
128
+ action: "storage-location",
129
+ },
130
+ {
131
+ id: "setups",
132
+ label: "Manage sync setups",
133
+ description: "Destination: add, switch, edit, or remove sync setups.",
134
+ currentValue: "Open",
135
+ action: "setups",
136
+ },
137
+ {
138
+ id: "connections",
139
+ label: "Manage storage connections",
140
+ description:
141
+ "Destination: manage shared server addresses and credentials; edits may affect multiple setups.",
142
+ currentValue: "Open",
143
+ action: "connections",
144
+ },
145
+ {
146
+ id: "automatic",
147
+ label: "Automatic sync",
148
+ description: AUTOMATIC_SYNC_DESCRIPTION,
149
+ currentValue: state.automatic ? "On" : "Off",
150
+ values: ["On", "Off"],
151
+ action: "automatic",
152
+ },
102
153
  {
103
154
  id: "automaticTransfer",
104
155
  label: "Automatic transfer at startup",
@@ -108,6 +159,14 @@ export async function showSyncSettings(
108
159
  values: ["On", "Off"],
109
160
  action: "automatic-transfer",
110
161
  },
162
+ {
163
+ id: "onSwitch",
164
+ label: "After switching setup (all setups)",
165
+ description: "All setups: ask before a pull review, open it automatically, or switch without pulling.",
166
+ currentValue: setupSwitchActionLabel(state.onSwitch),
167
+ values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
168
+ action: "on-switch",
169
+ },
111
170
  {
112
171
  id: "mergeSettings",
113
172
  label: "Settings field merge (experimental)",
@@ -117,14 +176,6 @@ export async function showSyncSettings(
117
176
  values: ["On", "Off"],
118
177
  action: "merge-settings",
119
178
  },
120
- {
121
- id: "localFields",
122
- label: "Machine-local settings fields",
123
- description:
124
- "Root field names omitted from future portable snapshots. Policy changes require directional migration; old history remains.",
125
- currentValue: `${state.localFields?.length ?? 0} fields · Edit`,
126
- action: "local-fields",
127
- },
128
179
  {
129
180
  id: "contentPolicy",
130
181
  label: "Content / partial sync (experimental)",
@@ -140,10 +191,41 @@ export async function showSyncSettings(
140
191
  values: ["Off", "Content only", "Content & partial", "Partial only"],
141
192
  action: "content-policy",
142
193
  },
194
+ {
195
+ id: "skipSecretScan",
196
+ label: "Skip secret scan (all setups)",
197
+ description: "All setups: allow pushes without checking managed local files for possible secrets.",
198
+ currentValue: state.skipSecretScan ? "On" : "Off",
199
+ values: ["On", "Off"],
200
+ action: "skip-secret-scan",
201
+ },
202
+ {
203
+ id: "showStatus",
204
+ label: "Show status (all setups)",
205
+ description: "All setups: show sync progress and attention in Pi status.",
206
+ currentValue: state.showStatus ? "On" : "Off",
207
+ values: ["On", "Off"],
208
+ action: "show-status",
209
+ },
143
210
  ],
144
211
  }),
145
212
  },
146
213
  actions: {
214
+ "storage-location": async ({ signal: actionSignal }) => {
215
+ const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
216
+ await showEditSyncSetupStorage(ctx, setupName, editorSignal);
217
+ return resumeSettings(editorSignal);
218
+ },
219
+ setups: async ({ signal: actionSignal }) => {
220
+ const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
221
+ const result = await showSyncSetupManager(ctx, runRoute, editorSignal);
222
+ return result === "exit" ? exitSettings() : resumeSettings(editorSignal);
223
+ },
224
+ connections: async ({ signal: actionSignal }) => {
225
+ const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
226
+ await showStorageConnections(ctx, editorSignal);
227
+ return resumeSettings(editorSignal);
228
+ },
147
229
  "content-policy": async ({ value, signal: actionSignal }) => {
148
230
  const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
149
231
  const validate = captureMutationOwner(ctx, mutationSignal);
@@ -356,17 +438,29 @@ export async function showSyncSettings(
356
438
  if (reviewSignal.aborted) return { kind: "rejected" };
357
439
  if (review.kind === "route-result") {
358
440
  const disposition = await dispatchManagerResult(ctx, review.result, review.route, runRoute, reviewSignal);
359
- return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
441
+ return disposition.kind === "close" ? exitSettings() : { kind: "stay" };
360
442
  }
361
- return review.kind === "closed" || review.kind === "stale" ? { kind: "close" } : { kind: "stay" };
443
+ return review.kind === "closed" || review.kind === "stale" ? exitSettings() : { kind: "stay" };
362
444
  },
363
445
  },
364
446
  });
447
+ async function resumeSettings(editorSignal: AbortSignal) {
448
+ if (editorSignal.aborted) return { kind: "close" as const };
449
+ try {
450
+ const active = await loadConfig();
451
+ if (editorSignal.aborted || active.setupName !== setupName) return { kind: "close" as const };
452
+ // Stay reloads authoritative rows without pushing a duplicate screen or clearing search.
453
+ return { kind: "stay" as const };
454
+ } catch {
455
+ return { kind: "close" as const };
456
+ }
457
+ }
365
458
  await runMenu(ctx, menu, {
366
459
  getState: () => loadConfig(setupName),
367
460
  signal,
368
461
  isCurrent: () => !signal?.aborted,
369
462
  });
463
+ return disposition;
370
464
  }
371
465
 
372
466
  function notifySaveFailure(ctx: ExtensionCommandContext, error: unknown) {
@@ -22,7 +22,7 @@ export async function showSyncSetupManager(ctx: ExtensionCommandContext, runRout
22
22
  await showAddTarget(ctx, setupSignal);
23
23
  },
24
24
  edit: async (name, setupSignal) => {
25
- await showEditTarget(ctx, name, setupSignal);
25
+ await showEditSyncSetupStorage(ctx, name, setupSignal);
26
26
  },
27
27
  makeCurrent: async (name, setupSignal) => {
28
28
  const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
@@ -84,7 +84,7 @@ async function showAddTarget(ctx: ExtensionCommandContext, signal?: AbortSignal)
84
84
  await showAddS3Target(ctx, raw, profile, name, signal);
85
85
  }
86
86
 
87
- async function showEditTarget(ctx: ExtensionCommandContext, name: string, signal?: AbortSignal) {
87
+ export async function showEditSyncSetupStorage(ctx: ExtensionCommandContext, name: string, signal?: AbortSignal) {
88
88
  const partial = await loadPartialConfig(name);
89
89
  if (signal?.aborted) return;
90
90
  if (!partial.setupName) {
@@ -0,0 +1,153 @@
1
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, SelectList, type TUI, type TuiMouseEvent, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { HorizontalRule, hardWrapTerminalDocument, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
4
+
5
+ export type SyncConfirmationChoice = "cancel" | "approve" | "details";
6
+
7
+ /** Kit choice context is clipped on short terminals; this summary must remain reviewable. */
8
+ export function createSyncConfirmation(options: {
9
+ title: string;
10
+ lines: readonly string[];
11
+ confirmationLabel: string;
12
+ tui: TUI;
13
+ theme: Theme;
14
+ keybindings: KeybindingsManager;
15
+ complete(choice: SyncConfirmationChoice): void;
16
+ }) {
17
+ const { tui, theme, keybindings } = options;
18
+ const choices: SyncConfirmationChoice[] = ["cancel", "approve", "details"];
19
+ const list = new SelectList(
20
+ [
21
+ { value: "cancel", label: "No, cancel" },
22
+ { value: "approve", label: `Yes, ${sanitizeTerminalText(options.confirmationLabel)}` },
23
+ { value: "details", label: "View details" },
24
+ ],
25
+ 3,
26
+ {
27
+ selectedPrefix: (text) => theme.fg("accent", text),
28
+ selectedText: (text) => theme.fg("accent", text),
29
+ description: (text) => theme.fg("muted", text),
30
+ scrollInfo: (text) => theme.fg("dim", text),
31
+ noMatch: (text) => theme.fg("muted", text),
32
+ },
33
+ );
34
+ const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
35
+ let selected = 0;
36
+ let offset = 0;
37
+ let maximumOffset = 0;
38
+ let lastWidth = 0;
39
+ let lastRows = 0;
40
+ let seen = new Set<number>();
41
+ let summary: readonly string[] = [];
42
+ let reviewed = false;
43
+ let disposed = false;
44
+ let finished = false;
45
+ let summaryStart = 0;
46
+ let summaryEnd = 0;
47
+ let actionsStart = 0;
48
+ const finish = (choice: SyncConfirmationChoice) => {
49
+ if (disposed || finished) return;
50
+ finished = true;
51
+ options.complete(choice);
52
+ };
53
+ const select = (index: number) => {
54
+ selected = (index + choices.length) % choices.length;
55
+ list.setSelectedIndex(selected);
56
+ };
57
+ list.onSelectionChange = (item) => {
58
+ selected = Math.max(0, choices.indexOf(item.value as SyncConfirmationChoice));
59
+ };
60
+ list.onSelect = (item) => {
61
+ const choice = choices[selected] ?? "cancel";
62
+ if (item.value === choice && (choice !== "approve" || reviewed)) finish(choice);
63
+ };
64
+ const component = {
65
+ render(width: number, expose = true): string[] {
66
+ if (!Number.isFinite(width) || width <= 0) {
67
+ reviewed = false;
68
+ return [];
69
+ }
70
+ const cells = Math.floor(width);
71
+ const rows = Math.max(1, Math.floor(tui.terminal.rows) - 3);
72
+ if (cells !== lastWidth || rows !== lastRows) {
73
+ // Reflow can expose previously unseen rows or hide the selected action.
74
+ // Reset proof and selection rather than carry approval across a resize.
75
+ summary = options.lines.flatMap((line) => hardWrapTerminalDocument(line, cells));
76
+ seen = new Set();
77
+ offset = 0;
78
+ select(0);
79
+ lastWidth = cells;
80
+ lastRows = rows;
81
+ }
82
+ const framed = rows >= 9;
83
+ const actions = list.render(cells);
84
+ const viewport = Math.max(0, rows - actions.length - 3 - (framed ? 2 : 0));
85
+ maximumOffset = Math.max(0, summary.length - viewport);
86
+ offset = Math.min(offset, maximumOffset);
87
+ const visible = summary.slice(offset, offset + viewport);
88
+ if (expose) for (let index = offset; index < offset + visible.length; index++) seen.add(index);
89
+ reviewed = viewport > 0 && seen.size === summary.length;
90
+ // Describe actions without advertising configured keys that can be invalid,
91
+ // aliased, or shadowed by an earlier cancellation/navigation binding.
92
+ const hint = reviewed ? "Select an action." : "Navigation keys scroll the summary before selecting Yes.";
93
+ const body = [
94
+ theme.fg("accent", theme.bold(hardWrapTerminalDocument(options.title, cells).join(" "))),
95
+ ...visible.map((line) => theme.fg("text", line)),
96
+ theme.fg(
97
+ "muted",
98
+ `Summary ${visible.length ? offset + 1 : 0}-${offset + visible.length}/${summary.length}${reviewed ? "" : " — review before Yes"}`,
99
+ ),
100
+ ...actions,
101
+ theme.fg("dim", hint),
102
+ ];
103
+ summaryStart = Number(framed) + 1;
104
+ summaryEnd = summaryStart + visible.length;
105
+ actionsStart = summaryEnd + 1;
106
+ const frame = framed ? [...rule.render(cells), ...body, ...rule.render(cells)] : body;
107
+ // Very small terminals cannot display the summary and all choices together.
108
+ // Keep cancellation available, but never approve a zero-row summary viewport.
109
+ return frame.slice(0, rows).map((line) => truncateToWidth(line, cells, ""));
110
+ },
111
+ invalidate() {
112
+ list.invalidate();
113
+ rule.invalidate();
114
+ },
115
+ handleInput(data: string) {
116
+ if (disposed || finished) return;
117
+ if (matchesKey(data, Key.ctrl("c")) || keybindings.matches(data, "tui.select.cancel")) {
118
+ finish("cancel");
119
+ return;
120
+ }
121
+ // Refresh height/width proof before handling an input racing with terminal resize.
122
+ component.render(tui.terminal.columns ?? lastWidth, false);
123
+ if (keybindings.matches(data, "tui.select.up")) {
124
+ if (!reviewed || (selected === 0 && offset > 0)) offset = Math.max(0, offset - 1);
125
+ else select(selected - 1);
126
+ } else if (keybindings.matches(data, "tui.select.down")) {
127
+ if (!reviewed || (selected === 0 && offset < maximumOffset)) offset = Math.min(maximumOffset, offset + 1);
128
+ else select(selected + 1);
129
+ } else if (keybindings.matches(data, "tui.select.confirm")) {
130
+ const choice = choices[selected] ?? "cancel";
131
+ if (choice !== "approve" || reviewed) finish(choice);
132
+ }
133
+ tui.requestRender();
134
+ },
135
+ handleMouse(event: TuiMouseEvent) {
136
+ if (disposed || finished || event.width !== lastWidth) return undefined;
137
+ component.render(event.width, false);
138
+ if (event.y >= summaryStart && event.y < summaryEnd && event.type === "wheel" && event.wheelDelta) {
139
+ offset = Math.max(0, Math.min(maximumOffset, offset + (event.wheelDelta < 0 ? -1 : 1)));
140
+ select(0);
141
+ tui.requestRender();
142
+ return { handled: true, render: true };
143
+ }
144
+ if (event.y < actionsStart || event.y >= actionsStart + choices.length || event.y >= lastRows) return undefined;
145
+ return list.handleMouse({ ...event, y: event.y - actionsStart, height: choices.length });
146
+ },
147
+ dispose() {
148
+ disposed = true;
149
+ seen.clear();
150
+ },
151
+ };
152
+ return component;
153
+ }