@sundaeswap/sprinkles 0.4.0 → 0.6.0

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 (140) hide show
  1. package/dist/cjs/Sprinkle/__tests__/encryption.test.js +22 -8
  2. package/dist/cjs/Sprinkle/__tests__/encryption.test.js.map +1 -1
  3. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +37 -46
  4. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  5. package/dist/cjs/Sprinkle/__tests__/field-utils.test.js +170 -0
  6. package/dist/cjs/Sprinkle/__tests__/field-utils.test.js.map +1 -0
  7. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js +283 -81
  8. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  9. package/dist/cjs/Sprinkle/__tests__/formatting.test.js +97 -0
  10. package/dist/cjs/Sprinkle/__tests__/formatting.test.js.map +1 -0
  11. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js +97 -7
  12. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  13. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js +30 -0
  14. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js.map +1 -1
  15. package/dist/cjs/Sprinkle/encryption.js +131 -0
  16. package/dist/cjs/Sprinkle/encryption.js.map +1 -0
  17. package/dist/cjs/Sprinkle/index.js +427 -438
  18. package/dist/cjs/Sprinkle/index.js.map +1 -1
  19. package/dist/cjs/Sprinkle/menus/array-menu.js +195 -0
  20. package/dist/cjs/Sprinkle/menus/array-menu.js.map +1 -0
  21. package/dist/cjs/Sprinkle/menus/field-menu.js +161 -0
  22. package/dist/cjs/Sprinkle/menus/field-menu.js.map +1 -0
  23. package/dist/cjs/Sprinkle/menus/index.js +33 -0
  24. package/dist/cjs/Sprinkle/menus/index.js.map +1 -0
  25. package/dist/cjs/Sprinkle/menus/object-menu.js +324 -0
  26. package/dist/cjs/Sprinkle/menus/object-menu.js.map +1 -0
  27. package/dist/cjs/Sprinkle/prompts.js +459 -0
  28. package/dist/cjs/Sprinkle/prompts.js.map +1 -0
  29. package/dist/cjs/Sprinkle/schemas.js +97 -0
  30. package/dist/cjs/Sprinkle/schemas.js.map +1 -0
  31. package/dist/cjs/Sprinkle/tx-dialog.js +101 -0
  32. package/dist/cjs/Sprinkle/tx-dialog.js.map +1 -0
  33. package/dist/cjs/Sprinkle/type-guards.js +89 -0
  34. package/dist/cjs/Sprinkle/type-guards.js.map +1 -0
  35. package/dist/cjs/Sprinkle/types.js +73 -0
  36. package/dist/cjs/Sprinkle/types.js.map +1 -0
  37. package/dist/cjs/Sprinkle/utils/field-utils.js +154 -0
  38. package/dist/cjs/Sprinkle/utils/field-utils.js.map +1 -0
  39. package/dist/cjs/Sprinkle/utils/formatting.js +126 -0
  40. package/dist/cjs/Sprinkle/utils/formatting.js.map +1 -0
  41. package/dist/cjs/Sprinkle/utils/index.js +56 -0
  42. package/dist/cjs/Sprinkle/utils/index.js.map +1 -0
  43. package/dist/cjs/Sprinkle/wallet.js +98 -0
  44. package/dist/cjs/Sprinkle/wallet.js.map +1 -0
  45. package/dist/esm/Sprinkle/__tests__/encryption.test.js +22 -8
  46. package/dist/esm/Sprinkle/__tests__/encryption.test.js.map +1 -1
  47. package/dist/esm/Sprinkle/__tests__/enhancements.test.js +37 -46
  48. package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  49. package/dist/esm/Sprinkle/__tests__/field-utils.test.js +168 -0
  50. package/dist/esm/Sprinkle/__tests__/field-utils.test.js.map +1 -0
  51. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +284 -82
  52. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  53. package/dist/esm/Sprinkle/__tests__/formatting.test.js +95 -0
  54. package/dist/esm/Sprinkle/__tests__/formatting.test.js.map +1 -0
  55. package/dist/esm/Sprinkle/__tests__/show-menu.test.js +98 -8
  56. package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  57. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js +30 -0
  58. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js.map +1 -1
  59. package/dist/esm/Sprinkle/encryption.js +117 -0
  60. package/dist/esm/Sprinkle/encryption.js.map +1 -0
  61. package/dist/esm/Sprinkle/index.js +248 -425
  62. package/dist/esm/Sprinkle/index.js.map +1 -1
  63. package/dist/esm/Sprinkle/menus/array-menu.js +190 -0
  64. package/dist/esm/Sprinkle/menus/array-menu.js.map +1 -0
  65. package/dist/esm/Sprinkle/menus/field-menu.js +155 -0
  66. package/dist/esm/Sprinkle/menus/field-menu.js.map +1 -0
  67. package/dist/esm/Sprinkle/menus/index.js +8 -0
  68. package/dist/esm/Sprinkle/menus/index.js.map +1 -0
  69. package/dist/esm/Sprinkle/menus/object-menu.js +318 -0
  70. package/dist/esm/Sprinkle/menus/object-menu.js.map +1 -0
  71. package/dist/esm/Sprinkle/prompts.js +443 -0
  72. package/dist/esm/Sprinkle/prompts.js.map +1 -0
  73. package/dist/esm/Sprinkle/schemas.js +91 -0
  74. package/dist/esm/Sprinkle/schemas.js.map +1 -0
  75. package/dist/esm/Sprinkle/tx-dialog.js +90 -0
  76. package/dist/esm/Sprinkle/tx-dialog.js.map +1 -0
  77. package/dist/esm/Sprinkle/type-guards.js +66 -0
  78. package/dist/esm/Sprinkle/type-guards.js.map +1 -0
  79. package/dist/esm/Sprinkle/types.js +66 -0
  80. package/dist/esm/Sprinkle/types.js.map +1 -0
  81. package/dist/esm/Sprinkle/utils/field-utils.js +145 -0
  82. package/dist/esm/Sprinkle/utils/field-utils.js.map +1 -0
  83. package/dist/esm/Sprinkle/utils/formatting.js +118 -0
  84. package/dist/esm/Sprinkle/utils/formatting.js.map +1 -0
  85. package/dist/esm/Sprinkle/utils/index.js +7 -0
  86. package/dist/esm/Sprinkle/utils/index.js.map +1 -0
  87. package/dist/esm/Sprinkle/wallet.js +90 -0
  88. package/dist/esm/Sprinkle/wallet.js.map +1 -0
  89. package/dist/types/Sprinkle/encryption.d.ts +43 -0
  90. package/dist/types/Sprinkle/encryption.d.ts.map +1 -0
  91. package/dist/types/Sprinkle/index.d.ts +17 -177
  92. package/dist/types/Sprinkle/index.d.ts.map +1 -1
  93. package/dist/types/Sprinkle/menus/array-menu.d.ts +31 -0
  94. package/dist/types/Sprinkle/menus/array-menu.d.ts.map +1 -0
  95. package/dist/types/Sprinkle/menus/field-menu.d.ts +34 -0
  96. package/dist/types/Sprinkle/menus/field-menu.d.ts.map +1 -0
  97. package/dist/types/Sprinkle/menus/index.d.ts +10 -0
  98. package/dist/types/Sprinkle/menus/index.d.ts.map +1 -0
  99. package/dist/types/Sprinkle/menus/object-menu.d.ts +34 -0
  100. package/dist/types/Sprinkle/menus/object-menu.d.ts.map +1 -0
  101. package/dist/types/Sprinkle/prompts.d.ts +119 -0
  102. package/dist/types/Sprinkle/prompts.d.ts.map +1 -0
  103. package/dist/types/Sprinkle/schemas.d.ts +125 -0
  104. package/dist/types/Sprinkle/schemas.d.ts.map +1 -0
  105. package/dist/types/Sprinkle/tx-dialog.d.ts +37 -0
  106. package/dist/types/Sprinkle/tx-dialog.d.ts.map +1 -0
  107. package/dist/types/Sprinkle/type-guards.d.ts +45 -0
  108. package/dist/types/Sprinkle/type-guards.d.ts.map +1 -0
  109. package/dist/types/Sprinkle/types.d.ts +115 -0
  110. package/dist/types/Sprinkle/types.d.ts.map +1 -0
  111. package/dist/types/Sprinkle/utils/field-utils.d.ts +47 -0
  112. package/dist/types/Sprinkle/utils/field-utils.d.ts.map +1 -0
  113. package/dist/types/Sprinkle/utils/formatting.d.ts +30 -0
  114. package/dist/types/Sprinkle/utils/formatting.d.ts.map +1 -0
  115. package/dist/types/Sprinkle/wallet.d.ts +27 -0
  116. package/dist/types/Sprinkle/wallet.d.ts.map +1 -0
  117. package/dist/types/tsconfig.build.tsbuildinfo +1 -1
  118. package/package.json +1 -1
  119. package/src/Sprinkle/__tests__/encryption.test.ts +23 -8
  120. package/src/Sprinkle/__tests__/enhancements.test.ts +34 -47
  121. package/src/Sprinkle/__tests__/field-utils.test.ts +191 -0
  122. package/src/Sprinkle/__tests__/fill-in-struct.test.ts +301 -86
  123. package/src/Sprinkle/__tests__/formatting.test.ts +115 -0
  124. package/src/Sprinkle/__tests__/show-menu.test.ts +102 -8
  125. package/src/Sprinkle/__tests__/tx-dialog.test.ts +30 -0
  126. package/src/Sprinkle/encryption.ts +130 -0
  127. package/src/Sprinkle/index.ts +368 -598
  128. package/src/Sprinkle/menus/array-menu.ts +191 -0
  129. package/src/Sprinkle/menus/field-menu.ts +145 -0
  130. package/src/Sprinkle/menus/index.ts +12 -0
  131. package/src/Sprinkle/menus/object-menu.ts +336 -0
  132. package/src/Sprinkle/prompts.ts +551 -0
  133. package/src/Sprinkle/schemas.ts +111 -0
  134. package/src/Sprinkle/tx-dialog.ts +100 -0
  135. package/src/Sprinkle/type-guards.ts +93 -0
  136. package/src/Sprinkle/types.ts +116 -0
  137. package/src/Sprinkle/utils/field-utils.ts +158 -0
  138. package/src/Sprinkle/utils/formatting.ts +127 -0
  139. package/src/Sprinkle/utils/index.ts +17 -0
  140. package/src/Sprinkle/wallet.ts +133 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Array menu module for menu-based struct editing.
3
+ * Shows array items and allows adding/removing/editing.
4
+ */
5
+
6
+ import { Separator } from "@inquirer/core";
7
+ import type { TArray, TSchema } from "@sinclair/typebox";
8
+ import { selectWithClear } from "../prompts.js";
9
+ import type { ArrayMenuResult, FieldState } from "../types.js";
10
+ import { UserCancelledError } from "../types.js";
11
+ import { formatValuePreview, formatBreadcrumb } from "../utils/formatting.js";
12
+ import { promptFieldMenu, displayFullValue } from "./field-menu.js";
13
+ import type { FillFunction } from "./object-menu.js";
14
+
15
+ /**
16
+ * Options for the array menu prompt.
17
+ */
18
+ export interface ArrayMenuOptions {
19
+ /** The array schema */
20
+ type: TArray;
21
+ /** Current path for display */
22
+ path: string[];
23
+ /** Type definitions for resolving refs */
24
+ defs: Record<string, TSchema>;
25
+ /** Existing array values */
26
+ defaults?: unknown[];
27
+ /** Function to fill individual items */
28
+ fillField: FillFunction;
29
+ }
30
+
31
+ /**
32
+ * Show a menu-based editor for an array.
33
+ * Allows adding, removing, and editing items.
34
+ *
35
+ * @param options - Array menu options
36
+ * @returns The edited array or back result
37
+ */
38
+ export async function promptArray<T>(
39
+ options: ArrayMenuOptions,
40
+ ): Promise<ArrayMenuResult<T>> {
41
+ const { type, path, defs, defaults = [], fillField } = options;
42
+
43
+ const itemType = type.items as TSchema;
44
+
45
+ // Initialize items from defaults
46
+ const items: T[] = [...(defaults as T[])];
47
+
48
+ // Main menu loop
49
+ while (true) {
50
+ // Build menu choices
51
+ const choices: Array<{ name: string; value: string } | Separator> = [];
52
+
53
+ // Add existing items
54
+ for (let i = 0; i < items.length; i++) {
55
+ const preview = formatValuePreview(items[i], 40);
56
+ choices.push({ name: `[${i}]: ${preview}`, value: `item:${i}` });
57
+ }
58
+
59
+ // Add separator and actions
60
+ choices.push(new Separator("\u2500\u2500\u2500 Actions \u2500\u2500\u2500"));
61
+ choices.push({ name: "Add item", value: "add" });
62
+
63
+ if (items.length > 0) {
64
+ choices.push({ name: "Remove item...", value: "remove" });
65
+ }
66
+
67
+ choices.push({ name: `Done (${items.length} item${items.length === 1 ? "" : "s"})`, value: "done" });
68
+ choices.push({ name: "Back", value: "back" });
69
+
70
+ // Show menu
71
+ const breadcrumb = formatBreadcrumb(path);
72
+ const message = breadcrumb
73
+ ? `Edit ${breadcrumb} [${items.length} items]:`
74
+ : `Edit array [${items.length} items]:`;
75
+
76
+ const selection = await selectWithClear({
77
+ message,
78
+ choices,
79
+ });
80
+
81
+ // Handle escape - same as done
82
+ if (selection === null) {
83
+ return { action: "done", value: items };
84
+ }
85
+
86
+ // Handle done
87
+ if (selection === "done") {
88
+ return { action: "done", value: items };
89
+ }
90
+
91
+ // Handle back
92
+ if (selection === "back") {
93
+ return { action: "back" };
94
+ }
95
+
96
+ // Handle add
97
+ if (selection === "add") {
98
+ try {
99
+ const newItem = await fillField(
100
+ itemType,
101
+ [...path, `[${items.length}]`],
102
+ defs,
103
+ undefined,
104
+ );
105
+ items.push(newItem as T);
106
+ } catch (e) {
107
+ if (e instanceof UserCancelledError) {
108
+ // Return to menu without adding
109
+ continue;
110
+ }
111
+ throw e;
112
+ }
113
+ continue;
114
+ }
115
+
116
+ // Handle remove
117
+ if (selection === "remove") {
118
+ const removeChoices = items.map((item, i) => ({
119
+ name: `[${i}]: ${formatValuePreview(item, 40)}`,
120
+ value: i,
121
+ }));
122
+ removeChoices.push({ name: "Cancel", value: -1 } as { name: string; value: number });
123
+
124
+ const indexToRemove = await selectWithClear({
125
+ message: "Select item to remove:",
126
+ choices: removeChoices,
127
+ });
128
+
129
+ if (indexToRemove !== null && indexToRemove !== -1) {
130
+ items.splice(indexToRemove as number, 1);
131
+ }
132
+ continue;
133
+ }
134
+
135
+ // Handle item selection
136
+ if (typeof selection === "string" && selection.startsWith("item:")) {
137
+ const index = parseInt(selection.slice(5), 10);
138
+ const currentValue = items[index];
139
+
140
+ // Create a FieldState for the item
141
+ const itemState: FieldState<T> = { status: "set", value: currentValue as T };
142
+
143
+ const menuResult = await promptFieldMenu({
144
+ fieldName: `[${index}]`,
145
+ fieldType: itemType,
146
+ state: itemState,
147
+ });
148
+
149
+ switch (menuResult.action) {
150
+ case "edit": {
151
+ try {
152
+ const newValue = await fillField(
153
+ itemType,
154
+ [...path, `[${index}]`],
155
+ defs,
156
+ currentValue,
157
+ );
158
+ items[index] = newValue as T;
159
+ } catch (e) {
160
+ if (e instanceof UserCancelledError) {
161
+ continue;
162
+ }
163
+ throw e;
164
+ }
165
+ break;
166
+ }
167
+
168
+ case "view": {
169
+ await displayFullValue(`[${index}]`, currentValue);
170
+ break;
171
+ }
172
+
173
+ case "clear": {
174
+ // Remove the item
175
+ items.splice(index, 1);
176
+ break;
177
+ }
178
+
179
+ case "setNull": {
180
+ items[index] = null as T;
181
+ break;
182
+ }
183
+
184
+ case "reset":
185
+ case "back":
186
+ // Do nothing
187
+ break;
188
+ }
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Field menu module for menu-based struct editing.
3
+ * Handles the sub-menu shown when selecting a field with an existing value.
4
+ */
5
+
6
+ import { Separator } from "@inquirer/core";
7
+ import type { TSchema } from "@sinclair/typebox";
8
+ import { selectWithClear } from "../prompts.js";
9
+ import type { FieldMenuResult, FieldState } from "../types.js";
10
+ import { isNullable, hasDefault, getDefault } from "../type-guards.js";
11
+ import { formatValuePreview } from "../utils/formatting.js";
12
+ import { bigIntReplacer } from "../encryption.js";
13
+
14
+ /**
15
+ * Options for the field menu prompt.
16
+ */
17
+ export interface FieldMenuOptions<T> {
18
+ /** The field name */
19
+ fieldName: string;
20
+ /** The field's schema */
21
+ fieldType: TSchema;
22
+ /** Current state of the field */
23
+ state: FieldState<T>;
24
+ }
25
+
26
+ /**
27
+ * Show a sub-menu for a field that has a value.
28
+ * Provides options to edit, view, clear, set null, or reset to default.
29
+ *
30
+ * @param options - Field menu options
31
+ * @returns The user's chosen action
32
+ */
33
+ export async function promptFieldMenu<T>(
34
+ options: FieldMenuOptions<T>,
35
+ ): Promise<FieldMenuResult<T>> {
36
+ const { fieldName, fieldType, state } = options;
37
+
38
+ const nullable = isNullable(fieldType);
39
+ const hasDefaultVal = hasDefault(fieldType);
40
+ const defaultVal = hasDefaultVal ? getDefault(fieldType) : undefined;
41
+
42
+ // Build the display of current value
43
+ const currentDisplay =
44
+ state.status === "null"
45
+ ? "null"
46
+ : state.status === "set"
47
+ ? formatValuePreview(state.value, 40)
48
+ : "[not set]";
49
+
50
+ const choices: Array<{ name: string; value: string } | Separator> = [];
51
+
52
+ // Always offer edit
53
+ choices.push({ name: "Edit value", value: "edit" });
54
+
55
+ // View full value (only if there's a value to view)
56
+ if (state.status === "set") {
57
+ choices.push({ name: "View full value", value: "view" });
58
+ }
59
+
60
+ // For nullable fields with value: offer "Set to null" and "Clear value"
61
+ if (nullable && state.status === "set") {
62
+ choices.push({ name: "Set to null", value: "setNull" });
63
+ choices.push({ name: "Clear value (remove from output)", value: "clear" });
64
+ }
65
+
66
+ // For nullable fields that are null: offer "Clear value" to remove null
67
+ if (nullable && state.status === "null") {
68
+ choices.push({ name: "Clear value (remove from output)", value: "clear" });
69
+ }
70
+
71
+ // For non-nullable fields with default: offer "Reset to default"
72
+ if (hasDefaultVal && !nullable && state.status === "set") {
73
+ const defaultPreview = formatValuePreview(defaultVal, 20);
74
+ choices.push({
75
+ name: `Reset to default (${defaultPreview})`,
76
+ value: "reset",
77
+ });
78
+ }
79
+
80
+ choices.push(new Separator());
81
+ choices.push({ name: "Back (keep current)", value: "back" });
82
+
83
+ const selection = await selectWithClear({
84
+ message: `${fieldName}: ${currentDisplay}`,
85
+ choices,
86
+ });
87
+
88
+ // Escape returns to menu
89
+ if (selection === null) {
90
+ return { action: "back" };
91
+ }
92
+
93
+ switch (selection) {
94
+ case "edit":
95
+ // Caller will handle the actual editing
96
+ return { action: "edit" };
97
+
98
+ case "view":
99
+ return { action: "view" };
100
+
101
+ case "clear":
102
+ return { action: "clear" };
103
+
104
+ case "setNull":
105
+ return { action: "setNull" };
106
+
107
+ case "reset":
108
+ return { action: "reset", defaultValue: defaultVal as T };
109
+
110
+ case "back":
111
+ default:
112
+ return { action: "back" };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Display the full value of a field.
118
+ * Shows formatted JSON and waits for user to press Enter.
119
+ *
120
+ * @param fieldName - The field name
121
+ * @param value - The value to display
122
+ */
123
+ export async function displayFullValue(
124
+ fieldName: string,
125
+ value: unknown,
126
+ ): Promise<void> {
127
+ const header = `Current value of ${fieldName}:`;
128
+ const jsonStr = JSON.stringify(value, bigIntReplacer, 2);
129
+ const jsonLines = jsonStr.split("\n").length;
130
+
131
+ console.log("");
132
+ console.log(header);
133
+ console.log(jsonStr);
134
+ console.log("");
135
+
136
+ // Wait for user to press Enter
137
+ await selectWithClear({
138
+ message: "Press Enter to continue...",
139
+ choices: [{ name: "Continue", value: "continue" }],
140
+ });
141
+
142
+ // Clear all the output lines: empty line + header + json lines + empty line
143
+ const totalLines = 1 + 1 + jsonLines + 1;
144
+ process.stdout.write("\x1b[1A\x1b[2K".repeat(totalLines) + "\x1b[G");
145
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Menu modules for menu-based struct editing.
3
+ */
4
+
5
+ export { promptFieldMenu, displayFullValue } from "./field-menu.js";
6
+ export type { FieldMenuOptions } from "./field-menu.js";
7
+
8
+ export { promptObject } from "./object-menu.js";
9
+ export type { ObjectMenuOptions, FillFunction } from "./object-menu.js";
10
+
11
+ export { promptArray } from "./array-menu.js";
12
+ export type { ArrayMenuOptions } from "./array-menu.js";
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Object menu module for menu-based struct editing.
3
+ * Shows all fields of an object and allows non-linear editing.
4
+ */
5
+
6
+ import { Separator } from "@inquirer/core";
7
+ import type { TObject, TSchema } from "@sinclair/typebox";
8
+ import { selectWithClear, confirmWithClear, clearLines } from "../prompts.js";
9
+ import colors from "yoctocolors-cjs";
10
+ import type { FieldState, ObjectMenuResult } from "../types.js";
11
+ import { UserCancelledError } from "../types.js";
12
+ import { isOptional, hasDefault, getDefault, isNullable, isLiteral } from "../type-guards.js";
13
+ import {
14
+ buildFieldLabel,
15
+ countRequiredFields,
16
+ allRequiredFieldsFilled,
17
+ getInitialFieldState,
18
+ isFieldRequired,
19
+ } from "../utils/field-utils.js";
20
+ import { formatBreadcrumb } from "../utils/formatting.js";
21
+ import { promptFieldMenu, displayFullValue } from "./field-menu.js";
22
+
23
+ /**
24
+ * Type for the fill function that handles individual field types.
25
+ */
26
+ export type FillFunction = <U extends TSchema>(
27
+ type: U,
28
+ path: string[],
29
+ defs: Record<string, TSchema>,
30
+ def?: unknown,
31
+ ) => Promise<unknown>;
32
+
33
+ /**
34
+ * Options for the object menu prompt.
35
+ */
36
+ export interface ObjectMenuOptions {
37
+ /** The object schema */
38
+ type: TObject;
39
+ /** Current path for display */
40
+ path: string[];
41
+ /** Type definitions for resolving refs */
42
+ defs: Record<string, TSchema>;
43
+ /** Default/existing values */
44
+ defaults?: Record<string, unknown>;
45
+ /** Function to fill individual fields */
46
+ fillField: FillFunction;
47
+ }
48
+
49
+ /**
50
+ * Show a menu-based editor for an object.
51
+ * Allows non-linear editing of fields with Submit/Cancel.
52
+ *
53
+ * @param options - Object menu options
54
+ * @returns The edited object or cancel result
55
+ */
56
+ export async function promptObject<T extends Record<string, unknown>>(
57
+ options: ObjectMenuOptions,
58
+ ): Promise<ObjectMenuResult<T>> {
59
+ const { type, path, defs, defaults = {}, fillField } = options;
60
+
61
+ const fields = type.properties as Record<string, TSchema>;
62
+ const fieldNames = Object.keys(fields);
63
+
64
+ // Edge case: empty struct
65
+ if (fieldNames.length === 0) {
66
+ return { action: "submit", value: {} as T };
67
+ }
68
+
69
+ // Edge case: single required field with no optionals - skip menu
70
+ const requiredFields = fieldNames.filter((name) =>
71
+ isFieldRequired(fields[name]!),
72
+ );
73
+ const optionalFields = fieldNames.filter(
74
+ (name) => !isFieldRequired(fields[name]!),
75
+ );
76
+
77
+ if (requiredFields.length === 1 && optionalFields.length === 0) {
78
+ const fieldName = requiredFields[0]!;
79
+ const fieldType = fields[fieldName]!;
80
+ try {
81
+ const value = await fillField(
82
+ fieldType,
83
+ [...path, fieldName],
84
+ defs,
85
+ defaults[fieldName],
86
+ );
87
+ return {
88
+ action: "submit",
89
+ value: { [fieldName]: value } as T,
90
+ };
91
+ } catch (e) {
92
+ if (e instanceof UserCancelledError) {
93
+ return { action: "cancel" };
94
+ }
95
+ throw e;
96
+ }
97
+ }
98
+
99
+ // Initialize state for all fields
100
+ const state = new Map<string, FieldState>();
101
+ for (const fieldName of fieldNames) {
102
+ const fieldType = fields[fieldName]!;
103
+ // Auto-fill Literal fields with their const value
104
+ if (isLiteral(fieldType)) {
105
+ state.set(fieldName, { status: "set", value: fieldType.const });
106
+ } else {
107
+ state.set(fieldName, getInitialFieldState(fieldType, defaults[fieldName]));
108
+ }
109
+ }
110
+
111
+ // Main menu loop
112
+ while (true) {
113
+ const { total, filled } = countRequiredFields(type, state);
114
+ const canSubmit = allRequiredFieldsFilled(type, state);
115
+
116
+ // Build menu choices
117
+ const choices: Array<{ name: string; value: string; disabled?: boolean | string } | Separator> = [];
118
+
119
+ // Add field items
120
+ for (const fieldName of fieldNames) {
121
+ const fieldType = fields[fieldName]!;
122
+ const fieldState = state.get(fieldName)!;
123
+ const label = buildFieldLabel(fieldName, fieldType, fieldState);
124
+ choices.push({ name: label, value: `field:${fieldName}` });
125
+ }
126
+
127
+ // Add separator and actions
128
+ choices.push(new Separator("\u2500\u2500\u2500 Actions \u2500\u2500\u2500"));
129
+
130
+ // Submit button
131
+ if (canSubmit) {
132
+ choices.push({ name: "Submit", value: "submit" });
133
+ } else {
134
+ choices.push({
135
+ name: `Submit (${filled} of ${total} required complete)`,
136
+ value: "submit",
137
+ disabled: true,
138
+ });
139
+ }
140
+
141
+ choices.push({ name: "Cancel", value: "cancel" });
142
+
143
+ // Show breadcrumb header if we're in a nested path
144
+ const breadcrumb = formatBreadcrumb(path);
145
+ if (breadcrumb) {
146
+ console.log(colors.dim(`📍 ${breadcrumb}`));
147
+ }
148
+
149
+ // Show menu
150
+ const selection = await selectWithClear({
151
+ message: "Edit:",
152
+ choices,
153
+ });
154
+
155
+ // Clear the breadcrumb line too if we printed one
156
+ if (breadcrumb) {
157
+ clearLines(1);
158
+ }
159
+
160
+ // Handle escape
161
+ if (selection === null) {
162
+ // Check if any values have been set
163
+ const hasValues = Array.from(state.values()).some(
164
+ (s) => s.status === "set" || s.status === "null",
165
+ );
166
+
167
+ if (hasValues) {
168
+ const confirmCancel = await confirmWithClear({
169
+ message: "Discard changes?",
170
+ default: false,
171
+ });
172
+ if (confirmCancel === null || confirmCancel === false) {
173
+ continue; // Return to menu
174
+ }
175
+ }
176
+ return { action: "cancel" };
177
+ }
178
+
179
+ // Handle submit
180
+ if (selection === "submit") {
181
+ if (!canSubmit) {
182
+ continue; // Shouldn't happen due to disabled, but be safe
183
+ }
184
+ const result = buildResultObject(fields, state, defs);
185
+ return { action: "submit", value: result as T };
186
+ }
187
+
188
+ // Handle cancel
189
+ if (selection === "cancel") {
190
+ const hasValues = Array.from(state.values()).some(
191
+ (s) => s.status === "set" || s.status === "null",
192
+ );
193
+
194
+ if (hasValues) {
195
+ const confirmCancel = await confirmWithClear({
196
+ message: "Discard changes?",
197
+ default: false,
198
+ });
199
+ if (confirmCancel === null || confirmCancel === false) {
200
+ continue;
201
+ }
202
+ }
203
+ return { action: "cancel" };
204
+ }
205
+
206
+ // Handle field selection
207
+ if (typeof selection === "string" && selection.startsWith("field:")) {
208
+ const fieldName = selection.slice(6);
209
+ const fieldType = fields[fieldName]!;
210
+ const fieldState = state.get(fieldName)!;
211
+
212
+ // If field has a value, show field sub-menu
213
+ if (fieldState.status === "set" || fieldState.status === "null") {
214
+ const menuResult = await promptFieldMenu({
215
+ fieldName,
216
+ fieldType,
217
+ state: fieldState,
218
+ });
219
+
220
+ switch (menuResult.action) {
221
+ case "edit": {
222
+ // Edit the field
223
+ try {
224
+ const newValue = await fillField(
225
+ fieldType,
226
+ [...path, fieldName],
227
+ defs,
228
+ fieldState.status === "set" ? fieldState.value : undefined,
229
+ );
230
+ state.set(fieldName, { status: "set", value: newValue });
231
+ } catch (e) {
232
+ if (e instanceof UserCancelledError) {
233
+ // Return to menu without changing
234
+ continue;
235
+ }
236
+ throw e;
237
+ }
238
+ break;
239
+ }
240
+
241
+ case "view": {
242
+ if (fieldState.status === "set") {
243
+ await displayFullValue(fieldName, fieldState.value);
244
+ }
245
+ break;
246
+ }
247
+
248
+ case "clear": {
249
+ state.set(fieldName, { status: "unset" });
250
+ break;
251
+ }
252
+
253
+ case "setNull": {
254
+ state.set(fieldName, { status: "null" });
255
+ break;
256
+ }
257
+
258
+ case "reset": {
259
+ // Reset to default value - show as unset, will use default on submit
260
+ state.set(fieldName, { status: "unset" });
261
+ break;
262
+ }
263
+
264
+ case "back":
265
+ // Do nothing, return to main menu
266
+ break;
267
+ }
268
+ } else {
269
+ // Field is unset - directly prompt for value
270
+ try {
271
+ const newValue = await fillField(
272
+ fieldType,
273
+ [...path, fieldName],
274
+ defs,
275
+ defaults[fieldName],
276
+ );
277
+ // If user skipped an optional field (returned undefined), leave as unset
278
+ if (newValue === undefined) {
279
+ // Don't change state - field remains unset
280
+ } else {
281
+ state.set(fieldName, { status: "set", value: newValue });
282
+ }
283
+ } catch (e) {
284
+ if (e instanceof UserCancelledError) {
285
+ // Return to menu without changing
286
+ continue;
287
+ }
288
+ throw e;
289
+ }
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Build the result object from the state map.
297
+ * Applies defaults for unset fields with defaults.
298
+ */
299
+ function buildResultObject(
300
+ fields: Record<string, TSchema>,
301
+ state: Map<string, FieldState>,
302
+ _defs: Record<string, TSchema>,
303
+ ): Record<string, unknown> {
304
+ const result: Record<string, unknown> = {};
305
+
306
+ for (const [fieldName, fieldType] of Object.entries(fields)) {
307
+ const fieldState = state.get(fieldName);
308
+
309
+ if (!fieldState || fieldState.status === "unset") {
310
+ // If optional, skip (undefined)
311
+ if (isOptional(fieldType)) {
312
+ continue;
313
+ }
314
+
315
+ // If field has a default, use it
316
+ if (hasDefault(fieldType)) {
317
+ result[fieldName] = getDefault(fieldType);
318
+ continue;
319
+ }
320
+
321
+ // No value, no default: omit from output
322
+ continue;
323
+ }
324
+
325
+ if (fieldState.status === "null") {
326
+ result[fieldName] = null;
327
+ continue;
328
+ }
329
+
330
+ if (fieldState.status === "set") {
331
+ result[fieldName] = fieldState.value;
332
+ }
333
+ }
334
+
335
+ return result;
336
+ }