@terreno/blocks 57.10.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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/biome.jsonc +18 -0
  4. package/bunfig.toml +6 -0
  5. package/dist/asks/errors.d.ts +77 -0
  6. package/dist/asks/errors.js +364 -0
  7. package/dist/asks/files.d.ts +53 -0
  8. package/dist/asks/files.js +171 -0
  9. package/dist/asks/formValues.d.ts +32 -0
  10. package/dist/asks/formValues.js +229 -0
  11. package/dist/asks/headless.d.ts +227 -0
  12. package/dist/asks/headless.js +94 -0
  13. package/dist/asks/jsonSchema.d.ts +6 -0
  14. package/dist/asks/jsonSchema.js +62 -0
  15. package/dist/asks/limits.d.ts +58 -0
  16. package/dist/asks/limits.js +61 -0
  17. package/dist/asks/prompt.d.ts +10 -0
  18. package/dist/asks/prompt.js +144 -0
  19. package/dist/asks/schema.d.ts +826 -0
  20. package/dist/asks/schema.js +864 -0
  21. package/dist/asks/simpleCard.d.ts +85 -0
  22. package/dist/asks/simpleCard.js +279 -0
  23. package/dist/asks/validateInput.d.ts +13 -0
  24. package/dist/asks/validateInput.js +19 -0
  25. package/dist/asks/validateResponse.d.ts +13 -0
  26. package/dist/asks/validateResponse.js +278 -0
  27. package/dist/cli.d.ts +11 -0
  28. package/dist/cli.js +58 -0
  29. package/dist/errors.d.ts +51 -0
  30. package/dist/errors.js +106 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.js +111 -0
  33. package/dist/jsonSchema.d.ts +5 -0
  34. package/dist/jsonSchema.js +12 -0
  35. package/dist/limits.d.ts +27 -0
  36. package/dist/limits.js +30 -0
  37. package/dist/lint.d.ts +21 -0
  38. package/dist/lint.js +388 -0
  39. package/dist/parse.d.ts +16 -0
  40. package/dist/parse.js +95 -0
  41. package/dist/parsePartial.d.ts +12 -0
  42. package/dist/parsePartial.js +105 -0
  43. package/dist/prompt.d.ts +9 -0
  44. package/dist/prompt.js +95 -0
  45. package/dist/schema.d.ts +199 -0
  46. package/dist/schema.js +277 -0
  47. package/dist/validate.d.ts +17 -0
  48. package/dist/validate.js +331 -0
  49. package/package.json +60 -0
  50. package/schemas/pendingAsksResponse.schema.json +195 -0
  51. package/schemas/simpleCard.schema.json +135 -0
  52. package/schemas/turnRequest.schema.json +108 -0
  53. package/schemas/turnResponse.schema.json +204 -0
  54. package/scripts/writeJsonSchemas.ts +11 -0
  55. package/src/asks/docParity.test.ts +65 -0
  56. package/src/asks/errors.test.ts +250 -0
  57. package/src/asks/errors.ts +449 -0
  58. package/src/asks/files.test.ts +294 -0
  59. package/src/asks/files.ts +216 -0
  60. package/src/asks/fixtures/invalid/choice-blank-text.json +20 -0
  61. package/src/asks/fixtures/invalid/choice-default-not-in-options.json +13 -0
  62. package/src/asks/fixtures/invalid/choice-default-selects-two.json +16 -0
  63. package/src/asks/fixtures/invalid/choice-description-too-long.json +24 -0
  64. package/src/asks/fixtures/invalid/choice-duplicate-ids.json +17 -0
  65. package/src/asks/fixtures/invalid/choice-label-too-long.json +23 -0
  66. package/src/asks/fixtures/invalid/choice-many-bounds-not-whole.json +17 -0
  67. package/src/asks/fixtures/invalid/choice-many-bounds-out-of-range.json +17 -0
  68. package/src/asks/fixtures/invalid/choice-many-default-duplicate.json +17 -0
  69. package/src/asks/fixtures/invalid/choice-many-default-too-many.json +15 -0
  70. package/src/asks/fixtures/invalid/choice-many-max-above-options.json +14 -0
  71. package/src/asks/fixtures/invalid/choice-many-min-above-choices.json +13 -0
  72. package/src/asks/fixtures/invalid/choice-many-min-above-max.json +15 -0
  73. package/src/asks/fixtures/invalid/choice-missing-required.json +14 -0
  74. package/src/asks/fixtures/invalid/choice-mixed-errors.json +19 -0
  75. package/src/asks/fixtures/invalid/choice-not-an-object.json +5 -0
  76. package/src/asks/fixtures/invalid/choice-one-with-many-fields.json +19 -0
  77. package/src/asks/fixtures/invalid/choice-option-id-format.json +35 -0
  78. package/src/asks/fixtures/invalid/choice-options-not-objects.json +12 -0
  79. package/src/asks/fixtures/invalid/choice-other-label-too-long.json +25 -0
  80. package/src/asks/fixtures/invalid/choice-other-label-without-allow-other.json +14 -0
  81. package/src/asks/fixtures/invalid/choice-prompt-empty.json +12 -0
  82. package/src/asks/fixtures/invalid/choice-prompt-too-long.json +23 -0
  83. package/src/asks/fixtures/invalid/choice-select-unknown.json +12 -0
  84. package/src/asks/fixtures/invalid/choice-submit-label-too-long.json +13 -0
  85. package/src/asks/fixtures/invalid/choice-title-too-long.json +24 -0
  86. package/src/asks/fixtures/invalid/choice-too-few-options.json +9 -0
  87. package/src/asks/fixtures/invalid/choice-too-many-options.json +219 -0
  88. package/src/asks/fixtures/invalid/choice-unknown-key-option.json +15 -0
  89. package/src/asks/fixtures/invalid/choice-unknown-key-root.json +13 -0
  90. package/src/asks/fixtures/invalid/choice-wrong-types.json +16 -0
  91. package/src/asks/fixtures/invalid/confirm-emoji-label-too-long.json +13 -0
  92. package/src/asks/fixtures/invalid/confirm-label-too-long.json +14 -0
  93. package/src/asks/fixtures/invalid/confirm-labels-alike-after-trim.json +14 -0
  94. package/src/asks/fixtures/invalid/confirm-labels-alike.json +13 -0
  95. package/src/asks/fixtures/invalid/confirm-missing-prompt-blank-label.json +16 -0
  96. package/src/asks/fixtures/invalid/confirm-unknown-keys.json +23 -0
  97. package/src/asks/fixtures/invalid/confirm-wrong-types.json +18 -0
  98. package/src/asks/fixtures/invalid/files-accept-duplicate.json +13 -0
  99. package/src/asks/fixtures/invalid/files-accept-empty.json +13 -0
  100. package/src/asks/fixtures/invalid/files-accept-unknown.json +13 -0
  101. package/src/asks/fixtures/invalid/files-counts-out-of-range.json +19 -0
  102. package/src/asks/fixtures/invalid/files-min-above-max.json +15 -0
  103. package/src/asks/fixtures/invalid/files-missing-accept-unknown-key.json +17 -0
  104. package/src/asks/fixtures/invalid/form-default-bad-format.json +40 -0
  105. package/src/asks/fixtures/invalid/form-default-invalid-date.json +40 -0
  106. package/src/asks/fixtures/invalid/form-default-not-in-options.json +58 -0
  107. package/src/asks/fixtures/invalid/form-default-out-of-range.json +33 -0
  108. package/src/asks/fixtures/invalid/form-default-text-bounds.json +42 -0
  109. package/src/asks/fixtures/invalid/form-default-wrong-type.json +30 -0
  110. package/src/asks/fixtures/invalid/form-duplicate-field-ids.json +24 -0
  111. package/src/asks/fixtures/invalid/form-field-id-format.json +19 -0
  112. package/src/asks/fixtures/invalid/form-field-text-limits.json +33 -0
  113. package/src/asks/fixtures/invalid/form-keys-of-another-type.json +39 -0
  114. package/src/asks/fixtures/invalid/form-length-above-cap.json +30 -0
  115. package/src/asks/fixtures/invalid/form-min-above-max.json +32 -0
  116. package/src/asks/fixtures/invalid/form-no-fields.json +13 -0
  117. package/src/asks/fixtures/invalid/form-select-options.json +44 -0
  118. package/src/asks/fixtures/invalid/form-too-many-fields.json +59 -0
  119. package/src/asks/fixtures/invalid/form-unknown-field-type.json +27 -0
  120. package/src/asks/fixtures/invalid/markdown-bounds-out-of-range.json +18 -0
  121. package/src/asks/fixtures/invalid/markdown-initial-above-cap.json +13 -0
  122. package/src/asks/fixtures/invalid/markdown-max-above-cap.json +13 -0
  123. package/src/asks/fixtures/invalid/markdown-min-above-cap.json +13 -0
  124. package/src/asks/fixtures/invalid/markdown-min-above-max.json +14 -0
  125. package/src/asks/fixtures/invalid/markdown-placeholder-too-long.json +13 -0
  126. package/src/asks/fixtures/invalid/markdown-wrong-types-unknown-key.json +27 -0
  127. package/src/asks/fixtures/valid/choice-emoji-label-cut.json +32 -0
  128. package/src/asks/fixtures/valid/choice-labels-collide-after-cut.json +21 -0
  129. package/src/asks/fixtures/valid/choice-long-text-cut.json +41 -0
  130. package/src/asks/fixtures/valid/choice-many-default-below-min.json +24 -0
  131. package/src/asks/fixtures/valid/choice-many-every-choice-with-other.json +24 -0
  132. package/src/asks/fixtures/valid/choice-many-no-default.json +21 -0
  133. package/src/asks/fixtures/valid/choice-many-optional-no-buttons.json +20 -0
  134. package/src/asks/fixtures/valid/choice-many-options-default-label-collides.json +23 -0
  135. package/src/asks/fixtures/valid/choice-many-options-no-buttons.json +21 -0
  136. package/src/asks/fixtures/valid/choice-many-options-no-default.json +22 -0
  137. package/src/asks/fixtures/valid/choice-many-options-with-default.json +30 -0
  138. package/src/asks/fixtures/valid/choice-many-required-with-default.json +29 -0
  139. package/src/asks/fixtures/valid/choice-many-single-pick-or-other.json +29 -0
  140. package/src/asks/fixtures/valid/choice-many-toppings-with-default.json +36 -0
  141. package/src/asks/fixtures/valid/choice-max-limits.json +245 -0
  142. package/src/asks/fixtures/valid/choice-plan-with-default.json +42 -0
  143. package/src/asks/fixtures/valid/choice-two-options-no-decline.json +32 -0
  144. package/src/asks/fixtures/valid/choice-two-options.json +32 -0
  145. package/src/asks/fixtures/valid/confirm-allow-decline.json +40 -0
  146. package/src/asks/fixtures/valid/confirm-default-labels.json +36 -0
  147. package/src/asks/fixtures/valid/confirm-destructive-delete.json +41 -0
  148. package/src/asks/fixtures/valid/confirm-emoji-labels.json +38 -0
  149. package/src/asks/fixtures/valid/confirm-long-text-cut.json +40 -0
  150. package/src/asks/fixtures/valid/files-every-type.json +28 -0
  151. package/src/asks/fixtures/valid/files-one-csv-no-decline.json +16 -0
  152. package/src/asks/fixtures/valid/files-receipt-photos.json +24 -0
  153. package/src/asks/fixtures/valid/form-boolean-newsletter.json +30 -0
  154. package/src/asks/fixtures/valid/form-date-due.json +44 -0
  155. package/src/asks/fixtures/valid/form-datetime-meeting.json +44 -0
  156. package/src/asks/fixtures/valid/form-datetime-minutes-offset.json +44 -0
  157. package/src/asks/fixtures/valid/form-eight-fields.json +71 -0
  158. package/src/asks/fixtures/valid/form-email-contact.json +44 -0
  159. package/src/asks/fixtures/valid/form-invoice-details.json +68 -0
  160. package/src/asks/fixtures/valid/form-multiselect-channels.json +64 -0
  161. package/src/asks/fixtures/valid/form-number-budget.json +45 -0
  162. package/src/asks/fixtures/valid/form-phone-callback.json +44 -0
  163. package/src/asks/fixtures/valid/form-select-shipping.json +58 -0
  164. package/src/asks/fixtures/valid/form-text-required-default.json +47 -0
  165. package/src/asks/fixtures/valid/form-textarea-notes.json +23 -0
  166. package/src/asks/fixtures/valid/form-time-reminder.json +44 -0
  167. package/src/asks/fixtures/valid/form-url-website.json +30 -0
  168. package/src/asks/fixtures/valid/markdown-announcement-draft.json +39 -0
  169. package/src/asks/fixtures/valid/markdown-empty-draft.json +34 -0
  170. package/src/asks/fixtures/valid/markdown-initial-blank-below-min.json +24 -0
  171. package/src/asks/fixtures/valid/markdown-initial-over-max.json +24 -0
  172. package/src/asks/fixtures/valid/markdown-long-text-cut.json +37 -0
  173. package/src/asks/fixtures/valid/markdown-no-decline.json +28 -0
  174. package/src/asks/fixtures/valid/markdown-no-initial-with-min.json +25 -0
  175. package/src/asks/formValues.ts +320 -0
  176. package/src/asks/headless.test.ts +146 -0
  177. package/src/asks/headless.ts +130 -0
  178. package/src/asks/jsonSchema.test.ts +171 -0
  179. package/src/asks/jsonSchema.ts +89 -0
  180. package/src/asks/limits.ts +58 -0
  181. package/src/asks/prompt.test.ts +200 -0
  182. package/src/asks/prompt.ts +173 -0
  183. package/src/asks/schema.test.ts +198 -0
  184. package/src/asks/schema.ts +1203 -0
  185. package/src/asks/simpleCard.test.ts +998 -0
  186. package/src/asks/simpleCard.ts +341 -0
  187. package/src/asks/validateInput.test.ts +875 -0
  188. package/src/asks/validateInput.ts +24 -0
  189. package/src/asks/validateResponse.test.ts +740 -0
  190. package/src/asks/validateResponse.ts +341 -0
  191. package/src/blocksDocParity.test.ts +21 -0
  192. package/src/cli.test.ts +134 -0
  193. package/src/cli.ts +65 -0
  194. package/src/display.test.ts +68 -0
  195. package/src/errors.test.ts +29 -0
  196. package/src/errors.ts +125 -0
  197. package/src/fixtures/invalid/column-not-found.yaml +14 -0
  198. package/src/fixtures/invalid/column-type.yaml +14 -0
  199. package/src/fixtures/invalid/dataset-not-found.yaml +7 -0
  200. package/src/fixtures/invalid/dataset-too-large.yaml +20 -0
  201. package/src/fixtures/invalid/depth-exceeded.yaml +10 -0
  202. package/src/fixtures/invalid/duplicate-id.yaml +6 -0
  203. package/src/fixtures/invalid/invalid-enum.yaml +5 -0
  204. package/src/fixtures/invalid/key-order.yaml +4 -0
  205. package/src/fixtures/invalid/missing-required.yaml +3 -0
  206. package/src/fixtures/invalid/not-a-document.txt +1 -0
  207. package/src/fixtures/invalid/row-arity.yaml +11 -0
  208. package/src/fixtures/invalid/select-target.yaml +12 -0
  209. package/src/fixtures/invalid/table-too-wide.yaml +9 -0
  210. package/src/fixtures/invalid/too-many-points.yaml +8 -0
  211. package/src/fixtures/invalid/unknown-key.yaml +5 -0
  212. package/src/fixtures/invalid/unsupported-version.yaml +4 -0
  213. package/src/fixtures/invalid/yaml-anchor.yaml +6 -0
  214. package/src/fixtures/partial/cut-after-divider.yaml +5 -0
  215. package/src/fixtures/valid/actions.yaml +32 -0
  216. package/src/fixtures/valid/chart.yaml +20 -0
  217. package/src/fixtures/valid/layout.yaml +26 -0
  218. package/src/fixtures/valid/prose.yaml +4 -0
  219. package/src/html.test.ts +52 -0
  220. package/src/index.test.ts +114 -0
  221. package/src/index.ts +175 -0
  222. package/src/jsonSchema.test.ts +33 -0
  223. package/src/jsonSchema.ts +11 -0
  224. package/src/limits.ts +27 -0
  225. package/src/lint.test.ts +289 -0
  226. package/src/lint.ts +484 -0
  227. package/src/parse.test.ts +281 -0
  228. package/src/parse.ts +104 -0
  229. package/src/parsePartial.test.ts +53 -0
  230. package/src/parsePartial.ts +116 -0
  231. package/src/prompt.test.ts +24 -0
  232. package/src/prompt.ts +104 -0
  233. package/src/schema.ts +509 -0
  234. package/src/tests/askFixtures.ts +40 -0
  235. package/src/validate.perf.test.ts +43 -0
  236. package/src/validate.ts +369 -0
  237. package/tsconfig.json +18 -0
@@ -0,0 +1,341 @@
1
+ import {z} from "zod";
2
+ import {type AskValidationError, askIssue, quoteValue} from "./errors";
3
+ import {formDefaultValues} from "./formValues";
4
+ import {ASK_LIMITS} from "./limits";
5
+ import {
6
+ ASK_KINDS,
7
+ type Ask,
8
+ type AskResponse,
9
+ askResponseSchema,
10
+ type ChoiceAskInput,
11
+ type ChoiceOption,
12
+ type ConfirmAskInput,
13
+ confirmButtonLabels,
14
+ type FilesAskInput,
15
+ type FormAskInput,
16
+ type MarkdownAskInput,
17
+ visibleLabel,
18
+ } from "./schema";
19
+ import {validateAskResponse} from "./validateResponse";
20
+
21
+ export const SIMPLE_CARD_BUTTON_STYLES = ["default", "primary", "destructive", "cancel"] as const;
22
+
23
+ const ELLIPSIS = "…";
24
+ const HIGH_SURROGATE = /[\uD800-\uDBFF]$/;
25
+ const LAST_WORD = /\s\S*$/;
26
+ const WHITESPACE = /\s/;
27
+ const USE_DEFAULT_LABEL_MAX_LENGTH = ASK_LIMITS.simpleCard.buttonLabelMaxLength - 'Use ""'.length;
28
+
29
+ export const simpleCardButtonSchema = z
30
+ .object({
31
+ id: z.string().min(1),
32
+ label: z.string().min(1).max(ASK_LIMITS.simpleCard.buttonLabelMaxLength),
33
+ response: askResponseSchema,
34
+ style: z.enum(SIMPLE_CARD_BUTTON_STYLES),
35
+ })
36
+ .strict();
37
+
38
+ export type SimpleCardButton = z.infer<typeof simpleCardButtonSchema>;
39
+
40
+ /**
41
+ * The small-screen form of an ask: short text and at most three buttons, each carrying the exact
42
+ * answer it sends. `handoff` is true when the buttons cannot show every option the ask offers, so
43
+ * the user needs the full app to answer; a select-many ask always hands off, because one tap cannot
44
+ * pick several, and so do a markdown ask, because a draft cannot be edited there, a form ask,
45
+ * because fields cannot be filled in there, and a files ask, because files cannot be picked there.
46
+ * A card with a button for every option has
47
+ * `handoff: false` even when Skip is left out to make room.
48
+ */
49
+ export const simpleCardSchema = z
50
+ .object({
51
+ buttons: z.array(simpleCardButtonSchema).max(ASK_LIMITS.simpleCard.buttonsMax),
52
+ handoff: z.boolean(),
53
+ kind: z.enum(ASK_KINDS),
54
+ text: z.string().min(1).max(ASK_LIMITS.simpleCard.textMaxLength),
55
+ title: z.string().min(1).max(ASK_LIMITS.simpleCard.titleMaxLength).optional(),
56
+ toolCallId: z.string().min(1),
57
+ })
58
+ .strict()
59
+ .superRefine((card, ctx) => {
60
+ const seen = new Set<string>();
61
+ card.buttons.forEach((button, index) => {
62
+ if (!seen.has(button.id)) {
63
+ seen.add(button.id);
64
+ return;
65
+ }
66
+ ctx.addIssue(
67
+ askIssue({
68
+ code: "DUPLICATE_ID",
69
+ fix: "Give every button a unique id.",
70
+ message: `Button id ${quoteValue(button.id)} is used more than once.`,
71
+ segments: ["buttons", index, "id"],
72
+ })
73
+ );
74
+ });
75
+ });
76
+
77
+ export type SimpleCard = z.infer<typeof simpleCardSchema>;
78
+
79
+ /**
80
+ * Cuts `text` to at most `maxLength` UTF-16 units, ending in "…". Prefers a word boundary when one
81
+ * falls in the second half of the kept text, and never splits a surrogate pair.
82
+ */
83
+ const shorten = (text: string, maxLength: number): string => {
84
+ if (text.length <= maxLength) {
85
+ return text;
86
+ }
87
+ let kept = text.slice(0, maxLength - ELLIPSIS.length);
88
+ if (HIGH_SURROGATE.test(kept)) {
89
+ kept = kept.slice(0, -1);
90
+ }
91
+ if (!WHITESPACE.test(text.charAt(kept.length))) {
92
+ const boundary = kept.search(LAST_WORD);
93
+ if (boundary >= Math.floor(maxLength / 2)) {
94
+ kept = kept.slice(0, boundary);
95
+ }
96
+ }
97
+ return `${kept.trimEnd()}${ELLIPSIS}`;
98
+ };
99
+
100
+ /** A label as its button shows it: without spaces at either end, cut to fit. */
101
+ const buttonLabel = (
102
+ label: string,
103
+ maxLength: number = ASK_LIMITS.simpleCard.buttonLabelMaxLength
104
+ ): string => shorten(visibleLabel(label), maxLength);
105
+
106
+ const SKIP_BUTTON: SimpleCardButton = {
107
+ id: "skip",
108
+ label: "Skip",
109
+ response: {action: "decline"},
110
+ style: "cancel",
111
+ };
112
+
113
+ const acceptSelected = (id: string): AskResponse => ({
114
+ action: "accept",
115
+ content: {selected: [id]},
116
+ });
117
+
118
+ const optionButton = (option: ChoiceOption, isDefault: boolean): SimpleCardButton => ({
119
+ id: `option:${option.id}`,
120
+ label: buttonLabel(option.label),
121
+ response: acceptSelected(option.id),
122
+ style: isDefault ? "primary" : "default",
123
+ });
124
+
125
+ const hasDistinctLabels = (buttons: SimpleCardButton[]): boolean =>
126
+ new Set(buttons.map((button) => button.label)).size === buttons.length;
127
+
128
+ /**
129
+ * The shortcut to the default when the options don't fit. It is left out when its cut label also
130
+ * fits another option, because the user could not tell which option a tap picks.
131
+ */
132
+ const useDefaultButtons = (
133
+ options: ChoiceOption[],
134
+ defaultOption: ChoiceOption | undefined
135
+ ): SimpleCardButton[] => {
136
+ if (!defaultOption) {
137
+ return [];
138
+ }
139
+ const label = buttonLabel(defaultOption.label, USE_DEFAULT_LABEL_MAX_LENGTH);
140
+ const namesAnotherOption = options.some(
141
+ (option) =>
142
+ option !== defaultOption && buttonLabel(option.label, USE_DEFAULT_LABEL_MAX_LENGTH) === label
143
+ );
144
+ if (namesAnotherOption) {
145
+ return [];
146
+ }
147
+ return [
148
+ {
149
+ id: "use-default",
150
+ label: `Use "${label}"`,
151
+ response: acceptSelected(defaultOption.id),
152
+ style: "primary",
153
+ },
154
+ ];
155
+ };
156
+
157
+ /**
158
+ * The shortcut to a many-select's default, when the default is a valid answer on its own. The
159
+ * button cannot list every option it picks, so the card still hands off.
160
+ */
161
+ const useSuggestedButtons = (input: ChoiceAskInput): SimpleCardButton[] => {
162
+ const defaults = input.default ?? [];
163
+ if (defaults.length === 0) {
164
+ return [];
165
+ }
166
+ const response: AskResponse = {action: "accept", content: {selected: [...defaults]}};
167
+ if (validateAskResponse({input, kind: "choice", response}).length > 0) {
168
+ return [];
169
+ }
170
+ return [{id: "use-default", label: "Use suggested", response, style: "primary"}];
171
+ };
172
+
173
+ const choiceCard = (input: ChoiceAskInput): Pick<SimpleCard, "buttons" | "handoff"> => {
174
+ const {buttonsMax} = ASK_LIMITS.simpleCard;
175
+ const skipButtons = input.allowDecline === false ? [] : [SKIP_BUTTON];
176
+ if (input.select === "many") {
177
+ return {buttons: [...useSuggestedButtons(input), ...skipButtons], handoff: true};
178
+ }
179
+ const defaultOption = input.options.find((option) => option.id === input.default?.[0]);
180
+ if (input.options.length > buttonsMax) {
181
+ return {
182
+ buttons: [...useDefaultButtons(input.options, defaultOption), ...skipButtons],
183
+ handoff: true,
184
+ };
185
+ }
186
+
187
+ const defaultFirst = defaultOption
188
+ ? [defaultOption, ...input.options.filter((option) => option !== defaultOption)]
189
+ : input.options;
190
+ const optionButtons = defaultFirst.map((option) =>
191
+ optionButton(option, option === defaultOption)
192
+ );
193
+ if (!hasDistinctLabels(optionButtons)) {
194
+ return {buttons: skipButtons, handoff: true};
195
+ }
196
+ const hasRoomForSkip = optionButtons.length < buttonsMax;
197
+ return {
198
+ buttons: hasRoomForSkip ? [...optionButtons, ...skipButtons] : optionButtons,
199
+ handoff: false,
200
+ };
201
+ };
202
+
203
+ /**
204
+ * Approve first and deny last (D26). A destructive approve is styled `destructive`, so a watch's
205
+ * Double Tap, which presses the first non-destructive button, can only deny. Deny is the negative
206
+ * answer, so the card has no Skip even when the ask allows declining.
207
+ */
208
+ const confirmCard = (input: ConfirmAskInput): Pick<SimpleCard, "buttons" | "handoff"> => {
209
+ const labels = confirmButtonLabels(input);
210
+ return {
211
+ buttons: [
212
+ {
213
+ id: "approve",
214
+ label: buttonLabel(labels.confirm),
215
+ response: {action: "accept", content: {confirmed: true}},
216
+ style: input.destructive === true ? "destructive" : "primary",
217
+ },
218
+ {
219
+ id: "deny",
220
+ label: buttonLabel(labels.deny),
221
+ response: {action: "accept", content: {confirmed: false}},
222
+ style: "cancel",
223
+ },
224
+ ],
225
+ handoff: false,
226
+ };
227
+ };
228
+
229
+ /**
230
+ * A draft cannot be edited on a small screen, so the card always hands off. Approve draft sends the
231
+ * draft unchanged, and is left out when the draft breaks the ask's length rules. Cancel declines.
232
+ */
233
+ const markdownCard = (input: MarkdownAskInput): Pick<SimpleCard, "buttons" | "handoff"> => {
234
+ const response: AskResponse = {
235
+ action: "accept",
236
+ content: {changed: false, markdown: input.initial ?? ""},
237
+ };
238
+ const isDraftValid = validateAskResponse({input, kind: "markdown", response}).length === 0;
239
+ const approveButtons: SimpleCardButton[] = isDraftValid
240
+ ? [{id: "approve", label: "Approve draft", response, style: "primary"}]
241
+ : [];
242
+ const cancelButtons: SimpleCardButton[] =
243
+ input.allowDecline === false
244
+ ? []
245
+ : [{id: "cancel", label: "Cancel", response: {action: "decline"}, style: "cancel"}];
246
+ return {buttons: [...approveButtons, ...cancelButtons], handoff: true};
247
+ };
248
+
249
+ /**
250
+ * Fields cannot be filled in on a small screen, so the card always hands off. Submit defaults sends
251
+ * the fields' defaults, and is offered only when the form has a default and every required field
252
+ * has one, so the answer is valid. Cancel declines.
253
+ */
254
+ const formCard = (input: FormAskInput): Pick<SimpleCard, "buttons" | "handoff"> => {
255
+ const values = formDefaultValues(input);
256
+ const hasEveryRequiredDefault = input.fields.every(
257
+ (field) => field.required !== true || Object.hasOwn(values, field.id)
258
+ );
259
+ const response: AskResponse = {action: "accept", content: {values}};
260
+ const isSubmittable =
261
+ Object.keys(values).length > 0 &&
262
+ hasEveryRequiredDefault &&
263
+ validateAskResponse({input, kind: "form", response}).length === 0;
264
+ const submitButtons: SimpleCardButton[] = isSubmittable
265
+ ? [{id: "submit-defaults", label: "Submit defaults", response, style: "primary"}]
266
+ : [];
267
+ const cancelButtons: SimpleCardButton[] =
268
+ input.allowDecline === false
269
+ ? []
270
+ : [{id: "cancel", label: "Cancel", response: {action: "decline"}, style: "cancel"}];
271
+ return {buttons: [...submitButtons, ...cancelButtons], handoff: true};
272
+ };
273
+
274
+ /** Files cannot be picked on a small screen, so the card always hands off. Skip declines. */
275
+ const filesCard = (input: FilesAskInput): Pick<SimpleCard, "buttons" | "handoff"> => ({
276
+ buttons: input.allowDecline === false ? [] : [SKIP_BUTTON],
277
+ handoff: true,
278
+ });
279
+
280
+ const kindCard = (ask: Ask): Pick<SimpleCard, "buttons" | "handoff"> => {
281
+ switch (ask.kind) {
282
+ case "choice":
283
+ return choiceCard(ask.input);
284
+ case "confirm":
285
+ return confirmCard(ask.input);
286
+ case "markdown":
287
+ return markdownCard(ask.input);
288
+ case "form":
289
+ return formCard(ask.input);
290
+ case "files":
291
+ return filesCard(ask.input);
292
+ }
293
+ };
294
+
295
+ /**
296
+ * The answer a pressed button sends: the `response` stored on the card's button with that id.
297
+ * Returns an `UNKNOWN_BUTTON` error instead when the card has no such button, so a small client can
298
+ * only send answers the card offered.
299
+ */
300
+ export const resolveButtonAnswer = ({
301
+ buttonId,
302
+ card,
303
+ }: {
304
+ buttonId: string;
305
+ card: SimpleCard;
306
+ }): {errors: AskValidationError[]; response?: AskResponse} => {
307
+ const button = card.buttons.find((candidate) => candidate.id === buttonId);
308
+ if (button) {
309
+ return {errors: [], response: button.response};
310
+ }
311
+ const buttonIds = card.buttons.map((candidate) => quoteValue(candidate.id)).join(", ");
312
+ return {
313
+ errors: [
314
+ {
315
+ code: "UNKNOWN_BUTTON",
316
+ fix:
317
+ buttonIds === ""
318
+ ? "The card has no buttons. Send a full askResponse instead."
319
+ : `Send the id of one of the card's buttons: ${buttonIds}.`,
320
+ message: `Button ${quoteValue(buttonId)} is not on the pending ask's simple card.`,
321
+ path: "buttonId",
322
+ },
323
+ ],
324
+ };
325
+ };
326
+
327
+ /**
328
+ * Derives the simple card for a valid ask. Pure and deterministic: the same ask always gives the
329
+ * same card, and every button's `response` passes `validateAskResponse` for that ask.
330
+ */
331
+ export const toSimpleCard = ({
332
+ toolCallId,
333
+ ...askFields
334
+ }: Ask & {toolCallId: string}): SimpleCard => {
335
+ const ask = askFields as Ask;
336
+ const {textMaxLength, titleMaxLength} = ASK_LIMITS.simpleCard;
337
+ const {input, kind} = ask;
338
+ const title = input.title === undefined ? {} : {title: shorten(input.title, titleMaxLength)};
339
+ const card = kindCard(ask);
340
+ return {...card, kind, text: shorten(input.prompt, textMaxLength), ...title, toolCallId};
341
+ };