@nan0web/ui-cli 1.1.0 → 2.0.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 (115) hide show
  1. package/README.md +114 -207
  2. package/package.json +22 -12
  3. package/src/CLI.js +22 -29
  4. package/src/CLiMessage.js +2 -3
  5. package/src/Command.js +26 -24
  6. package/src/CommandError.js +3 -5
  7. package/src/CommandHelp.js +40 -36
  8. package/src/CommandMessage.js +56 -40
  9. package/src/CommandParser.js +27 -25
  10. package/src/InputAdapter.js +630 -90
  11. package/src/OutputAdapter.js +7 -8
  12. package/src/README.md.js +190 -316
  13. package/src/components/Alert.js +3 -6
  14. package/src/components/prompt/Autocomplete.js +12 -0
  15. package/src/components/prompt/Confirm.js +29 -0
  16. package/src/components/prompt/DateTime.js +26 -0
  17. package/src/components/prompt/Input.js +15 -0
  18. package/src/components/prompt/Mask.js +12 -0
  19. package/src/components/prompt/Multiselect.js +26 -0
  20. package/src/components/prompt/Next.js +8 -0
  21. package/src/components/prompt/Password.js +13 -0
  22. package/src/components/prompt/Pause.js +9 -0
  23. package/src/components/prompt/ProgressBar.js +16 -0
  24. package/src/components/prompt/Select.js +29 -0
  25. package/src/components/prompt/Slider.js +16 -0
  26. package/src/components/prompt/Spinner.js +29 -0
  27. package/src/components/prompt/Toggle.js +13 -0
  28. package/src/components/prompt/Tree.js +17 -0
  29. package/src/components/view/Alert.js +78 -0
  30. package/src/components/view/Badge.js +11 -0
  31. package/src/components/view/Nav.js +23 -0
  32. package/src/components/view/Table.js +12 -0
  33. package/src/components/view/Toast.js +9 -0
  34. package/src/core/Component.js +79 -0
  35. package/src/core/PropValidation.js +138 -0
  36. package/src/core/render.js +37 -0
  37. package/src/index.js +80 -41
  38. package/src/test/PlaygroundTest.js +37 -25
  39. package/src/test/index.js +2 -4
  40. package/src/ui/alert.js +58 -0
  41. package/src/ui/autocomplete.js +86 -0
  42. package/src/ui/badge.js +35 -0
  43. package/src/ui/confirm.js +49 -0
  44. package/src/ui/date-time.js +45 -0
  45. package/src/ui/form.js +120 -55
  46. package/src/ui/index.js +18 -4
  47. package/src/ui/input.js +79 -152
  48. package/src/ui/mask.js +132 -0
  49. package/src/ui/multiselect.js +59 -0
  50. package/src/ui/nav.js +74 -0
  51. package/src/ui/next.js +18 -13
  52. package/src/ui/progress.js +88 -0
  53. package/src/ui/select.js +49 -72
  54. package/src/ui/slider.js +154 -0
  55. package/src/ui/spinner.js +65 -0
  56. package/src/ui/table.js +163 -0
  57. package/src/ui/toast.js +34 -0
  58. package/src/ui/toggle.js +34 -0
  59. package/src/ui/tree.js +393 -0
  60. package/src/utils/parse.js +1 -1
  61. package/types/CLI.d.ts +5 -5
  62. package/types/CLiMessage.d.ts +1 -1
  63. package/types/Command.d.ts +2 -2
  64. package/types/CommandHelp.d.ts +3 -3
  65. package/types/CommandMessage.d.ts +8 -8
  66. package/types/CommandParser.d.ts +3 -3
  67. package/types/InputAdapter.d.ts +149 -15
  68. package/types/OutputAdapter.d.ts +1 -1
  69. package/types/README.md.d.ts +1 -1
  70. package/types/UiMessage.d.ts +31 -29
  71. package/types/components/prompt/Autocomplete.d.ts +6 -0
  72. package/types/components/prompt/Confirm.d.ts +6 -0
  73. package/types/components/prompt/DateTime.d.ts +6 -0
  74. package/types/components/prompt/Input.d.ts +6 -0
  75. package/types/components/prompt/Mask.d.ts +6 -0
  76. package/types/components/prompt/Multiselect.d.ts +6 -0
  77. package/types/components/prompt/Next.d.ts +6 -0
  78. package/types/components/prompt/Password.d.ts +6 -0
  79. package/types/components/prompt/Pause.d.ts +6 -0
  80. package/types/components/prompt/ProgressBar.d.ts +12 -0
  81. package/types/components/prompt/Select.d.ts +18 -0
  82. package/types/components/prompt/Slider.d.ts +6 -0
  83. package/types/components/prompt/Spinner.d.ts +21 -0
  84. package/types/components/prompt/Toggle.d.ts +6 -0
  85. package/types/components/prompt/Tree.d.ts +6 -0
  86. package/types/components/view/Alert.d.ts +21 -0
  87. package/types/components/view/Badge.d.ts +5 -0
  88. package/types/components/view/Nav.d.ts +15 -0
  89. package/types/components/view/Table.d.ts +10 -0
  90. package/types/components/view/Toast.d.ts +5 -0
  91. package/types/core/Component.d.ts +34 -0
  92. package/types/core/PropValidation.d.ts +48 -0
  93. package/types/core/render.d.ts +6 -0
  94. package/types/index.d.ts +47 -15
  95. package/types/test/PlaygroundTest.d.ts +12 -8
  96. package/types/test/index.d.ts +1 -1
  97. package/types/ui/alert.d.ts +14 -0
  98. package/types/ui/autocomplete.d.ts +20 -0
  99. package/types/ui/badge.d.ts +8 -0
  100. package/types/ui/confirm.d.ts +21 -0
  101. package/types/ui/date-time.d.ts +19 -0
  102. package/types/ui/form.d.ts +43 -12
  103. package/types/ui/index.d.ts +17 -2
  104. package/types/ui/input.d.ts +31 -74
  105. package/types/ui/mask.d.ts +29 -0
  106. package/types/ui/multiselect.d.ts +25 -0
  107. package/types/ui/nav.d.ts +27 -0
  108. package/types/ui/progress.d.ts +43 -0
  109. package/types/ui/select.d.ts +25 -64
  110. package/types/ui/slider.d.ts +23 -0
  111. package/types/ui/spinner.d.ts +28 -0
  112. package/types/ui/table.d.ts +28 -0
  113. package/types/ui/toast.d.ts +8 -0
  114. package/types/ui/toggle.d.ts +17 -0
  115. package/types/ui/tree.d.ts +48 -0
@@ -4,19 +4,44 @@
4
4
  * @module InputAdapter
5
5
  */
6
6
 
7
- import {
8
- UiForm,
9
- InputAdapter as BaseInputAdapter,
10
- UiMessage,
11
- } from "@nan0web/ui"
12
- import { CancelError } from "@nan0web/ui/core"
7
+ import { UiForm, InputAdapter as BaseInputAdapter, UiMessage } from '@nan0web/ui'
8
+ import { CancelError } from '@nan0web/ui/core'
9
+ import prompts from 'prompts'
10
+ import readline from 'node:readline'
13
11
 
14
- import { ask as baseAsk, createInput, createPredefinedInput, Input } from "./ui/input.js"
15
- import { select as baseSelect } from "./ui/select.js"
16
- import { generateForm } from "./ui/form.js"
12
+ import {
13
+ ask as baseAsk,
14
+ text as baseText,
15
+ beep,
16
+ createInput,
17
+ createPredefinedInput,
18
+ Input,
19
+ } from './ui/input.js'
20
+ import { next } from './ui/next.js'
21
+ import { select as baseSelect } from './ui/select.js'
22
+ import { confirm as baseConfirm } from './ui/confirm.js'
23
+ import { autocomplete as baseAutocomplete } from './ui/autocomplete.js'
24
+ import { table as baseTable } from './ui/table.js'
25
+ import { multiselect as baseMultiselect } from './ui/multiselect.js'
26
+ import { mask as baseMask } from './ui/mask.js'
27
+ import { toggle as baseToggle } from './ui/toggle.js'
28
+ import { slider as baseSlider } from './ui/slider.js'
29
+ import { progress as baseProgress } from './ui/progress.js'
30
+ import { spinner as baseSpinner } from './ui/spinner.js'
31
+ import { tree as baseTree } from './ui/tree.js'
32
+ import { datetime as baseDateTime } from './ui/date-time.js'
33
+ import { generateForm } from './ui/form.js'
34
+
35
+ const DEFAULT_MAX_RETRIES = 100
17
36
 
18
- /** @typedef {import("./ui/select.js").InputFn} InputFn */
19
- /** @typedef {import("./ui/select.js").ConsoleLike} ConsoleLike */
37
+ /**
38
+ * @typedef {Object} ConsoleLike
39
+ * @property {(...args: any[]) => void} debug
40
+ * @property {(...args: any[]) => void} log
41
+ * @property {(...args: any[]) => void} info
42
+ * @property {(...args: any[]) => void} warn
43
+ * @property {(...args: any[]) => void} error
44
+ */
20
45
 
21
46
  /**
22
47
  * Extends the generic {@link BaseInputAdapter} with CLI‑specific behaviour.
@@ -32,29 +57,43 @@ export default class CLiInputAdapter extends BaseInputAdapter {
32
57
  /** @type {ConsoleLike} */
33
58
  #console
34
59
  #stdout
35
- /** @type {Map<string, () => Promise<Function>>} */
60
+ /** @type {Map<string, () => Promise<any>>} */
36
61
  #components = new Map()
62
+ /** @type {number} */
63
+ #maxRetries
64
+ /** @type {Function} */
65
+ #t
37
66
 
38
67
  constructor(options = {}) {
39
68
  super()
40
69
  const {
41
70
  predefined = process.env.PLAY_DEMO_SEQUENCE ?? [],
42
- divider = process.env.PLAY_DEMO_DIVIDER ?? ",",
71
+ divider = process.env.PLAY_DEMO_DIVIDER ?? ',',
43
72
  console: initialConsole = console,
44
73
  stdout = process.stdout,
45
74
  components = new Map(),
75
+ t = (key) => key,
46
76
  } = options
47
77
 
78
+ let { maxRetries = process.env.UI_CLI_MAX_RETRIES ?? DEFAULT_MAX_RETRIES } = options
79
+
80
+ // Increase default retries for automated environments to prevent flakiness
81
+ if (process.env.PLAY_DEMO_SEQUENCE && maxRetries === DEFAULT_MAX_RETRIES) {
82
+ maxRetries = 1000
83
+ }
84
+
48
85
  this.#console = initialConsole
49
86
  this.#stdout = stdout
50
87
  this.#components = components
88
+ this.#maxRetries = Number(maxRetries)
89
+ this.#t = t
51
90
 
52
91
  if (Array.isArray(predefined)) {
53
- this.#answers = predefined.map(v => String(v))
54
- } else if (typeof predefined === "string") {
92
+ this.#answers = predefined.map((v) => String(v))
93
+ } else if (typeof predefined === 'string') {
55
94
  this.#answers = predefined
56
95
  .split(divider)
57
- .map(v => v.trim())
96
+ .map((v) => v.trim())
58
97
  .filter(Boolean)
59
98
  } else {
60
99
  this.#answers = []
@@ -62,9 +101,21 @@ export default class CLiInputAdapter extends BaseInputAdapter {
62
101
  }
63
102
 
64
103
  /** @returns {ConsoleLike} */
65
- get console() { return this.#console }
104
+ get console() {
105
+ return this.#console
106
+ }
107
+ /** @returns {Function} */
108
+ get t() {
109
+ return this.#t
110
+ }
111
+ /** @param {Function} val */
112
+ set t(val) {
113
+ this.#t = val
114
+ }
66
115
  /** @returns {NodeJS.WriteStream} */
67
- get stdout() { return this.#stdout }
116
+ get stdout() {
117
+ return this.#stdout
118
+ }
68
119
 
69
120
  #nextAnswer() {
70
121
  if (this.#cursor < this.#answers.length) {
@@ -75,6 +126,11 @@ export default class CLiInputAdapter extends BaseInputAdapter {
75
126
  return null
76
127
  }
77
128
 
129
+ /** @returns {string[]} */
130
+ getRemainingAnswers() {
131
+ return this.#answers.slice(this.#cursor)
132
+ }
133
+
78
134
  /**
79
135
  * Normalise a value that can be either a raw string or an {@link Input}
80
136
  * instance (which carries the actual value in its `value` property).
@@ -83,17 +139,41 @@ export default class CLiInputAdapter extends BaseInputAdapter {
83
139
  * @returns {string} Plain string value.
84
140
  */
85
141
  #normalise(val) {
86
- if (val && typeof val === "object" && "value" in val) {
142
+ if (val && typeof val === 'object' && 'value' in val) {
87
143
  return String(val.value)
88
144
  }
89
- return String(val ?? "")
145
+ return String(val ?? '')
146
+ }
147
+
148
+ /**
149
+ * Private helper to format value according to mask
150
+ * @param {string|number} value
151
+ * @param {string} mask
152
+ */
153
+ #applyMask(value, mask) {
154
+ if (!mask) return String(value)
155
+ const cleanValue = String(value).replace(/[^a-zA-Z0-9]/g, '')
156
+ let i = 0
157
+ let v = 0
158
+ let result = ''
159
+ while (i < mask.length && v < cleanValue.length) {
160
+ const maskChar = mask[i]
161
+ if (maskChar === '#' || maskChar === '0' || maskChar === 'A') {
162
+ result += cleanValue[v]
163
+ v++
164
+ } else {
165
+ result += maskChar
166
+ }
167
+ i++
168
+ }
169
+ return result
90
170
  }
91
171
 
92
172
  /**
93
173
  * Create a handler with stop words that supports predefined answers.
94
174
  *
95
175
  * @param {string[]} stops - Stop words for cancellation.
96
- * @returns {InputFn}
176
+ * @returns {Function}
97
177
  */
98
178
  createHandler(stops = []) {
99
179
  const self = this
@@ -103,7 +183,7 @@ export default class CLiInputAdapter extends BaseInputAdapter {
103
183
  this.stdout.write(`${question}${predefined}\n`)
104
184
  const input = new Input({ value: predefined, stops })
105
185
  if (input.cancelled) {
106
- throw new CancelError("Cancelled via stop word")
186
+ throw new CancelError('Cancelled via stop word')
107
187
  }
108
188
  return input
109
189
  }
@@ -112,6 +192,61 @@ export default class CLiInputAdapter extends BaseInputAdapter {
112
192
  }
113
193
  }
114
194
 
195
+ /**
196
+ * Create a select handler that supports predefined answers.
197
+ * @returns {Function}
198
+ */
199
+ createSelectHandler() {
200
+ const self = this
201
+ return async (config) => {
202
+ const predefined = self.#nextAnswer()
203
+ if (predefined !== null) {
204
+ // Normalize options to find value
205
+ let choices = []
206
+ const options = config.options || []
207
+ if (options instanceof Map) {
208
+ choices = Array.from(options.entries()).map(([value, label]) => ({ label, value }))
209
+ } else if (Array.isArray(options)) {
210
+ choices = options.map((el) => (typeof el === 'string' ? { label: el, value: el } : el))
211
+ }
212
+
213
+ const idx = Number(predefined) - 1
214
+ let valToInject = predefined
215
+ if (!isNaN(idx) && idx >= 0 && idx < choices.length) {
216
+ valToInject = choices[idx].value
217
+ }
218
+
219
+ if (config.console) {
220
+ if (config.title) config.console.info(config.title)
221
+ choices.forEach((c, i) => {
222
+ config.console.info(` ${i + 1}) ${c.label}`)
223
+ })
224
+ const p = config.prompt ?? ''
225
+ config.console.info(`${p}${predefined}`)
226
+ }
227
+ prompts.inject([valToInject])
228
+ }
229
+ return self.select(config)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Pause execution and wait for user input (Press any key).
235
+ *
236
+ * @param {string} [message] - Message to display.
237
+ * @returns {Promise<void>}
238
+ */
239
+ async pause(message) {
240
+ const predefined = this.#nextAnswer()
241
+ if (message) this.console.info(message)
242
+ if (predefined !== null) {
243
+ // Automated mode: proceed immediately
244
+ return
245
+ }
246
+ // Interactive mode
247
+ await next()
248
+ }
249
+
115
250
  /**
116
251
  * Prompt the user for a full form, handling navigation and validation.
117
252
  *
@@ -122,101 +257,205 @@ export default class CLiInputAdapter extends BaseInputAdapter {
122
257
  */
123
258
  async requestForm(form, options = {}) {
124
259
  const { silent = true } = options
125
- if (!silent) this.console.info(`\n${form.title}\n`)
260
+ if (!silent) this.console.info(`\n${this.#t(form.title)}\n`)
126
261
 
127
262
  let formData = { ...form.state }
128
263
  let idx = 0
264
+ let retries = 0
129
265
 
130
266
  while (idx < form.fields.length) {
131
- const field = form.fields[idx]
132
- const prompt = `${field.label || field.name}${field.required ? " *" : ""}: `
267
+ const field = /** @type {any} */ (form.fields[idx])
268
+ if (retries > this.#maxRetries) {
269
+ return {
270
+ body: { action: 'form-cancel', cancelled: true, error: 'Infinite loop detected' },
271
+ cancelled: true,
272
+ action: 'form-cancel',
273
+ }
274
+ }
275
+ const label = this.#t(field.label || field.name)
276
+ const hasPunctuation = label.trim().endsWith('?') || label.trim().endsWith(':')
277
+ const promptMsg = `${label}${field.required ? ' *' : ''}${hasPunctuation ? '' : ':'}`
133
278
 
134
279
  // ---- Handle select fields (options) ----------------------------------------
135
- if (Array.isArray(field.options ?? 0) && field.options.length) {
280
+ if (
281
+ field.type === 'select' ||
282
+ (Array.isArray(field.options ?? 0) &&
283
+ field.options.length &&
284
+ field.type !== 'multiselect' &&
285
+ field.type !== 'autocomplete')
286
+ ) {
136
287
  const options = /** @type {any[]} */ (field.options)
137
288
 
138
- const predefined = this.#nextAnswer()
139
289
  const selConfig = {
140
- title: field.label,
141
- prompt: "Choose (number): ",
142
- options: options.map(opt =>
143
- typeof opt === "string"
144
- ? { label: opt, value: opt }
145
- : opt
290
+ title: this.#t(field.label),
291
+ prompt: this.#t('Choose (number): '),
292
+ options: options.map((opt) =>
293
+ typeof opt === 'string'
294
+ ? { label: this.#t(opt), value: opt }
295
+ : { ...opt, label: this.#t(opt.label) }
146
296
  ),
147
297
  console: { info: this.console.info.bind(this.console) },
148
298
  }
149
-
150
- let selResult
151
- if (predefined !== null) {
152
- selConfig.ask = createPredefinedInput([predefined], this.console)
153
- selResult = await this.select(selConfig)
154
- } else {
155
- selConfig.ask = this.createHandler(["cancel", "quit", "exit"])
156
- selResult = await this.select(selConfig)
299
+ const chosen = await this.requestSelect(selConfig)
300
+ if (!chosen || chosen.cancelled) {
301
+ return {
302
+ body: { action: 'form-cancel', cancelled: true },
303
+ cancelled: true,
304
+ action: 'form-cancel',
305
+ }
157
306
  }
158
307
 
159
- const chosen = selResult?.value
160
- if (!options.includes(chosen)) {
161
- this.console.error("\nEnumeration must have one value")
308
+ const values = options.map((o) => (typeof o === 'string' ? o : o.value))
309
+ if (!values.includes(chosen.value)) {
310
+ this.console.error('\nEnumeration must have one value')
311
+ retries++
162
312
  continue
163
313
  }
164
- formData[field.name] = String(chosen)
314
+ formData[field.name] = String(chosen.value)
315
+ idx++
316
+ retries = 0
317
+ continue
318
+ }
319
+ // ---- Handle confirm / toggle fields --------------------------------------
320
+ if (field.type === 'confirm' || field.type === 'toggle') {
321
+ const res = await this.requestConfirm({ message: promptMsg })
322
+ if (!res || res.cancelled) {
323
+ return {
324
+ body: { action: 'form-cancel', cancelled: true },
325
+ cancelled: true,
326
+ action: 'form-cancel',
327
+ }
328
+ }
329
+ formData[field.name] = res.value
165
330
  idx++
166
331
  continue
167
332
  }
168
- // ---------------------------------------------------------------------------
169
333
 
170
- const rawAnswer = await this.ask(prompt)
171
- const answerStr = this.#normalise(rawAnswer)
334
+ // ---- Handle multiselect fields -------------------------------------------
335
+ if (field.type === 'multiselect') {
336
+ const res = await this.requestMultiselect({
337
+ message: promptMsg,
338
+ options: Array.isArray(field.options) ? field.options : [],
339
+ initial: [],
340
+ })
341
+ if (!res || res.cancelled) {
342
+ return {
343
+ body: { action: 'form-cancel', cancelled: true },
344
+ cancelled: true,
345
+ action: 'form-cancel',
346
+ }
347
+ }
348
+ formData[field.name] = res.value
349
+ idx++
350
+ continue
351
+ }
172
352
 
173
- if (["", "esc"].includes(answerStr)) {
353
+ // ---- Handle mask fields --------------------------------------------------
354
+ if (field.type === 'mask') {
355
+ const res = await this.requestMask({
356
+ message: promptMsg,
357
+ mask: field.mask || '',
358
+ placeholder: field.placeholder || '',
359
+ })
360
+ if (!res || res.cancelled) {
361
+ return {
362
+ body: { action: 'form-cancel', cancelled: true },
363
+ cancelled: true,
364
+ action: 'form-cancel',
365
+ }
366
+ }
367
+ formData[field.name] = res.value
368
+ idx++
369
+ continue
370
+ }
371
+
372
+ // ---- Handle date / datetime fields ---------------------------------------
373
+ if (field.type === 'date' || field.type === 'datetime') {
374
+ const res = await this.requestDateTime({
375
+ message: promptMsg,
376
+ initial: field.value instanceof Date ? field.value : new Date(),
377
+ mask: field.mask,
378
+ })
379
+ if (!res || res.cancelled) {
380
+ return {
381
+ body: { action: 'form-cancel', cancelled: true },
382
+ cancelled: true,
383
+ action: 'form-cancel',
384
+ }
385
+ }
386
+ formData[field.name] = res.value
387
+ idx++
388
+ continue
389
+ }
390
+
391
+ // ---- Handle text / password / number fields ------------------------------
392
+
393
+ const res = await this.requestInput({
394
+ prompt: promptMsg,
395
+ initial: field.placeholder,
396
+ type: field.type === 'password' || field.type === 'secret' ? 'password' : 'text',
397
+ validation: (val) => {
398
+ if (!field.validation) return true
399
+ const result = field.validation(val)
400
+ if (result !== true) beep()
401
+ return result
402
+ },
403
+ })
404
+
405
+ if (!res || res.cancelled) {
174
406
  return {
175
- body: { action: "form-cancel", cancelled: true, form: {} },
176
- form: {},
407
+ body: { action: 'form-cancel', cancelled: true },
177
408
  cancelled: true,
178
- action: "form-cancel",
409
+ action: 'form-cancel',
179
410
  }
180
411
  }
181
412
 
182
- const trimmed = answerStr.trim()
183
- if (trimmed === "::prev" || trimmed === "::back") {
413
+ const trimmed = String(res.value || '').trim()
414
+
415
+ if (trimmed === '::prev' || trimmed === '::back') {
184
416
  idx = Math.max(0, idx - 1)
417
+ retries = 0
185
418
  continue
186
419
  }
187
- if (trimmed === "::next" || trimmed === "::skip") {
420
+ if (trimmed === '::next' || trimmed === '::skip') {
188
421
  idx++
422
+ retries = 0
189
423
  continue
190
424
  }
191
- if (trimmed === "" && !field.required) {
425
+ if (trimmed === '' && !field.required) {
192
426
  idx++
427
+ retries = 0
193
428
  continue
194
429
  }
195
- if (field.required && trimmed === "") {
196
- this.console.info("\nField is required.")
430
+ if (field.required && trimmed === '') {
431
+ this.console.info(`\n${this.#t('Field is required.')}`)
432
+ retries++
197
433
  continue
198
434
  }
435
+
199
436
  const schema = field.constructor
200
437
  const { isValid, errors } = form.validateValue(field.name, trimmed, schema)
201
438
  if (!isValid) {
202
- this.console.warn("\n" + Object.values(errors).join("\n"))
439
+ this.console.warn('\n' + Object.values(errors).join('\n'))
440
+ retries++
203
441
  continue
204
442
  }
205
443
  formData[field.name] = trimmed
206
444
  idx++
445
+ retries = 0
207
446
  }
208
447
 
209
448
  const finalForm = form.setData(formData)
210
449
  const errors = finalForm.validate()
211
450
  if (errors.size) {
212
- this.console.warn("\n" + Object.values(errors).join("\n"))
451
+ this.console.warn('\n' + Object.values(errors).join('\n'))
213
452
  return await this.requestForm(form, options)
214
453
  }
215
454
  return {
216
- body: { action: "form-submit", cancelled: false, form: finalForm },
455
+ body: { action: 'form-submit', cancelled: false, form: finalForm },
217
456
  form: finalForm,
218
457
  cancelled: false,
219
- action: "form-submit",
458
+ action: 'form-submit',
220
459
  }
221
460
  }
222
461
 
@@ -233,8 +472,11 @@ export default class CLiInputAdapter extends BaseInputAdapter {
233
472
  const compLoader = this.#components.get(component)
234
473
  if (compLoader) {
235
474
  try {
236
- const compFn = await compLoader()
237
- if (typeof compFn === "function") {
475
+ let compFn = await compLoader()
476
+ if (compFn && typeof compFn === 'object' && compFn.default) {
477
+ compFn = compFn.default
478
+ }
479
+ if (typeof compFn === 'function') {
238
480
  compFn.call(this, props)
239
481
  return
240
482
  }
@@ -243,9 +485,9 @@ export default class CLiInputAdapter extends BaseInputAdapter {
243
485
  this.console.debug?.(err.stack)
244
486
  }
245
487
  }
246
- if (props && typeof props === "object") {
488
+ if (props && typeof props === 'object') {
247
489
  const { variant, content } = props
248
- const prefix = variant ? `[${variant}]` : ""
490
+ const prefix = variant ? `[${variant}]` : ''
249
491
  this.console.info(`${prefix} ${String(content)}`)
250
492
  } else {
251
493
  this.console.info(String(component))
@@ -267,21 +509,52 @@ export default class CLiInputAdapter extends BaseInputAdapter {
267
509
  * Prompt the user to select an option from a list.
268
510
  *
269
511
  * @param {Object} config - Configuration object.
270
- * @returns {Promise<string>} Selected value (or empty string on cancel).
512
+ * @returns {Promise<{value: string|undefined, cancelled: boolean}>} Selected value (or undefined on cancel).
271
513
  */
272
514
  async requestSelect(config) {
515
+ config.limit = config.limit ?? Math.max(5, (this.stdout.rows || 24) - 4)
273
516
  try {
274
517
  const predefined = this.#nextAnswer()
275
- if (!config.console) config.console = { info: () => { } }
518
+ if (!config.console) config.console = this.#console
276
519
 
277
520
  if (predefined !== null) {
278
- config.yes = true
279
- config.ask = createPredefinedInput([predefined], config.console)
521
+ // Normalize options to find value
522
+ let choices = []
523
+ const options = config.options || []
524
+ if (options instanceof Map) {
525
+ choices = Array.from(options.entries()).map(([value, label]) => ({ label, value }))
526
+ } else if (Array.isArray(options)) {
527
+ choices = options.map((el) => {
528
+ if (typeof el === 'string') return { label: el, value: el }
529
+ return { label: el.label || el.title, value: el.value }
530
+ })
531
+ }
532
+
533
+ const idx = Number(predefined) - 1
534
+ let valToInject = predefined
535
+ if (!isNaN(idx) && idx >= 0 && idx < choices.length) {
536
+ valToInject = choices[idx].value
537
+ }
538
+
539
+ if (config.console) {
540
+ if (config.title) config.console.info(config.title)
541
+ const limit = config.limit || choices.length
542
+ choices.slice(0, limit).forEach((c, i) => {
543
+ config.console.info(` ${i + 1}) ${this.#t(c.label)}`)
544
+ })
545
+ if (choices.length > limit) {
546
+ config.console.info(` ↓ (${choices.length - limit} ${this.#t('more')})`)
547
+ }
548
+ const p = config.prompt ?? ''
549
+ config.console.info(`✔ ${p} ${predefined}`)
550
+ }
551
+ return { value: valToInject, cancelled: false }
280
552
  }
281
- const result = await this.select(config)
282
- return result.value
553
+ config.t = this.t
554
+ const res = await this.select(config)
555
+ return { value: res.value, cancelled: res.cancelled ?? false }
283
556
  } catch (e) {
284
- if (e instanceof CancelError) return ""
557
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
285
558
  throw e
286
559
  }
287
560
  }
@@ -290,18 +563,285 @@ export default class CLiInputAdapter extends BaseInputAdapter {
290
563
  * Prompt for a single string input.
291
564
  *
292
565
  * @param {Object} config - Prompt configuration.
293
- * @returns {Promise<string>} User response string.
566
+ * @returns {Promise<{value: string|undefined, cancelled: boolean}>} User response string or undefined on cancel.
294
567
  */
295
568
  async requestInput(config) {
296
569
  const predefined = this.#nextAnswer()
297
- if (predefined !== null) return predefined
570
+ const promptOrig =
571
+ config.prompt ?? config.message ?? `${config.label ?? config.name ?? 'Input'}: `
572
+ const prompt = this.#t(promptOrig)
573
+ if (predefined !== null) {
574
+ if (config.type !== 'password' && config.type !== 'secret') {
575
+ this.console.info(`✔ ${prompt} ${predefined}`)
576
+ } else {
577
+ this.console.info(`✔ ${prompt}${'*'.repeat(predefined.length)}`)
578
+ }
579
+ return { value: predefined, cancelled: false }
580
+ } else if (config.yes === true && config.value !== undefined) {
581
+ return { value: config.value, cancelled: false }
582
+ }
583
+
584
+ try {
585
+ const res = await baseText({
586
+ message: prompt,
587
+ initial: config.initial ?? config.placeholder,
588
+ type: config.type === 'password' || config.type === 'secret' ? 'password' : 'text',
589
+ validate: config.validate || config.validation,
590
+ })
591
+ return res
592
+ } catch (e) {
593
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
594
+ throw e
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Prompt the user for an autocomplete selection.
600
+ *
601
+ * @param {Object} config - Configuration object.
602
+ * @returns {Promise<{value: any, cancelled: boolean}>} Selected value.
603
+ */
604
+ async requestAutocomplete(config) {
605
+ config.limit = config.limit ?? Math.max(5, (this.stdout.rows || 24) - 4)
606
+ const predefined = this.#nextAnswer()
607
+ const prompt = config.message || config.title || 'Search: '
608
+ if (predefined !== null) {
609
+ this.console.info(`${prompt} ${predefined}`)
610
+ return { value: predefined, cancelled: false }
611
+ }
612
+ try {
613
+ return await baseAutocomplete(config)
614
+ } catch (e) {
615
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
616
+ throw e
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Requests confirmation (yes/no).
622
+ *
623
+ * @param {Object} config - Confirmation configuration.
624
+ * @returns {Promise<{value: boolean|undefined, cancelled: boolean}>} User confirmation.
625
+ */
626
+ async requestConfirm(config) {
627
+ const predefined = this.#nextAnswer()
628
+ const prompt = config.message || 'Confirm: '
629
+ if (predefined !== null) {
630
+ const val = ['y', 'yes', 'true', '1', 'так', '+'].includes(predefined.toLowerCase())
631
+ const display = val ? config.active || this.#t('yes') : config.inactive || this.#t('no')
632
+ this.console.info(`✔ ${prompt} ${display}`)
633
+ return { value: val, cancelled: false }
634
+ }
635
+ try {
636
+ const active = config.active || (this.#t ? this.#t('yes') : 'Yes')
637
+ const inactive = config.inactive || (this.#t ? this.#t('no') : 'No')
638
+ if (typeof config === 'object' && config !== null) config.t = this.t
639
+ return await baseConfirm({ ...config, message: prompt, active, inactive, t: this.t })
640
+ } catch (e) {
641
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
642
+ throw e
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Display an interactive table.
648
+ *
649
+ * @param {Object} config - Table configuration.
650
+ * @returns {Promise<{value: any, cancelled: boolean}>}
651
+ */
652
+ async requestTable(config) {
653
+ config.t = this.t
654
+ config.prompt = (c) => this.requestInput(c)
655
+ config.logger = config.logger || this.console
656
+ try {
657
+ return await baseTable(config)
658
+ } catch (e) {
659
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
660
+ throw e
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Requests multiple selection.
666
+ *
667
+ * @param {Object} config - Multiselect configuration.
668
+ * @returns {Promise<{value: any[]|undefined, cancelled: boolean}>} Selected values.
669
+ */
670
+ async requestMultiselect(config) {
671
+ const predefined = this.#nextAnswer()
672
+ const prompt = config.message || 'Select: '
673
+ if (predefined !== null) {
674
+ this.console.info(`${prompt} ${predefined}`)
675
+ return { value: predefined.split(',').map((v) => v.trim()), cancelled: false }
676
+ }
677
+ const instructions =
678
+ `\n${this.#t('Instructions')}:\n` +
679
+ ` ↑/↓: ${this.#t('Highlight option')}\n` +
680
+ ` ←/→/[space]: ${this.#t('Toggle selection')}\n` +
681
+ ` a: ${this.#t('Toggle all')}\n` +
682
+ ` enter/return: ${this.#t('Complete answer')}`
683
+
684
+ // Pass instructions to multiselect config
685
+ config.instructions = instructions
686
+ config.t = this.t
687
+
688
+ try {
689
+ return await baseMultiselect(config)
690
+ } catch (e) {
691
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
692
+ throw e
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Requests masked input.
698
+ *
699
+ * @param {Object} config - Mask configuration.
700
+ * @returns {Promise<{value: string|undefined, cancelled: boolean}>} Masked value.
701
+ */
702
+ async requestMask(config) {
703
+ const predefined = this.#nextAnswer()
704
+ const prompt = config.message || 'Input: '
705
+ if (predefined !== null) {
706
+ const display = this.#applyMask(predefined, config.mask)
707
+ this.console.info(`✔ ${prompt} ${display}`)
708
+ return { value: predefined, cancelled: false }
709
+ }
710
+ try {
711
+ const maskImpl = this.#components.get('mask') || baseMask
712
+ const res = await maskImpl({ ...config, message: prompt })
713
+ if (res && !res.cancelled) {
714
+ try {
715
+ readline.moveCursor(this.#stdout, 0, -1)
716
+ readline.clearLine(this.#stdout, 0)
717
+ this.console.info(`✔ ${prompt} ${res.value}`)
718
+ } catch (e) {
719
+ this.console.error('Cursor op failed:', e)
720
+ // Ignore cursor errors in non-TTY environments, but ensure output is logged
721
+ // If cursor moved failed, we might have duplicate log.
722
+ // But we prioritize showing correct value.
723
+ // If readline failed, we assume console.info needs to run?
724
+ // If readline failed, console.info might have been cleared? No.
725
+ // We only print if we tried to clear.
726
+ }
727
+ }
728
+ return res
729
+ } catch (e) {
730
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
731
+ throw e
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Request a toggle switch.
737
+ * @param {Object} config
738
+ * @returns {Promise<{value: boolean|undefined, cancelled: boolean}>}
739
+ */
740
+ async requestToggle(config) {
741
+ const predefined = this.#nextAnswer()
742
+ const prompt = config.message || 'Confirm: '
743
+ if (predefined !== null) {
744
+ this.console.info(`✔ ${prompt} ${predefined}`)
745
+ return {
746
+ value: ['y', 'yes', 'true', '1', 'так'].includes(predefined.toLowerCase()),
747
+ cancelled: false,
748
+ }
749
+ }
750
+ try {
751
+ return await baseToggle(config)
752
+ } catch (e) {
753
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
754
+ throw e
755
+ }
756
+ }
298
757
 
299
- if (config.yes === true && config.value !== undefined) {
300
- return config.value
758
+ /**
759
+ * Request a numeric slider.
760
+ * @param {Object} config
761
+ * @returns {Promise<{value: number|undefined, cancelled: boolean}>}
762
+ */
763
+ async requestSlider(config) {
764
+ const predefined = this.#nextAnswer()
765
+ const prompt = config.message || 'Value: '
766
+ if (predefined !== null) {
767
+ this.console.info(`${prompt} ${predefined}`)
768
+ return { value: Number(predefined), cancelled: false }
769
+ }
770
+ try {
771
+ config.t = this.t
772
+ return await baseSlider(config)
773
+ } catch (e) {
774
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
775
+ throw e
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Create a progress bar.
781
+ * @param {Object} options
782
+ * @returns {import('./ui/progress.js').ProgressBar}
783
+ */
784
+ requestProgress(options) {
785
+ return baseProgress(options)
786
+ }
787
+
788
+ /**
789
+ * Create and start a spinner.
790
+ * @param {string} message
791
+ * @returns {import('./ui/spinner.js').Spinner}
792
+ */
793
+ requestSpinner(message) {
794
+ return baseSpinner(message)
795
+ }
796
+
797
+ /**
798
+ * Request a selection from a tree view.
799
+ * @param {Object} config
800
+ * @returns {Promise<{value: any, cancelled: boolean}>} Selected node(s).
801
+ */
802
+ async requestTree(config) {
803
+ const predefined = this.#nextAnswer()
804
+ if (predefined !== null) {
805
+ const prompt = config.message || 'Select: '
806
+ this.console.info(`${prompt} ${predefined}`)
807
+ return { value: predefined, cancelled: false }
808
+ }
809
+ try {
810
+ config.t = this.t
811
+ return await baseTree(config)
812
+ } catch (e) {
813
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
814
+ throw e
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Request a date or time from the user.
820
+ * @param {Object} config
821
+ * @returns {Promise<{value: Date|undefined, cancelled: boolean}>}
822
+ */
823
+ async requestDateTime(config) {
824
+ const predefined = this.#nextAnswer()
825
+ const promptOrig = config.message || config.label || config.name || 'Date: '
826
+ const prompt = this.#t(promptOrig)
827
+ if (predefined !== null) {
828
+ this.console.info(`${prompt} ${predefined}`)
829
+ let val = new Date(predefined)
830
+ if (isNaN(val.getTime())) {
831
+ if (/^\d{1,2}:\d{2}/.test(predefined)) {
832
+ const today = new Date().toISOString().split('T')[0]
833
+ val = new Date(`${today}T${predefined}`)
834
+ }
835
+ }
836
+ return { value: isNaN(val.getTime()) ? undefined : val, cancelled: false }
837
+ }
838
+ try {
839
+ const res = await baseDateTime({ t: this.t, ...config, message: prompt })
840
+ return res
841
+ } catch (e) {
842
+ if (e instanceof CancelError) return { value: undefined, cancelled: true }
843
+ throw e
301
844
  }
302
- const prompt = config.prompt ?? `${config.label ?? config.name}: `
303
- const answer = await this.ask(prompt)
304
- return this.#normalise(answer)
305
845
  }
306
846
 
307
847
  /**
@@ -317,10 +857,10 @@ export default class CLiInputAdapter extends BaseInputAdapter {
317
857
  const predefined = this.#nextAnswer()
318
858
  if (predefined !== null) {
319
859
  this.stdout.write(`${question}${predefined}\n`)
320
- return predefined
860
+ prompts.inject([predefined])
321
861
  }
322
- const input = await baseAsk(question)
323
- return input.value
862
+ const result = await baseAsk(question)
863
+ return result
324
864
  }
325
865
 
326
866
  /** @inheritDoc */
@@ -342,22 +882,22 @@ export default class CLiInputAdapter extends BaseInputAdapter {
342
882
  */
343
883
  async requireInput(msg) {
344
884
  if (!msg) {
345
- throw new Error("Message instance is required")
885
+ throw new Error('Message instance is required')
346
886
  }
347
887
  if (!(msg instanceof UiMessage)) {
348
- throw new TypeError("Message must be an instance UiMessage")
888
+ throw new TypeError('Message must be an instance UiMessage')
349
889
  }
350
890
  /** @type {Map<string,string>} */
351
891
  let errors = msg.validate()
352
892
  while (errors.size > 0) {
353
- const form = generateForm(
354
- /** @type {any} */(msg.constructor).Body,
355
- { initialState: msg.body }
356
- )
893
+ const form = generateForm(/** @type {any} */ (msg.constructor).Body, {
894
+ initialState: msg.body,
895
+ t: this.#t,
896
+ })
357
897
 
358
898
  const formResult = await this.processForm(form, msg.body)
359
899
  if (formResult.cancelled) {
360
- throw new CancelError("User cancelled form")
900
+ throw new CancelError('User cancelled form')
361
901
  }
362
902
 
363
903
  const updatedBody = { ...msg.body, ...formResult.form.state }