@mrclrchtr/supi-ask-user 1.4.0 → 1.5.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.
@@ -0,0 +1,400 @@
1
+ import {
2
+ type Component,
3
+ Editor,
4
+ type Focusable,
5
+ Key,
6
+ matchesKey,
7
+ SelectList,
8
+ } from "@earendil-works/pi-tui";
9
+ import type { NormalizedChoiceQuestion } from "../types.ts";
10
+ import { createActionList } from "./overlay-actions.ts";
11
+ import {
12
+ clampIndex,
13
+ currentCustomValue,
14
+ currentPreviewText,
15
+ currentTextValue,
16
+ makeEditorTheme,
17
+ makeSelectListTheme,
18
+ renderOverlayFrame,
19
+ } from "./overlay-render.ts";
20
+ import {
21
+ buildChoiceItems,
22
+ buildChoiceRows,
23
+ type ChoiceRow,
24
+ choiceRowValue,
25
+ defaultChoiceRowIndex,
26
+ type FocusTarget,
27
+ type OverlayAction,
28
+ type OverlayMode,
29
+ previewOptionIndexForRows,
30
+ } from "./overlay-view.ts";
31
+ import type { OverlayArgs } from "./types.ts";
32
+
33
+ export class AskUserOverlay implements Component, Focusable {
34
+ focused = false;
35
+
36
+ private readonly editor: Editor;
37
+ private focus: FocusTarget = "choices";
38
+ private mode: OverlayMode = "choice";
39
+ private closed = false;
40
+ private cachedWidth: number | undefined;
41
+ private cachedLines: string[] | undefined;
42
+ private readonly onAbort: () => void;
43
+
44
+ private choiceRows: ChoiceRow[] = [];
45
+ private choiceRowIndex = 0;
46
+ private previewOptionIndex = 0;
47
+ private choiceList: SelectList | undefined;
48
+ private textActions: Array<{ action: OverlayAction; label: string }> = [];
49
+ private actionIndex = 0;
50
+ private actionList: SelectList | undefined;
51
+
52
+ constructor(private readonly args: OverlayArgs) {
53
+ this.editor = new Editor(args.tui, makeEditorTheme(args.theme));
54
+ this.editor.onSubmit = (value) => this.handleEditorSubmit(value);
55
+ this.syncCurrentQuestion();
56
+ this.onAbort = () => {
57
+ this.args.controller.abort();
58
+ this.finish();
59
+ };
60
+ args.signal?.addEventListener("abort", this.onAbort);
61
+ }
62
+
63
+ render(width: number): string[] {
64
+ this.editor.focused = this.focus === "editor";
65
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
66
+ this.cachedWidth = width;
67
+ this.cachedLines = renderOverlayFrame({
68
+ width,
69
+ theme: this.args.theme,
70
+ controller: this.args.controller,
71
+ mode: this.mode,
72
+ focus: this.focus,
73
+ editor: this.editor,
74
+ choiceRows: this.choiceRows,
75
+ choiceRowIndex: this.choiceRowIndex,
76
+ actionList: this.actionList,
77
+ textActionLabels: this.textActions.map(({ label }) => label),
78
+ previewText: currentPreviewText(
79
+ this.args.controller.currentQuestion,
80
+ this.previewOptionIndex,
81
+ ),
82
+ });
83
+ return this.cachedLines;
84
+ }
85
+
86
+ handleInput(data: string): void {
87
+ if (this.closed || this.args.controller.isTerminal) return;
88
+
89
+ if (this.args.keybindings.matches(data, "app.tools.expand")) {
90
+ this.args.onToggleToolsExpanded?.();
91
+ return;
92
+ }
93
+
94
+ if (this.mode === "note-input" && matchesKey(data, Key.escape)) {
95
+ const question = this.args.controller.currentQuestion;
96
+ const row = this.choiceRows[this.choiceRowIndex];
97
+ if (question.type === "choice" && row?.kind === "option") {
98
+ this.restoreChoiceMode(question, row.optionIndex);
99
+ }
100
+ return;
101
+ }
102
+ if (matchesKey(data, Key.escape)) {
103
+ this.args.controller.cancel();
104
+ this.finish();
105
+ return;
106
+ }
107
+ if (this.mode !== "note-input" && matchesKey(data, Key.left)) {
108
+ if (this.args.controller.goBack()) {
109
+ this.syncCurrentQuestion();
110
+ this.refresh();
111
+ }
112
+ return;
113
+ }
114
+
115
+ switch (this.focus) {
116
+ case "choices":
117
+ this.handleChoiceKey(data);
118
+ return;
119
+ case "actions":
120
+ this.handleActionKey(data);
121
+ return;
122
+ case "editor":
123
+ this.handleEditorKey(data);
124
+ return;
125
+ }
126
+ }
127
+
128
+ invalidate(): void {
129
+ this.cachedLines = undefined;
130
+ this.choiceList?.invalidate();
131
+ this.actionList?.invalidate();
132
+ this.editor.invalidate();
133
+ }
134
+
135
+ dispose(): void {
136
+ this.closed = true;
137
+ this.args.signal?.removeEventListener("abort", this.onAbort);
138
+ }
139
+
140
+ private handleChoiceKey(data: string): void {
141
+ const question = this.args.controller.currentQuestion;
142
+ if (question.type !== "choice" || !this.choiceList) return;
143
+
144
+ if (data === "n") {
145
+ const row = this.choiceRows[this.choiceRowIndex];
146
+ if (row?.kind === "option") this.openNoteEditor(question, row.optionIndex);
147
+ return;
148
+ }
149
+
150
+ if (matchesKey(data, Key.space)) {
151
+ const row = this.choiceRows[this.choiceRowIndex];
152
+ if (row?.kind === "option") this.applyChoiceSelection(question, row.optionIndex, false);
153
+ return;
154
+ }
155
+
156
+ this.choiceList.handleInput(data);
157
+ }
158
+
159
+ private handleActionKey(data: string): void {
160
+ const question = this.args.controller.currentQuestion;
161
+ if (!this.actionList) return;
162
+
163
+ if (question.type === "text") {
164
+ if (matchesKey(data, Key.up) && this.actionIndex === 0) {
165
+ this.focus = "editor";
166
+ this.refresh();
167
+ return;
168
+ }
169
+ } else if (matchesKey(data, Key.up) && this.actionIndex === 0) {
170
+ this.focus = "choices";
171
+ this.refresh();
172
+ return;
173
+ }
174
+
175
+ this.actionList.handleInput(data);
176
+ this.refresh();
177
+ }
178
+
179
+ private handleEditorKey(data: string): void {
180
+ if (this.mode === "text" && matchesKey(data, Key.down) && this.textActions.length > 0) {
181
+ this.focus = "actions";
182
+ this.refresh();
183
+ return;
184
+ }
185
+
186
+ this.editor.handleInput(data);
187
+ this.refresh();
188
+ }
189
+
190
+ private handleEditorSubmit(value: string): void {
191
+ const trimmed = value.trim();
192
+ const question = this.args.controller.currentQuestion;
193
+
194
+ if (this.mode === "discuss-input") {
195
+ this.args.controller.finishDiscuss(trimmed || undefined);
196
+ this.finish();
197
+ return;
198
+ }
199
+ if (this.mode === "note-input") {
200
+ if (question.type !== "choice") return;
201
+ const row = this.choiceRows[this.choiceRowIndex];
202
+ if (row?.kind !== "option") return;
203
+ this.args.controller.setChoiceOptionNote(question, row.optionIndex, trimmed || undefined);
204
+ this.restoreChoiceMode(question, row.optionIndex);
205
+ return;
206
+ }
207
+ if (this.mode === "custom-input") {
208
+ if (trimmed.length === 0) return;
209
+ this.args.controller.setAnswer(question.id, { kind: "custom", value: trimmed });
210
+ this.advanceAfterQuestion();
211
+ return;
212
+ }
213
+ if (trimmed.length === 0) {
214
+ if (question.required) return;
215
+ this.args.controller.clearAnswer(question.id);
216
+ this.advanceAfterQuestion();
217
+ return;
218
+ }
219
+
220
+ this.args.controller.setAnswer(question.id, { kind: "text", value: trimmed });
221
+ this.advanceAfterQuestion();
222
+ }
223
+
224
+ private applyChoiceSelection(
225
+ question: NormalizedChoiceQuestion,
226
+ optionIndex: number,
227
+ submit: boolean,
228
+ ): void {
229
+ if (question.multi) {
230
+ this.toggleMultiChoice(question, optionIndex);
231
+ if (submit && this.args.controller.hasAnswer(question.id)) this.advanceAfterQuestion();
232
+ return;
233
+ }
234
+
235
+ this.args.controller.selectChoiceOption(question, optionIndex);
236
+ this.choiceRowIndex = optionIndex;
237
+ this.previewOptionIndex = optionIndex;
238
+ if (submit) {
239
+ this.advanceAfterQuestion();
240
+ return;
241
+ }
242
+ this.buildChoiceList(question);
243
+ this.refresh();
244
+ }
245
+
246
+ private toggleMultiChoice(question: NormalizedChoiceQuestion, optionIndex: number): void {
247
+ this.args.controller.toggleChoiceOption(question, optionIndex);
248
+ this.choiceRowIndex = optionIndex;
249
+ this.previewOptionIndex = optionIndex;
250
+ this.buildChoiceList(question);
251
+ this.refresh();
252
+ }
253
+
254
+ private openNoteEditor(question: NormalizedChoiceQuestion, optionIndex: number): void {
255
+ const option = question.options[optionIndex];
256
+ if (!option) return;
257
+ this.mode = "note-input";
258
+ this.focus = "editor";
259
+ this.choiceRowIndex = optionIndex;
260
+ this.previewOptionIndex = optionIndex;
261
+ this.editor.setText(this.args.controller.getChoiceOptionNote(question.id, option.value) ?? "");
262
+ this.refresh();
263
+ }
264
+
265
+ private restoreChoiceMode(question: NormalizedChoiceQuestion, optionIndex: number): void {
266
+ this.mode = "choice";
267
+ this.focus = "choices";
268
+ this.editor.setText("");
269
+ this.choiceRows = buildChoiceRows(this.args.controller, question);
270
+ const nextIndex = this.choiceRows.findIndex(
271
+ (row) => row.kind === "option" && row.optionIndex === optionIndex,
272
+ );
273
+ this.choiceRowIndex = clampIndex(
274
+ nextIndex >= 0 ? nextIndex : optionIndex,
275
+ this.choiceRows.length,
276
+ );
277
+ this.previewOptionIndex = optionIndex;
278
+ this.buildChoiceList(question);
279
+ this.refresh();
280
+ }
281
+
282
+ private handleAction(action: OverlayAction): void {
283
+ switch (action) {
284
+ case "other":
285
+ this.mode = "custom-input";
286
+ this.focus = "editor";
287
+ this.editor.setText(currentCustomValue(this.args.controller));
288
+ this.refresh();
289
+ return;
290
+ case "skip":
291
+ this.args.controller.clearAnswer(this.args.controller.currentQuestion.id);
292
+ this.advanceAfterQuestion();
293
+ return;
294
+ case "discuss":
295
+ this.mode = "discuss-input";
296
+ this.focus = "editor";
297
+ this.editor.setText("");
298
+ this.refresh();
299
+ return;
300
+ case "partial":
301
+ this.args.controller.finishPartial();
302
+ this.finish();
303
+ return;
304
+ }
305
+ }
306
+
307
+ private advanceAfterQuestion(): void {
308
+ if (!this.args.controller.goNext()) {
309
+ this.args.controller.finishSubmitted();
310
+ this.finish();
311
+ return;
312
+ }
313
+ this.syncCurrentQuestion();
314
+ this.refresh();
315
+ }
316
+
317
+ private syncCurrentQuestion(): void {
318
+ const question = this.args.controller.currentQuestion;
319
+ if (question.type === "text") {
320
+ this.mode = "text";
321
+ this.focus = "editor";
322
+ this.editor.setText(currentTextValue(this.args.controller, question.initial));
323
+ this.buildTextActions();
324
+ this.choiceRows = [];
325
+ return;
326
+ }
327
+
328
+ this.mode = "choice";
329
+ this.focus = "choices";
330
+ this.textActions = [];
331
+ this.actionList = undefined;
332
+ this.editor.setText("");
333
+ this.choiceRows = buildChoiceRows(this.args.controller, question);
334
+ this.choiceRowIndex = clampIndex(
335
+ defaultChoiceRowIndex(this.args.controller, question, this.choiceRows),
336
+ this.choiceRows.length,
337
+ );
338
+ this.previewOptionIndex =
339
+ previewOptionIndexForRows(this.choiceRows, this.choiceRowIndex, this.previewOptionIndex) ?? 0;
340
+ this.buildChoiceList(question);
341
+ }
342
+
343
+ private buildChoiceList(question: NormalizedChoiceQuestion): void {
344
+ const items = buildChoiceItems(this.args.controller, question, this.choiceRows);
345
+
346
+ const list = new SelectList(
347
+ items,
348
+ Math.min(this.choiceRows.length, 10),
349
+ makeSelectListTheme(this.args.theme),
350
+ );
351
+ list.onSelectionChange = (item) => {
352
+ const nextIndex = this.choiceRows.findIndex((row) => choiceRowValue(row) === item.value);
353
+ if (nextIndex < 0) return;
354
+ this.choiceRowIndex = nextIndex;
355
+ this.previewOptionIndex =
356
+ previewOptionIndexForRows(this.choiceRows, nextIndex, this.previewOptionIndex) ??
357
+ this.previewOptionIndex;
358
+ this.refresh();
359
+ };
360
+ list.onSelect = (item) => {
361
+ const row = this.choiceRows.find((candidate) => choiceRowValue(candidate) === item.value);
362
+ if (!row) return;
363
+ if (row.kind === "option") {
364
+ this.applyChoiceSelection(question, row.optionIndex, true);
365
+ return;
366
+ }
367
+ this.handleAction(row.action);
368
+ };
369
+ list.setSelectedIndex(this.choiceRowIndex);
370
+ this.choiceList = list;
371
+ }
372
+
373
+ private buildTextActions(): void {
374
+ const state = createActionList({
375
+ controller: this.args.controller,
376
+ theme: this.args.theme,
377
+ actionIndex: this.actionIndex,
378
+ onIndexChange: (index) => {
379
+ this.actionIndex = index;
380
+ this.refresh();
381
+ },
382
+ onAction: (action) => this.handleAction(action),
383
+ });
384
+ this.textActions = state.entries;
385
+ this.actionList = state.list;
386
+ this.actionIndex = state.index;
387
+ }
388
+
389
+ private refresh(): void {
390
+ this.cachedLines = undefined;
391
+ this.args.tui.requestRender();
392
+ }
393
+
394
+ private finish(): void {
395
+ if (this.closed) return;
396
+ this.closed = true;
397
+ this.args.signal?.removeEventListener("abort", this.onAbort);
398
+ this.args.done(this.args.controller.outcome());
399
+ }
400
+ }
@@ -8,7 +8,14 @@ import {
8
8
  } from "@earendil-works/pi-tui";
9
9
  import type { AskUserController } from "../session/controller.ts";
10
10
  import type { NormalizedQuestionnaire } from "../types.ts";
11
- import { type FocusTarget, footerText, type OverlayMode, splitColumns } from "./overlay-view.ts";
11
+ import {
12
+ type ChoiceRow,
13
+ type FocusTarget,
14
+ footerText,
15
+ type OverlayMode,
16
+ renderChoiceList,
17
+ splitColumns,
18
+ } from "./overlay-view.ts";
12
19
 
13
20
  export interface RenderOverlayFrameArgs {
14
21
  width: number;
@@ -17,7 +24,8 @@ export interface RenderOverlayFrameArgs {
17
24
  mode: OverlayMode;
18
25
  focus: FocusTarget;
19
26
  editor: Editor;
20
- choiceList: SelectList | undefined;
27
+ choiceRows: ChoiceRow[];
28
+ choiceRowIndex: number;
21
29
  actionList: SelectList | undefined;
22
30
  textActionLabels: string[];
23
31
  previewText?: string;
@@ -114,9 +122,22 @@ function renderBody(args: RenderOverlayFrameArgs): string[] {
114
122
  }
115
123
 
116
124
  function renderChoiceBody(args: RenderOverlayFrameArgs): string[] {
117
- const leftLines = args.choiceList?.render(splitLeftWidth(args.width)) ?? [];
118
-
119
- if (args.mode === "custom-input" || args.mode === "discuss-input") {
125
+ const question = args.controller.currentQuestion;
126
+ const listWidth = splitLeftWidth(args.width);
127
+
128
+ const leftLines =
129
+ question.type === "choice"
130
+ ? renderChoiceList({
131
+ controller: args.controller,
132
+ question,
133
+ rows: args.choiceRows,
134
+ selectedIndex: args.choiceRowIndex,
135
+ theme: args.theme,
136
+ width: listWidth,
137
+ })
138
+ : [];
139
+
140
+ if (args.mode === "custom-input" || args.mode === "discuss-input" || args.mode === "note-input") {
120
141
  const rightLines = renderEditorLines(args, splitRightWidth(args.width));
121
142
  if (args.width >= 100) {
122
143
  return splitColumns({
@@ -169,7 +190,9 @@ function renderEditorLines(args: RenderOverlayFrameArgs, width: number): string[
169
190
  ? "Discuss instead"
170
191
  : args.mode === "custom-input"
171
192
  ? "Other answer"
172
- : "Your answer";
193
+ : args.mode === "note-input"
194
+ ? "Option note"
195
+ : "Your answer";
173
196
 
174
197
  const lines = [args.theme.fg("accent", label), ...args.editor.render(Math.max(20, width - 1))];
175
198
  const question = args.controller.currentQuestion;
@@ -1,12 +1,12 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { SelectItem } from "@earendil-works/pi-tui";
3
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
4
  import type { AskUserController } from "../session/controller.ts";
5
5
  import type { NormalizedChoiceQuestion } from "../types.ts";
6
6
 
7
7
  export type OverlayAction = "other" | "skip" | "discuss" | "partial";
8
8
  export type FocusTarget = "choices" | "editor" | "actions";
9
- export type OverlayMode = "choice" | "text" | "custom-input" | "discuss-input";
9
+ export type OverlayMode = "choice" | "text" | "custom-input" | "discuss-input" | "note-input";
10
10
 
11
11
  export type ChoiceRow =
12
12
  | { kind: "option"; optionIndex: number }
@@ -45,8 +45,8 @@ export function buildChoiceItems(
45
45
  question,
46
46
  optionIndex: row.optionIndex,
47
47
  label: option.label,
48
- description: option.description,
49
48
  selectedIndexes,
49
+ hasNote: !!controller.getChoiceOptionNote(question.id, option.value),
50
50
  }),
51
51
  ]
52
52
  : [];
@@ -117,9 +117,12 @@ export function footerText(args: {
117
117
  if (mode === "custom-input" || mode === "discuss-input") {
118
118
  return "Enter submit • Esc cancel";
119
119
  }
120
+ if (mode === "note-input") {
121
+ return "Enter save • Esc close";
122
+ }
120
123
  return question.multi
121
- ? "↑↓ move • Space toggle • Enter submit • ← back • Esc cancel"
122
- : "↑↓ move • Space select • Enter submit • ← back • Esc cancel";
124
+ ? "↑↓ move • Space toggle • Enter submit • n note • ← back • Esc cancel"
125
+ : "↑↓ move • Space select • Enter submit • n note • ← back • Esc cancel";
123
126
  }
124
127
 
125
128
  export function splitColumns(args: {
@@ -147,14 +150,109 @@ export function choiceRowValue(row: ChoiceRow): string {
147
150
  return row.kind === "option" ? `option:${row.optionIndex}` : `action:${row.action}`;
148
151
  }
149
152
 
153
+ function renderOptionRow(args: {
154
+ option: { label: string; description?: string };
155
+ labelText: string;
156
+ isSelected: boolean;
157
+ theme: Theme;
158
+ width: number;
159
+ }): string[] {
160
+ const { theme, isSelected, labelText, width, option } = args;
161
+ const prefix = isSelected ? "\u2192 " : " ";
162
+
163
+ const lines: string[] = [
164
+ isSelected ? theme.fg("accent", `${prefix}${labelText}`) : `${prefix}${labelText}`,
165
+ ];
166
+
167
+ if (option.description) {
168
+ const descWidth = Math.max(10, width - 2);
169
+ const wrapped = wrapTextWithAnsi(option.description, descWidth);
170
+ for (const descLine of wrapped) {
171
+ lines.push(theme.fg("muted", ` ${descLine}`));
172
+ }
173
+ }
174
+
175
+ return lines;
176
+ }
177
+
178
+ function renderActionRow(args: {
179
+ actionLabel: string;
180
+ isSelected: boolean;
181
+ theme: Theme;
182
+ }): string[] {
183
+ const { theme, isSelected, actionLabel } = args;
184
+ const prefix = isSelected ? "\u2192 " : " ";
185
+ return [isSelected ? theme.fg("accent", `${prefix}${actionLabel}`) : `${prefix}${actionLabel}`];
186
+ }
187
+
188
+ function prepareOptionMarker(
189
+ question: NormalizedChoiceQuestion,
190
+ optionIndex: number,
191
+ selectedIndexes: Set<number>,
192
+ ): string {
193
+ if (question.multi) {
194
+ return selectedIndexes.has(optionIndex) ? "[x]" : "[ ]";
195
+ }
196
+ return selectedIndexes.has(optionIndex) ? "(*)" : "( )";
197
+ }
198
+
199
+ function prepareOptionLabel(
200
+ option: { label: string },
201
+ marker: string,
202
+ recommended: boolean,
203
+ hasNote: boolean,
204
+ ): string {
205
+ return `${marker} ${option.label}${recommended ? " (recommended)" : ""}${hasNote ? " [note]" : ""}`;
206
+ }
207
+
208
+ export function renderChoiceList(args: {
209
+ controller: AskUserController;
210
+ question: NormalizedChoiceQuestion;
211
+ rows: ChoiceRow[];
212
+ selectedIndex: number;
213
+ theme: Theme;
214
+ width: number;
215
+ }): string[] {
216
+ const { controller, question, rows, selectedIndex, theme, width } = args;
217
+ const lines: string[] = [];
218
+ const selectedIndexes = new Set(controller.getSelectedIndexes(question));
219
+
220
+ for (let i = 0; i < rows.length; i++) {
221
+ const row = rows[i];
222
+ const isSelected = i === selectedIndex;
223
+
224
+ if (row.kind === "option") {
225
+ const option = question.options[row.optionIndex];
226
+ if (!option) continue;
227
+
228
+ const marker = prepareOptionMarker(question, row.optionIndex, selectedIndexes);
229
+ const recommended = question.recommendedIndexes.includes(row.optionIndex);
230
+ const hasNote = !!controller.getChoiceOptionNote(question.id, option.value);
231
+ const labelText = prepareOptionLabel(option, marker, recommended, hasNote);
232
+
233
+ lines.push(...renderOptionRow({ option, labelText, isSelected, theme, width }));
234
+ } else {
235
+ const answer = controller.getAnswer(question.id);
236
+ const actionLabelText =
237
+ row.action === "other" && answer?.kind === "custom"
238
+ ? `Other \u2014 ${answer.value}`
239
+ : actionLabel(row.action);
240
+
241
+ lines.push(...renderActionRow({ actionLabel: actionLabelText, isSelected, theme }));
242
+ }
243
+ }
244
+
245
+ return lines;
246
+ }
247
+
150
248
  function buildOptionItem(args: {
151
249
  question: NormalizedChoiceQuestion;
152
250
  optionIndex: number;
153
251
  label: string;
154
- description: string | undefined;
155
252
  selectedIndexes: Set<number>;
253
+ hasNote: boolean;
156
254
  }): SelectItem {
157
- const { question, optionIndex, label, description, selectedIndexes } = args;
255
+ const { question, optionIndex, label, selectedIndexes, hasNote } = args;
158
256
  const recommended = question.recommendedIndexes.includes(optionIndex) ? " (recommended)" : "";
159
257
  const marker = question.multi
160
258
  ? selectedIndexes.has(optionIndex)
@@ -165,8 +263,7 @@ function buildOptionItem(args: {
165
263
  : "( )";
166
264
  return {
167
265
  value: choiceRowValue({ kind: "option", optionIndex }),
168
- label: `${marker} ${label}${recommended}`,
169
- description,
266
+ label: `${marker} ${label}${recommended}${hasNote ? " [note]" : ""}`,
170
267
  };
171
268
  }
172
269