@sundaeswap/sprinkles 0.5.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 (112) hide show
  1. package/dist/cjs/Sprinkle/__tests__/encryption.test.js +3 -1
  2. package/dist/cjs/Sprinkle/__tests__/encryption.test.js.map +1 -1
  3. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +3 -37
  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 +242 -87
  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 +9 -5
  12. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  13. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js +9 -0
  14. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js.map +1 -1
  15. package/dist/cjs/Sprinkle/index.js +135 -91
  16. package/dist/cjs/Sprinkle/index.js.map +1 -1
  17. package/dist/cjs/Sprinkle/menus/array-menu.js +195 -0
  18. package/dist/cjs/Sprinkle/menus/array-menu.js.map +1 -0
  19. package/dist/cjs/Sprinkle/menus/field-menu.js +161 -0
  20. package/dist/cjs/Sprinkle/menus/field-menu.js.map +1 -0
  21. package/dist/cjs/Sprinkle/menus/index.js +33 -0
  22. package/dist/cjs/Sprinkle/menus/index.js.map +1 -0
  23. package/dist/cjs/Sprinkle/menus/object-menu.js +324 -0
  24. package/dist/cjs/Sprinkle/menus/object-menu.js.map +1 -0
  25. package/dist/cjs/Sprinkle/prompts.js +68 -2
  26. package/dist/cjs/Sprinkle/prompts.js.map +1 -1
  27. package/dist/cjs/Sprinkle/type-guards.js +48 -1
  28. package/dist/cjs/Sprinkle/type-guards.js.map +1 -1
  29. package/dist/cjs/Sprinkle/types.js +24 -0
  30. package/dist/cjs/Sprinkle/types.js.map +1 -1
  31. package/dist/cjs/Sprinkle/utils/field-utils.js +154 -0
  32. package/dist/cjs/Sprinkle/utils/field-utils.js.map +1 -0
  33. package/dist/cjs/Sprinkle/utils/formatting.js +126 -0
  34. package/dist/cjs/Sprinkle/utils/formatting.js.map +1 -0
  35. package/dist/cjs/Sprinkle/utils/index.js +56 -0
  36. package/dist/cjs/Sprinkle/utils/index.js.map +1 -0
  37. package/dist/esm/Sprinkle/__tests__/encryption.test.js +3 -1
  38. package/dist/esm/Sprinkle/__tests__/encryption.test.js.map +1 -1
  39. package/dist/esm/Sprinkle/__tests__/enhancements.test.js +3 -37
  40. package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  41. package/dist/esm/Sprinkle/__tests__/field-utils.test.js +168 -0
  42. package/dist/esm/Sprinkle/__tests__/field-utils.test.js.map +1 -0
  43. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +243 -88
  44. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  45. package/dist/esm/Sprinkle/__tests__/formatting.test.js +95 -0
  46. package/dist/esm/Sprinkle/__tests__/formatting.test.js.map +1 -0
  47. package/dist/esm/Sprinkle/__tests__/show-menu.test.js +9 -5
  48. package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  49. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js +9 -0
  50. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js.map +1 -1
  51. package/dist/esm/Sprinkle/index.js +102 -93
  52. package/dist/esm/Sprinkle/index.js.map +1 -1
  53. package/dist/esm/Sprinkle/menus/array-menu.js +190 -0
  54. package/dist/esm/Sprinkle/menus/array-menu.js.map +1 -0
  55. package/dist/esm/Sprinkle/menus/field-menu.js +155 -0
  56. package/dist/esm/Sprinkle/menus/field-menu.js.map +1 -0
  57. package/dist/esm/Sprinkle/menus/index.js +8 -0
  58. package/dist/esm/Sprinkle/menus/index.js.map +1 -0
  59. package/dist/esm/Sprinkle/menus/object-menu.js +318 -0
  60. package/dist/esm/Sprinkle/menus/object-menu.js.map +1 -0
  61. package/dist/esm/Sprinkle/prompts.js +59 -1
  62. package/dist/esm/Sprinkle/prompts.js.map +1 -1
  63. package/dist/esm/Sprinkle/type-guards.js +42 -0
  64. package/dist/esm/Sprinkle/type-guards.js.map +1 -1
  65. package/dist/esm/Sprinkle/types.js +24 -0
  66. package/dist/esm/Sprinkle/types.js.map +1 -1
  67. package/dist/esm/Sprinkle/utils/field-utils.js +145 -0
  68. package/dist/esm/Sprinkle/utils/field-utils.js.map +1 -0
  69. package/dist/esm/Sprinkle/utils/formatting.js +118 -0
  70. package/dist/esm/Sprinkle/utils/formatting.js.map +1 -0
  71. package/dist/esm/Sprinkle/utils/index.js +7 -0
  72. package/dist/esm/Sprinkle/utils/index.js.map +1 -0
  73. package/dist/types/Sprinkle/index.d.ts +9 -3
  74. package/dist/types/Sprinkle/index.d.ts.map +1 -1
  75. package/dist/types/Sprinkle/menus/array-menu.d.ts +31 -0
  76. package/dist/types/Sprinkle/menus/array-menu.d.ts.map +1 -0
  77. package/dist/types/Sprinkle/menus/field-menu.d.ts +34 -0
  78. package/dist/types/Sprinkle/menus/field-menu.d.ts.map +1 -0
  79. package/dist/types/Sprinkle/menus/index.d.ts +10 -0
  80. package/dist/types/Sprinkle/menus/index.d.ts.map +1 -0
  81. package/dist/types/Sprinkle/menus/object-menu.d.ts +34 -0
  82. package/dist/types/Sprinkle/menus/object-menu.d.ts.map +1 -0
  83. package/dist/types/Sprinkle/prompts.d.ts +25 -0
  84. package/dist/types/Sprinkle/prompts.d.ts.map +1 -1
  85. package/dist/types/Sprinkle/type-guards.d.ts +24 -1
  86. package/dist/types/Sprinkle/type-guards.d.ts.map +1 -1
  87. package/dist/types/Sprinkle/types.d.ts +53 -0
  88. package/dist/types/Sprinkle/types.d.ts.map +1 -1
  89. package/dist/types/Sprinkle/utils/field-utils.d.ts +47 -0
  90. package/dist/types/Sprinkle/utils/field-utils.d.ts.map +1 -0
  91. package/dist/types/Sprinkle/utils/formatting.d.ts +30 -0
  92. package/dist/types/Sprinkle/utils/formatting.d.ts.map +1 -0
  93. package/dist/types/tsconfig.build.tsbuildinfo +1 -1
  94. package/package.json +1 -1
  95. package/src/Sprinkle/__tests__/encryption.test.ts +2 -0
  96. package/src/Sprinkle/__tests__/enhancements.test.ts +3 -42
  97. package/src/Sprinkle/__tests__/field-utils.test.ts +191 -0
  98. package/src/Sprinkle/__tests__/fill-in-struct.test.ts +252 -103
  99. package/src/Sprinkle/__tests__/formatting.test.ts +115 -0
  100. package/src/Sprinkle/__tests__/show-menu.test.ts +14 -8
  101. package/src/Sprinkle/__tests__/tx-dialog.test.ts +9 -0
  102. package/src/Sprinkle/index.ts +131 -119
  103. package/src/Sprinkle/menus/array-menu.ts +191 -0
  104. package/src/Sprinkle/menus/field-menu.ts +145 -0
  105. package/src/Sprinkle/menus/index.ts +12 -0
  106. package/src/Sprinkle/menus/object-menu.ts +336 -0
  107. package/src/Sprinkle/prompts.ts +71 -1
  108. package/src/Sprinkle/type-guards.ts +42 -0
  109. package/src/Sprinkle/types.ts +43 -0
  110. package/src/Sprinkle/utils/field-utils.ts +158 -0
  111. package/src/Sprinkle/utils/formatting.ts +127 -0
  112. package/src/Sprinkle/utils/index.ts +17 -0
@@ -8,12 +8,16 @@ import {
8
8
  import { CborSet, VkeyWitness, TxCBOR } from "@blaze-cardano/core";
9
9
  import {
10
10
  selectCancellable,
11
+ selectWithClear,
11
12
  inputCancellable,
13
+ inputWithClear,
12
14
  passwordCancellable,
15
+ passwordWithClear,
13
16
  confirmCancellable,
14
17
  searchCancellable,
15
18
  select,
16
19
  } from "./prompts.js";
20
+ import colors from "yoctocolors-cjs";
17
21
  import { type TSchema, Type, OptionalKind } from "@sinclair/typebox";
18
22
  import * as fs from "fs";
19
23
  import * as path from "path";
@@ -29,6 +33,11 @@ export type {
29
33
  IProfileEntry,
30
34
  TxDialogResult,
31
35
  TxDialogOptions,
36
+ FieldState,
37
+ RequiredFieldCount,
38
+ FieldMenuResult,
39
+ ObjectMenuResult,
40
+ ArrayMenuResult,
32
41
  } from "./types.js";
33
42
  export { UserCancelledError } from "./types.js";
34
43
  import type {
@@ -67,6 +76,11 @@ import {
67
76
  isTuple,
68
77
  isUnion,
69
78
  isSensitive,
79
+ isNull,
80
+ isNullable,
81
+ unwrapNullable,
82
+ hasDefault,
83
+ getDefault,
70
84
  } from "./type-guards.js";
71
85
  export {
72
86
  isOptional,
@@ -81,6 +95,11 @@ export {
81
95
  isTuple,
82
96
  isUnion,
83
97
  isSensitive,
98
+ isNull,
99
+ isNullable,
100
+ unwrapNullable,
101
+ hasDefault,
102
+ getDefault,
84
103
  } from "./type-guards.js";
85
104
 
86
105
  // Import schemas for use in this file
@@ -122,6 +141,10 @@ import {
122
141
  mergeSignatures,
123
142
  } from "./tx-dialog.js";
124
143
 
144
+ // Import menu modules
145
+ import { promptObject, promptArray } from "./menus/index.js";
146
+ import { formatPath } from "./utils/formatting.js";
147
+
125
148
  export interface IMenuAction<S extends TSchema> {
126
149
  title: string;
127
150
  action: (sprinkle: Sprinkle<S>) => Promise<Sprinkle<S> | void>;
@@ -503,10 +526,15 @@ export class Sprinkle<S extends TSchema> {
503
526
  // --- Menu ---
504
527
 
505
528
  async showMenu(menu: IMenu<S>): Promise<void> {
506
- return this._showMenu(menu, true);
529
+ return this._showMenu(menu, true, [menu.title]);
507
530
  }
508
531
 
509
- private async _showMenu(menu: IMenu<S>, main: boolean): Promise<void> {
532
+ private async _showMenu(menu: IMenu<S>, main: boolean, path: string[], clearPrevious = false): Promise<void> {
533
+ // Clear previous breadcrumb if coming back from action/submenu
534
+ if (clearPrevious) {
535
+ process.stdout.write("\x1b[1A\x1b[2K\x1b[G");
536
+ }
537
+
510
538
  if (menu.beforeShow) {
511
539
  await menu.beforeShow(this);
512
540
  }
@@ -523,11 +551,17 @@ export class Sprinkle<S extends TSchema> {
523
551
  choices.push({ name: "Settings & Profiles", value: -5 });
524
552
  choices.push({ name: "Exit", value: -1 });
525
553
  }
526
- const selectionResult = await selectCancellable({
554
+
555
+ // Show breadcrumb
556
+ const breadcrumb = path.join(" > ");
557
+ console.log(colors.dim("🍞 " + breadcrumb));
558
+
559
+ const selectionResult = await selectWithClear({
527
560
  message: "Select an option:",
528
561
  choices: choices,
529
562
  });
530
563
  // Handle escape (null) as Back
564
+ // Don't clear here - let the caller's clearPrevious handle it
531
565
  if (selectionResult === null) {
532
566
  return;
533
567
  }
@@ -539,9 +573,18 @@ export class Sprinkle<S extends TSchema> {
539
573
  {
540
574
  title: "View settings",
541
575
  action: async () => {
542
- console.log(
543
- JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2),
544
- );
576
+ const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
577
+ const jsonLines = jsonStr.split("\n").length;
578
+ console.log(jsonStr);
579
+
580
+ // Wait for user to press Enter
581
+ await selectWithClear({
582
+ message: "Press Enter to continue...",
583
+ choices: [{ name: "Continue", value: "continue" }],
584
+ });
585
+
586
+ // Clear the JSON output
587
+ process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
545
588
  },
546
589
  },
547
590
  {
@@ -591,24 +634,29 @@ export class Sprinkle<S extends TSchema> {
591
634
  },
592
635
  ],
593
636
  };
594
- await this._showMenu(settingsMenu, false);
595
- await this._showMenu(menu, main);
637
+ await this._showMenu(settingsMenu, false, [...path, "Settings & Profiles"], true);
638
+ await this._showMenu(menu, main, path, true);
596
639
  return;
597
640
  }
598
641
  if (selection === -1) {
642
+ // Don't clear here - let the caller's clearPrevious handle it
599
643
  return;
600
644
  }
601
645
  const selectedItem = menu.items[selection]!;
602
646
  if ("action" in selectedItem) {
647
+ // Update breadcrumb to show current action
648
+ process.stdout.write("\x1b[1A\x1b[2K\x1b[G");
649
+ console.log(colors.dim("🍞 " + [...path, selectedItem.title].join(" > ")));
650
+
603
651
  const result = await selectedItem.action(this);
604
652
  if (result instanceof Sprinkle) {
605
653
  this.settings = result.settings;
606
654
  this.saveSettings();
607
655
  }
608
- await this._showMenu(menu, main);
656
+ await this._showMenu(menu, main, path, true);
609
657
  } else {
610
- await this._showMenu(selectedItem, false);
611
- await this._showMenu(menu, main);
658
+ await this._showMenu(selectedItem, false, [...path, selectedItem.title], true);
659
+ await this._showMenu(menu, main, path, true);
612
660
  }
613
661
  return;
614
662
  }
@@ -789,7 +837,7 @@ export class Sprinkle<S extends TSchema> {
789
837
  choices.push({ name: "Submit transaction", value: "submit" });
790
838
  choices.push({ name: "Cancel", value: "cancel" });
791
839
 
792
- const selection = await selectCancellable({
840
+ const selection = await selectWithClear({
793
841
  message: "Select an option:",
794
842
  choices,
795
843
  });
@@ -1002,68 +1050,20 @@ export class Sprinkle<S extends TSchema> {
1002
1050
  }
1003
1051
  }
1004
1052
 
1053
+ /**
1054
+ * Edit an existing struct value using a menu-based interface.
1055
+ * This is now unified with FillInStruct - both use the same menu system.
1056
+ * @param type - The TypeBox schema
1057
+ * @param current - The current value to edit
1058
+ * @returns The edited value
1059
+ */
1005
1060
  async EditStruct<U extends TSchema>(
1006
1061
  type: U,
1007
1062
  current: TExact<U>,
1008
1063
  ): Promise<TExact<U>> {
1009
- return this._editStruct<U>(type, ["root"], current);
1010
- }
1011
-
1012
- async _editStruct<U extends TSchema>(
1013
- type: U,
1014
- path: string[],
1015
- current?: TExact<U>,
1016
- ): Promise<TExact<U>> {
1017
- if (isObject(type)) {
1018
- const obj = {} as Record<string, unknown>;
1019
- const fields = type["properties"] as Record<string, U>;
1020
- const menuItems: TMenuItem<S>[] = [];
1021
- const currentRecord = current as Record<string, unknown>;
1022
- for (const [field, fieldType] of Object.entries(fields)) {
1023
- if (current && field in currentRecord) {
1024
- obj[field] = currentRecord[field] as TExact<U>;
1025
- }
1026
- const menuTitle = Sprinkle.ExtractMessage(
1027
- fieldType,
1028
- `Edit ${field} at ${path.join(".")}`,
1029
- );
1030
- if (
1031
- isOptional(fieldType) &&
1032
- current &&
1033
- currentRecord[field] !== undefined
1034
- ) {
1035
- menuItems.push({
1036
- title: `Clear ${field}`,
1037
- action: async (sprinkle: Sprinkle<S>) => {
1038
- obj[field] = undefined;
1039
- return sprinkle;
1040
- },
1041
- });
1042
- }
1043
- menuItems.push({
1044
- title: menuTitle,
1045
- action: async (sprinkle: Sprinkle<S>) => {
1046
- const fieldValue = await sprinkle._editStruct(
1047
- fieldType,
1048
- path.concat([field]),
1049
- current && field in currentRecord
1050
- ? (currentRecord[field] as TExact<U>)
1051
- : undefined,
1052
- );
1053
- obj[field] = fieldValue;
1054
- return sprinkle;
1055
- },
1056
- });
1057
- }
1058
- const editMenu: IMenu<S> = {
1059
- title: "Test",
1060
- items: menuItems,
1061
- };
1062
- await this._showMenu(editMenu, false);
1063
- return obj as TExact<U>;
1064
- }
1065
-
1066
- return this._fillInStruct<U>(type, path, {}, current);
1064
+ // Use FillInStruct with current values as defaults
1065
+ // The menu system will show existing values and allow editing
1066
+ return this.FillInStruct(type, current);
1067
1067
  }
1068
1068
 
1069
1069
  async FillInStruct<U extends TSchema>(
@@ -1092,10 +1092,11 @@ export class Sprinkle<S extends TSchema> {
1092
1092
  >;
1093
1093
  }
1094
1094
  if (isOptional(type)) {
1095
- const shouldSet = await selectCancellable({
1095
+ const pathDisplay = formatPath(path) || "value";
1096
+ const shouldSet = await selectWithClear({
1096
1097
  message: Sprinkle.ExtractMessage(
1097
1098
  type,
1098
- `Set value for ${path.join(".")}?`,
1099
+ `Set value for ${pathDisplay}?`,
1099
1100
  ),
1100
1101
  choices: [
1101
1102
  { name: "Yes", value: true },
@@ -1116,6 +1117,7 @@ export class Sprinkle<S extends TSchema> {
1116
1117
  }
1117
1118
 
1118
1119
  if (isUnion(type)) {
1120
+ const pathDisplay = formatPath(path) || "value";
1119
1121
  const choices = [];
1120
1122
  const resolved = this.resolveType(type, path, defs);
1121
1123
  for (const variant of resolved.anyOf) {
@@ -1124,13 +1126,12 @@ export class Sprinkle<S extends TSchema> {
1124
1126
  value: variant,
1125
1127
  });
1126
1128
  }
1127
- const selectionResult = await selectCancellable({
1129
+ const selectionResult = await selectWithClear({
1128
1130
  message: Sprinkle.ExtractMessage(
1129
1131
  resolved,
1130
- `Enter a choice for ${path.join(".")}`,
1132
+ `Enter a choice for ${pathDisplay}`,
1131
1133
  ),
1132
1134
  choices: choices,
1133
- default: def ? `${def}` : undefined,
1134
1135
  });
1135
1136
  if (selectionResult === null) {
1136
1137
  throw new UserCancelledError();
@@ -1142,7 +1143,7 @@ export class Sprinkle<S extends TSchema> {
1142
1143
  if (isString(type)) {
1143
1144
  // Special handling for hot wallet private key - offer generation option
1144
1145
  if (type.title === "Hot Wallet Private Key") {
1145
- const choice = await selectCancellable({
1146
+ const choice = await selectWithClear({
1146
1147
  message: "Hot wallet setup:",
1147
1148
  choices: [
1148
1149
  { name: "Enter existing private key", value: "existing" },
@@ -1157,7 +1158,7 @@ export class Sprinkle<S extends TSchema> {
1157
1158
  return Sprinkle.generateWalletFromMnemonic() as Promise<TExact<U>>;
1158
1159
  }
1159
1160
  // Fall through to password prompt for "existing" choice
1160
- const answer = await passwordCancellable({
1161
+ const answer = await passwordWithClear({
1161
1162
  message: "Enter your private key:",
1162
1163
  });
1163
1164
  if (answer === null) {
@@ -1166,18 +1167,19 @@ export class Sprinkle<S extends TSchema> {
1166
1167
  return answer as TExact<U>;
1167
1168
  }
1168
1169
 
1170
+ const pathDisplay = formatPath(path) || "value";
1169
1171
  const defaultString = (def ? def : this.defaults["string"]) as
1170
1172
  | string
1171
1173
  | undefined;
1172
1174
  const message = Sprinkle.ExtractMessage(
1173
1175
  type,
1174
- `Enter a string for ${path.join(".")}`,
1176
+ `Enter a string for ${pathDisplay}`,
1175
1177
  );
1176
1178
  let answer: string | null;
1177
1179
  if (isSensitive(type)) {
1178
- answer = await passwordCancellable({ message });
1180
+ answer = await passwordWithClear({ message });
1179
1181
  } else {
1180
- answer = await inputCancellable({ message, default: defaultString });
1182
+ answer = await inputWithClear({ message, default: defaultString });
1181
1183
  if (answer !== null) {
1182
1184
  this.defaults["string"] = answer;
1183
1185
  }
@@ -1189,10 +1191,11 @@ export class Sprinkle<S extends TSchema> {
1189
1191
  }
1190
1192
 
1191
1193
  if (isBigInt(type)) {
1192
- const answer = await inputCancellable({
1194
+ const pathDisplay = formatPath(path) || "value";
1195
+ const answer = await inputWithClear({
1193
1196
  message: Sprinkle.ExtractMessage(
1194
1197
  type,
1195
- `Enter a bigint for ${path.join(".")}`,
1198
+ `Enter a bigint for ${pathDisplay}`,
1196
1199
  ),
1197
1200
  default: def ? (def as bigint).toString() : undefined,
1198
1201
  validate: (s) => {
@@ -1215,47 +1218,55 @@ export class Sprinkle<S extends TSchema> {
1215
1218
  }
1216
1219
 
1217
1220
  if (isObject(type)) {
1218
- const obj = {} as Record<string, unknown>;
1219
- const fields = type["properties"] as Record<string, U>;
1220
- for (const [field, fieldType] of Object.entries(fields)) {
1221
- const fieldValue = await this._fillInStruct(
1222
- fieldType,
1223
- path.concat([field]),
1224
- defs,
1225
- def
1226
- ? ((def as Record<string, unknown>)[field] as TExact<U>)
1227
- : undefined,
1228
- );
1229
- obj[field] = fieldValue;
1221
+ // Use menu-based editing for objects
1222
+ const defaults = def as Record<string, unknown> | undefined;
1223
+ const sprinkle = this;
1224
+ const result = await promptObject({
1225
+ type,
1226
+ path,
1227
+ defs,
1228
+ defaults,
1229
+ fillField: async <T extends TSchema>(
1230
+ fieldType: T,
1231
+ fieldPath: string[],
1232
+ fieldDefs: Record<string, TSchema>,
1233
+ fieldDef?: unknown,
1234
+ ) => {
1235
+ return sprinkle._fillInStruct(fieldType, fieldPath, fieldDefs, fieldDef as TExact<T>);
1236
+ },
1237
+ });
1238
+
1239
+ if (result.action === "cancel") {
1240
+ throw new UserCancelledError();
1230
1241
  }
1231
- return obj as TExact<U>;
1242
+
1243
+ return result.value as TExact<U>;
1232
1244
  }
1233
1245
 
1234
- //TODO: support starting with default values for arrays and allow removal of items
1235
1246
  if (isArray(type)) {
1236
- const arr: unknown[] = [];
1237
- const itemType = type.items as U;
1238
- let addMore = true;
1239
- while (addMore) {
1240
- const itemValue = await this._fillInStruct(
1241
- itemType,
1242
- path.concat([`[${arr.length}]`]),
1243
- defs,
1244
- );
1245
- arr.push(itemValue);
1246
- const continueAnswer = await selectCancellable({
1247
- message: `Add another item to ${path.join(".")}?`,
1248
- choices: [
1249
- { name: "Yes", value: true },
1250
- { name: "No", value: false },
1251
- ],
1252
- });
1253
- if (continueAnswer === null) {
1254
- throw new UserCancelledError();
1255
- }
1256
- addMore = continueAnswer as boolean;
1247
+ // Use menu-based editing for arrays
1248
+ const defaults = def as unknown[] | undefined;
1249
+ const sprinkle = this;
1250
+ const result = await promptArray({
1251
+ type,
1252
+ path,
1253
+ defs,
1254
+ defaults,
1255
+ fillField: async <T extends TSchema>(
1256
+ itemType: T,
1257
+ itemPath: string[],
1258
+ itemDefs: Record<string, TSchema>,
1259
+ itemDef?: unknown,
1260
+ ) => {
1261
+ return sprinkle._fillInStruct(itemType, itemPath, itemDefs, itemDef as TExact<T>);
1262
+ },
1263
+ });
1264
+
1265
+ if (result.action === "back") {
1266
+ throw new UserCancelledError();
1257
1267
  }
1258
- return arr as TExact<U>;
1268
+
1269
+ return result.value as TExact<U>;
1259
1270
  }
1260
1271
 
1261
1272
  if (isTuple(type)) {
@@ -1274,8 +1285,9 @@ export class Sprinkle<S extends TSchema> {
1274
1285
  return result as TExact<U>;
1275
1286
  }
1276
1287
 
1288
+ const pathDisplay = formatPath(path) || "root";
1277
1289
  throw new Error(
1278
- `Unable to fill in struct for type at path ${path.join(".")}`,
1290
+ `Unable to fill in struct for type at path ${pathDisplay}`,
1279
1291
  );
1280
1292
  }
1281
1293
 
@@ -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
+ }