@wix/himalaya-cli 0.960.0 → 0.962.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 (90) hide show
  1. package/dist/cli.mjs +1 -0
  2. package/package.json +2 -2
  3. package/templates/beauty-salon-owner/config.ts +1 -1
  4. package/templates/beauty-salon-owner/dev/index.ts +3075 -0
  5. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +466 -9
  6. package/templates/beauty-salon-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  7. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  8. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  9. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  11. package/templates/community-club-owner/config.ts +1 -1
  12. package/templates/community-club-owner/dev/index.ts +3075 -0
  13. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +466 -9
  14. package/templates/community-club-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  15. package/templates/community-club-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  16. package/templates/community-club-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  17. package/templates/community-club-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  18. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  19. package/templates/events-meetups-owner/config.ts +1 -1
  20. package/templates/events-meetups-owner/dev/index.ts +3075 -0
  21. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +466 -9
  22. package/templates/events-meetups-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  23. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  24. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  25. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  26. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  27. package/templates/gym-fitness-owner/config.ts +1 -1
  28. package/templates/gym-fitness-owner/dev/index.ts +3075 -0
  29. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +466 -9
  30. package/templates/gym-fitness-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  31. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  32. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  33. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  34. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  35. package/templates/nonprofit-cause-owner/config.ts +1 -1
  36. package/templates/nonprofit-cause-owner/dev/index.ts +3075 -0
  37. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +466 -9
  38. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  39. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  40. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  41. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  42. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  43. package/templates/owner-lite/config.ts +1 -1
  44. package/templates/owner-lite/dev/index.ts +3075 -0
  45. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +466 -9
  46. package/templates/owner-lite/tier5-src/_shared/aria-generic-widget.ts +251 -0
  47. package/templates/owner-lite/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  48. package/templates/owner-lite/tier5-src/_shared/aria-widget-model.ts +331 -0
  49. package/templates/owner-lite/tier5-src/_shared/aria-widget-view.ts +722 -0
  50. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  51. package/templates/podcast-creator-owner/config.ts +1 -1
  52. package/templates/podcast-creator-owner/dev/index.ts +3075 -0
  53. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +466 -9
  54. package/templates/podcast-creator-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  55. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  56. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  57. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  58. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  59. package/templates/portfolio-creative-owner/config.ts +1 -1
  60. package/templates/portfolio-creative-owner/dev/index.ts +3075 -0
  61. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +466 -9
  62. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  63. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  64. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  65. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  66. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  67. package/templates/restaurant-owner/config.ts +1 -1
  68. package/templates/restaurant-owner/dev/index.ts +3075 -0
  69. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +466 -9
  70. package/templates/restaurant-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  71. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  72. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  73. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  74. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  75. package/templates/retail-store-owner/config.ts +1 -1
  76. package/templates/retail-store-owner/dev/index.ts +3075 -0
  77. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +466 -9
  78. package/templates/retail-store-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  79. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  80. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  81. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  82. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  83. package/templates/yoga-studio-owner/config.ts +1 -1
  84. package/templates/yoga-studio-owner/dev/index.ts +3075 -0
  85. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +466 -9
  86. package/templates/yoga-studio-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  87. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  88. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  89. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  90. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
@@ -0,0 +1,331 @@
1
+ // The typed view model an Aria response widget becomes, once the envelope
2
+ // envelope scanner has lifted it out of the assistant's prose.
3
+ //
4
+ // The load-bearing observation, read off the reference catalogue: EIGHT of the
5
+ // fourteen kinds share one skeleton — `text` + `options[]` + a primary and a
6
+ // secondary button. So this is one normalized model with variations, not
7
+ // fourteen parsers, and the per-kind work in `aria-widget-kinds.ts` stays small.
8
+ //
9
+ // Everything here is PURE. No network, no worker state, no globals — which is
10
+ // what lets the whole catalogue be tested without booting a bundle.
11
+
12
+ /** The fourteen documented kinds, plus the arm that keeps the screen alive when
13
+ * the service ships a fifteenth.
14
+ *
15
+ * `UNKNOWN` is not a failure mode, it is the forward-compatibility contract:
16
+ * the service can add a widget type any day, and an owner must see the
17
+ * assistant's prose rather than a broken screen. The generic card upgrades
18
+ * this arm
19
+ * to a generic card; the contract here is only that it never throws and never
20
+ * blanks. */
21
+ export type WidgetKind =
22
+ | "SINGLE_SELECT"
23
+ | "MULTI_SELECT"
24
+ | "SINGLE_SELECT_BUTTON"
25
+ | "RADIO_BUTTON"
26
+ | "PLANS"
27
+ | "PREMIUM_UPSELL"
28
+ | "VISUAL_DISPLAY"
29
+ | "AUTOCOMPLETE"
30
+ | "ITEMS_SELECTION"
31
+ | "QUESTIONNAIRE"
32
+ | "CHART"
33
+ | "TODO_PLAN"
34
+ | "ASYNC_TOOL"
35
+ | "OAUTH_REQUIRED"
36
+ | "UNKNOWN";
37
+
38
+ /** Every kind above, in declaration order. Exported because two other things
39
+ * must agree with this union exactly and cannot read a TypeScript type at
40
+ * runtime: the `FragmentSlot` case ids in the descriptor, and the
41
+ * test that pins them to it. */
42
+ export const WIDGET_KINDS: readonly WidgetKind[] = [
43
+ "SINGLE_SELECT",
44
+ "MULTI_SELECT",
45
+ "SINGLE_SELECT_BUTTON",
46
+ "RADIO_BUTTON",
47
+ "PLANS",
48
+ "PREMIUM_UPSELL",
49
+ "VISUAL_DISPLAY",
50
+ "AUTOCOMPLETE",
51
+ "ITEMS_SELECTION",
52
+ "QUESTIONNAIRE",
53
+ "CHART",
54
+ "TODO_PLAN",
55
+ "ASYNC_TOOL",
56
+ "OAUTH_REQUIRED",
57
+ "UNKNOWN",
58
+ ];
59
+
60
+ /** One selectable row.
61
+ *
62
+ * Every field is present and scalar, always — never optional, never null. That
63
+ * is not tidiness, it is a hard requirement of the renderer: `$item.`
64
+ * substitutes WHOLE prop values only, a missing field fails substitution for
65
+ * the entire row, and a non-scalar stringifies to `""`. The existing transcript
66
+ * bubbles already carry fixed `link1..3Label/Url/Hidden` slots for exactly this
67
+ * reason (`aria-chat.ts`). A row that omits `subtitle` renders nothing at all
68
+ * rather than rendering without a subtitle. */
69
+ export interface WidgetOption {
70
+ title: string;
71
+ subtitle: string;
72
+ text: string;
73
+ value: string;
74
+ pictureUrl: string;
75
+ buttonLabel: string;
76
+ /** The per-option button's `respondText`, before selection substitution. */
77
+ respondText: string;
78
+ preselected: boolean;
79
+ /** The option button's own destination, when it has one instead of a
80
+ * `respondText`. `mobileAction.link` / `.externalLink`. Raw — disclosure is
81
+ * the caller's job, through the one existing helper. */
82
+ actionUrl: string;
83
+ /** `mobileAction.screen.id` — an in-app destination rather than a url. */
84
+ actionScreenId: string;
85
+ /** What to say when `actionScreenId` names a screen this app does not have.
86
+ * The catalogue ships exactly that case, and dropping it left an option that
87
+ * navigates nowhere and explains nothing. */
88
+ notSupportedText: string;
89
+ /** PLANS writes each plan's benefits as `list: [{text, icon}]`. Joined to one
90
+ * scalar because an option row is `$item.`-templated and a nested array
91
+ * substitutes as `""`; the structured form rides in `extra` for the kinds
92
+ * that want icons. Empty for every other kind. */
93
+ listText: string;
94
+ }
95
+
96
+ /** A footer button. Same all-fields-present rule as `WidgetOption`. */
97
+ export interface WidgetButton {
98
+ label: string;
99
+ /** What to send back when tapped, before `<SELECTED>` substitution. */
100
+ respondText: string;
101
+ /** `primaryButton.disableIfNothingSelected` — the multi-select gate. */
102
+ disableIfEmpty: boolean;
103
+ /** `mobileAction.externalLink` / `mobileAction.link`. Raw and UNCHECKED here:
104
+ * disclosure is the view's job and reuses the existing `hostOf`/`isWixHost`/
105
+ * `disclosedLabel` machinery in `aria-chat.ts`, which already survives the
106
+ * two defeats that check was hardened against. Normalizing is not vetting,
107
+ * and this
108
+ * model must not grow a second URL check. */
109
+ externalUrl: string;
110
+ }
111
+
112
+ /** The normalized widget. One shape for all fifteen arms.
113
+ *
114
+ * `extra` carries the per-kind fields that do not fit the shared skeleton —
115
+ * chart slices, todo items, questionnaire questions, the async job id. Kept as
116
+ * an untyped bag on purpose: the kinds that need it each have their own issue
117
+ * and will type their own slice of it there, rather than this
118
+ * file growing fourteen optional members that fourteen renderers must ignore. */
119
+ export interface NormalizedWidget {
120
+ kind: WidgetKind;
121
+ text: string;
122
+ bottomText: string;
123
+ badge: string;
124
+ options: WidgetOption[];
125
+ primary: WidgetButton | null;
126
+ secondary: WidgetButton | null;
127
+ extra: Record<string, unknown>;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Reading an untrusted payload
132
+ // ---------------------------------------------------------------------------
133
+ //
134
+ // Widget payloads are model-influenced content off the wire. Every reader below
135
+ // answers with a usable value for ANY input — including `null`, an array where
136
+ // an object was expected, and a number where a string was. Nothing here throws,
137
+ // because the one thing worse than a widget that renders wrong is a chat screen
138
+ // that stops rendering.
139
+
140
+ /** May this url be handed to the system opener?
141
+ *
142
+ * `http(s)` only. A widget button's destination is MODEL OUTPUT and is
143
+ * reachable by prompt injection through content this owner does not control
144
+ * — a review, an enquiry, a product description. The transcript's own links
145
+ * are already restricted to `http(s)` by the extractor that finds them, but
146
+ * a widget url arrives straight off the payload, so before this it reached
147
+ * the opener after nothing but a `trim()`.
148
+ *
149
+ * That matters more than it looks: the opener takes ANY scheme, so a payload
150
+ * could name `tel:`, `sms:`, a `wix://` deep link, or a scheme some other
151
+ * app on the device registered. Disclosure changes the LABEL beside the
152
+ * button; it does not stop the navigation.
153
+ *
154
+ * Exported so it can be tested directly: through the worker the guard is
155
+ * INVISIBLE — a refused url and a url whose opener throws both leave the
156
+ * state untouched, so a test driving the action cannot tell them apart.
157
+ *
158
+ * Anchored and case-insensitive: `HTTPS:` is the same scheme, and
159
+ * `javascript:void(0)//https://x` must not pass on a substring. */
160
+ export function isWebUrl(url: string): boolean {
161
+ return /^https?:\/\//i.test(url.trim());
162
+ }
163
+
164
+ /** A plain object, or `null` for anything else — arrays included. `typeof []`
165
+ * is `"object"`, so the array test is doing real work here. */
166
+ export function objectAt(v: unknown): Record<string, unknown> | null {
167
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
168
+ }
169
+
170
+ /** A display string, whether the wire wrote it bare or wrapped.
171
+ *
172
+ * QUESTIONNAIRE writes every label as `{ text: "…" }` — question titles,
173
+ * subtitles, each option's title and subtitle. Every other kind writes them
174
+ * bare. Reading only the bare form turned a whole questionnaire into a shape
175
+ * with the right question COUNT and no prompts or choices in it: a renderer
176
+ * would have shown an empty form, and the count assertion would still pass.
177
+ *
178
+ * Accepts either, at any depth of one. */
179
+ export function textOf(v: unknown): string {
180
+ if (typeof v === "string") return v;
181
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
182
+ const o = objectAt(v);
183
+ return o && typeof o.text === "string" ? o.text : "";
184
+ }
185
+
186
+ /** `textOf` for a key on a container. */
187
+ export function textAt(o: Record<string, unknown> | null, key: string): string {
188
+ return o ? textOf(o[key]) : "";
189
+ }
190
+
191
+ /** A string, or `""`. Numbers and booleans are stringified — the service does
192
+ * send a bare number for a value now and then, and dropping it would lose the
193
+ * option's identity rather than merely its label. */
194
+ export function stringAt(o: Record<string, unknown> | null, key: string): string {
195
+ if (!o) return "";
196
+ const v = o[key];
197
+ if (typeof v === "string") return v;
198
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
199
+ return "";
200
+ }
201
+
202
+ /** A boolean, or `false`. Only a real `true` counts: a truthy string would make
203
+ * `"false"` mean true, which is the wrong way for a preselection to fail. */
204
+ export function boolAt(o: Record<string, unknown> | null, key: string): boolean {
205
+ return !!o && o[key] === true;
206
+ }
207
+
208
+ /** An array of plain objects, or `[]`. Non-object entries are dropped rather
209
+ * than coerced: a string in an options array has no value to select by, and a
210
+ * row that selects nothing is worse than a row that is not there. */
211
+ export function objectsAt(o: Record<string, unknown> | null, key: string): Record<string, unknown>[] {
212
+ if (!o) return [];
213
+ const v = o[key];
214
+ if (!Array.isArray(v)) return [];
215
+ const out: Record<string, unknown>[] = [];
216
+ for (const e of v) {
217
+ const obj = objectAt(e);
218
+ if (obj) out.push(obj);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** A badge's display text. The service sends `{text, type, skin}`; only the
224
+ * text reaches the model, because `type` and `skin` are Wix-design-system
225
+ * tokens with no counterpart in Himi's vocabulary. */
226
+ export function badgeTextOf(o: Record<string, unknown> | null): string {
227
+ return stringAt(objectAt(o?.badge), "text");
228
+ }
229
+
230
+ /** One button, or `null` when the key is absent.
231
+ *
232
+ * `null` rather than an empty button is the whole point: the view hides the
233
+ * footer
234
+ * row on `null`, and an all-empty button would paint a tappable control with no
235
+ * label that sends an empty string. */
236
+ export function buttonAt(o: Record<string, unknown> | null, key: string): WidgetButton | null {
237
+ const b = objectAt(o?.[key]);
238
+ if (!b) return null;
239
+ const action = objectAt(b.mobileAction);
240
+ return {
241
+ label: stringAt(b, "label"),
242
+ respondText: stringAt(b, "respondText"),
243
+ disableIfEmpty: boolAt(b, "disableIfNothingSelected"),
244
+ // Two spellings for one concept, and both are live in the catalogue:
245
+ // PREMIUM_UPSELL sends `externalLink`, CHART sends `link`.
246
+ externalUrl: stringAt(action, "externalLink") || stringAt(action, "link"),
247
+ };
248
+ }
249
+
250
+ /** The shared option skeleton — the eight-kind commonality this model is built
251
+ * around. Per-kind normalizers reshape what comes out; none of them re-reads
252
+ * the wire format. */
253
+ export function optionsOf(o: Record<string, unknown> | null): WidgetOption[] {
254
+ return objectsAt(o, "options").map((opt) => {
255
+ // `button` is the usual spelling; VISUAL_DISPLAY's variant card uses
256
+ // `primaryButton`, the same name the widget-level footer uses.
257
+ const button = objectAt(opt.button) ?? objectAt(opt.primaryButton);
258
+ const action = objectAt(button?.mobileAction);
259
+ // A variant card carries its label under `sections`, not as a flat title.
260
+ const sections = objectsAt(opt, "sections");
261
+ const first = sections[0] ?? null;
262
+ return {
263
+ title: textAt(opt, "title") || textAt(first, "title"),
264
+ subtitle: textAt(opt, "subtitle") || textAt(first, "subtitle"),
265
+ text: textAt(opt, "text"),
266
+ value: stringAt(opt, "value"),
267
+ pictureUrl: stringAt(objectAt(opt.picture), "url"),
268
+ buttonLabel: stringAt(button, "label"),
269
+ respondText: stringAt(button, "respondText"),
270
+ preselected: boolAt(opt, "isPreselected"),
271
+ actionUrl: stringAt(action, "externalLink") || stringAt(action, "link"),
272
+ actionScreenId: stringAt(objectAt(action?.screen), "id"),
273
+ notSupportedText: textAt(objectAt(action?.notSupported), "description"),
274
+ listText: objectsAt(opt, "list").map((x) => textOf(x)).filter(Boolean).join("\n"),
275
+ };
276
+ });
277
+ }
278
+
279
+ /** An empty widget of the given kind. The base every normalizer starts from, and
280
+ * the answer for a payload that could not be read at all. */
281
+ export function emptyWidget(kind: WidgetKind, text = ""): NormalizedWidget {
282
+ return { kind, text, bottomText: "", badge: "", options: [], primary: null, secondary: null, extra: {} };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // `<SELECTED>` substitution
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** The tokens the service writes into a `respondText` where the owner's choice
290
+ * belongs: `"Use <SELECTED>"` becomes `"Use Sunny Deals"`.
291
+ *
292
+ * TWO spellings, both live in the reference catalogue — `<SELECTED>` five
293
+ * times, `<VALUES>` three (RADIO_BUTTON's primary button, and each PLANS
294
+ * option's). Handling only the first sent the owner's own words back with
295
+ * `<VALUES>` still in them, which is the failure mode this substitution
296
+ * exists to prevent, just wearing the other name. */
297
+ export const SELECTED_TOKEN = "<SELECTED>";
298
+ export const VALUES_TOKEN = "<VALUES>";
299
+ /** Matches either. A regex for `split` only — never for `replace`, whose `$`
300
+ * semantics are the reason this is a split in the first place. */
301
+ const SELECTION_TOKENS = /<SELECTED>|<VALUES>/g;
302
+
303
+ /** Put the selection into a `respondText`.
304
+ *
305
+ * Lives here, as a pure string function, rather than in the worker — so the
306
+ * cases that actually bite can be tested exhaustively without booting a bundle.
307
+ *
308
+ * Three decisions worth stating, because each is a case the naive version gets
309
+ * wrong:
310
+ *
311
+ * - **`split`/`join`, not `replaceAll`.** The replacement is a selected VALUE:
312
+ * model-influenced text off the wire. `String.replaceAll` reads `$` in its
313
+ * replacement as a SUBSTITUTION PATTERN, so a value containing `$&` splices
314
+ * the matched `<SELECTED>` back in, `` $` `` splices the text before it, and
315
+ * `$$` collapses to one `$`. A price like `"$5 $& up"` would come back
316
+ * mangled. `split`/`join` has no such syntax and inserts the value literally.
317
+ * (It is NOT about re-scanning: `replaceAll` with a string replacement does
318
+ * not re-read its own output, so a value merely containing the literal
319
+ * `<SELECTED>` is safe either way. An earlier draft of this comment claimed
320
+ * otherwise and the mutation test proved it wrong.)
321
+ * - **No token means no substitution.** The service sends plenty of
322
+ * `respondText` values with no placeholder at all; they are sent verbatim.
323
+ * - **An empty selection collapses the token** rather than leaving it visible.
324
+ * Sending the owner's own words back with `<SELECTED>` still in them is the
325
+ * one outcome that reads as a bug to the person who sees it. */
326
+ export function substituteSelection(respondText: string, selected: string[]): string {
327
+ if (!respondText) return "";
328
+ const parts = respondText.split(SELECTION_TOKENS);
329
+ if (parts.length === 1) return respondText;
330
+ return parts.join(selected.join(", "));
331
+ }