@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.
- package/README.md +114 -207
- package/package.json +22 -12
- package/src/CLI.js +22 -29
- package/src/CLiMessage.js +2 -3
- package/src/Command.js +26 -24
- package/src/CommandError.js +3 -5
- package/src/CommandHelp.js +40 -36
- package/src/CommandMessage.js +56 -40
- package/src/CommandParser.js +27 -25
- package/src/InputAdapter.js +630 -90
- package/src/OutputAdapter.js +7 -8
- package/src/README.md.js +190 -316
- package/src/components/Alert.js +3 -6
- package/src/components/prompt/Autocomplete.js +12 -0
- package/src/components/prompt/Confirm.js +29 -0
- package/src/components/prompt/DateTime.js +26 -0
- package/src/components/prompt/Input.js +15 -0
- package/src/components/prompt/Mask.js +12 -0
- package/src/components/prompt/Multiselect.js +26 -0
- package/src/components/prompt/Next.js +8 -0
- package/src/components/prompt/Password.js +13 -0
- package/src/components/prompt/Pause.js +9 -0
- package/src/components/prompt/ProgressBar.js +16 -0
- package/src/components/prompt/Select.js +29 -0
- package/src/components/prompt/Slider.js +16 -0
- package/src/components/prompt/Spinner.js +29 -0
- package/src/components/prompt/Toggle.js +13 -0
- package/src/components/prompt/Tree.js +17 -0
- package/src/components/view/Alert.js +78 -0
- package/src/components/view/Badge.js +11 -0
- package/src/components/view/Nav.js +23 -0
- package/src/components/view/Table.js +12 -0
- package/src/components/view/Toast.js +9 -0
- package/src/core/Component.js +79 -0
- package/src/core/PropValidation.js +138 -0
- package/src/core/render.js +37 -0
- package/src/index.js +80 -41
- package/src/test/PlaygroundTest.js +37 -25
- package/src/test/index.js +2 -4
- package/src/ui/alert.js +58 -0
- package/src/ui/autocomplete.js +86 -0
- package/src/ui/badge.js +35 -0
- package/src/ui/confirm.js +49 -0
- package/src/ui/date-time.js +45 -0
- package/src/ui/form.js +120 -55
- package/src/ui/index.js +18 -4
- package/src/ui/input.js +79 -152
- package/src/ui/mask.js +132 -0
- package/src/ui/multiselect.js +59 -0
- package/src/ui/nav.js +74 -0
- package/src/ui/next.js +18 -13
- package/src/ui/progress.js +88 -0
- package/src/ui/select.js +49 -72
- package/src/ui/slider.js +154 -0
- package/src/ui/spinner.js +65 -0
- package/src/ui/table.js +163 -0
- package/src/ui/toast.js +34 -0
- package/src/ui/toggle.js +34 -0
- package/src/ui/tree.js +393 -0
- package/src/utils/parse.js +1 -1
- package/types/CLI.d.ts +5 -5
- package/types/CLiMessage.d.ts +1 -1
- package/types/Command.d.ts +2 -2
- package/types/CommandHelp.d.ts +3 -3
- package/types/CommandMessage.d.ts +8 -8
- package/types/CommandParser.d.ts +3 -3
- package/types/InputAdapter.d.ts +149 -15
- package/types/OutputAdapter.d.ts +1 -1
- package/types/README.md.d.ts +1 -1
- package/types/UiMessage.d.ts +31 -29
- package/types/components/prompt/Autocomplete.d.ts +6 -0
- package/types/components/prompt/Confirm.d.ts +6 -0
- package/types/components/prompt/DateTime.d.ts +6 -0
- package/types/components/prompt/Input.d.ts +6 -0
- package/types/components/prompt/Mask.d.ts +6 -0
- package/types/components/prompt/Multiselect.d.ts +6 -0
- package/types/components/prompt/Next.d.ts +6 -0
- package/types/components/prompt/Password.d.ts +6 -0
- package/types/components/prompt/Pause.d.ts +6 -0
- package/types/components/prompt/ProgressBar.d.ts +12 -0
- package/types/components/prompt/Select.d.ts +18 -0
- package/types/components/prompt/Slider.d.ts +6 -0
- package/types/components/prompt/Spinner.d.ts +21 -0
- package/types/components/prompt/Toggle.d.ts +6 -0
- package/types/components/prompt/Tree.d.ts +6 -0
- package/types/components/view/Alert.d.ts +21 -0
- package/types/components/view/Badge.d.ts +5 -0
- package/types/components/view/Nav.d.ts +15 -0
- package/types/components/view/Table.d.ts +10 -0
- package/types/components/view/Toast.d.ts +5 -0
- package/types/core/Component.d.ts +34 -0
- package/types/core/PropValidation.d.ts +48 -0
- package/types/core/render.d.ts +6 -0
- package/types/index.d.ts +47 -15
- package/types/test/PlaygroundTest.d.ts +12 -8
- package/types/test/index.d.ts +1 -1
- package/types/ui/alert.d.ts +14 -0
- package/types/ui/autocomplete.d.ts +20 -0
- package/types/ui/badge.d.ts +8 -0
- package/types/ui/confirm.d.ts +21 -0
- package/types/ui/date-time.d.ts +19 -0
- package/types/ui/form.d.ts +43 -12
- package/types/ui/index.d.ts +17 -2
- package/types/ui/input.d.ts +31 -74
- package/types/ui/mask.d.ts +29 -0
- package/types/ui/multiselect.d.ts +25 -0
- package/types/ui/nav.d.ts +27 -0
- package/types/ui/progress.d.ts +43 -0
- package/types/ui/select.d.ts +25 -64
- package/types/ui/slider.d.ts +23 -0
- package/types/ui/spinner.d.ts +28 -0
- package/types/ui/table.d.ts +28 -0
- package/types/ui/toast.d.ts +8 -0
- package/types/ui/toggle.d.ts +17 -0
- package/types/ui/tree.d.ts +48 -0
package/src/ui/mask.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mask module – provides formatted input handling.
|
|
3
|
+
*
|
|
4
|
+
* @module ui/mask
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import prompts from 'prompts'
|
|
8
|
+
import { CancelError } from '@nan0web/ui/core'
|
|
9
|
+
import { beep } from './input.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validates and optionally formats user input based on a pattern.
|
|
13
|
+
* Pattern uses '#' for numbers and 'A' for letters.
|
|
14
|
+
* Example: '(###) ###-####'
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} config
|
|
17
|
+
* @param {string} config.message - Prompt question
|
|
18
|
+
* @param {string} config.mask - Mask pattern (e.g., '###-###')
|
|
19
|
+
* @param {string} [config.placeholder] - Hint for the user
|
|
20
|
+
* @param {Function} [config.t] - Translation function
|
|
21
|
+
* @returns {Promise<{value: string, cancelled: boolean}>}
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Cleans the input value by stripping non-alphanumerics and smart prefix.
|
|
25
|
+
*/
|
|
26
|
+
export function cleanMaskInput(value, mask) {
|
|
27
|
+
let cleanValue = value.toString().replace(/[^a-zA-Z0-9]/g, '')
|
|
28
|
+
|
|
29
|
+
// Smart Prefix Detection:
|
|
30
|
+
// If the user typed the mask's static prefix (e.g. '38' for '+38'),
|
|
31
|
+
// remove it from the input to prevent duplication.
|
|
32
|
+
const firstPlaceholderIndex = mask.search(/[#0A]/)
|
|
33
|
+
if (firstPlaceholderIndex > 0) {
|
|
34
|
+
const maskPrefix = mask.substring(0, firstPlaceholderIndex).replace(/[^a-zA-Z0-9]/g, '')
|
|
35
|
+
if (maskPrefix && cleanValue.startsWith(maskPrefix)) {
|
|
36
|
+
// Special Check: If the user typed ONLY the prefix so far, don't strip it yet?
|
|
37
|
+
// Actually, removing it is safer to avoid "Format must be: +38 (38)" error midway?
|
|
38
|
+
// But for full validation, we strip it.
|
|
39
|
+
cleanValue = cleanValue.substring(maskPrefix.length)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return cleanValue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Formats a value according to the given mask.
|
|
47
|
+
* pattern: # = digit, A = letter, 0 = digit.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} value
|
|
50
|
+
* @param {string} mask
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function formatMask(value, mask) {
|
|
54
|
+
let i = 0
|
|
55
|
+
let v = 0
|
|
56
|
+
let result = ''
|
|
57
|
+
|
|
58
|
+
const cleanValue = cleanMaskInput(value, mask)
|
|
59
|
+
|
|
60
|
+
while (i < mask.length && v < cleanValue.length) {
|
|
61
|
+
const maskChar = mask[i]
|
|
62
|
+
if (maskChar === '#' || maskChar === '0' || maskChar === 'A') {
|
|
63
|
+
result += cleanValue[v]
|
|
64
|
+
v++
|
|
65
|
+
} else {
|
|
66
|
+
result += maskChar
|
|
67
|
+
}
|
|
68
|
+
i++
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function mask(config) {
|
|
74
|
+
const { message, mask, placeholder, t } = config
|
|
75
|
+
|
|
76
|
+
const validate = (val) => {
|
|
77
|
+
if (!val) {
|
|
78
|
+
beep()
|
|
79
|
+
return 'Input is required'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Support '#', '0' for numbers and 'A' for letters.
|
|
83
|
+
const cleanMask = mask.replace(/[^#0A]/g, '')
|
|
84
|
+
const cleanVal = cleanMaskInput(val, mask)
|
|
85
|
+
|
|
86
|
+
if (cleanVal.length !== cleanMask.length) {
|
|
87
|
+
beep()
|
|
88
|
+
return `${config.t ? config.t('Format must be:') : 'Format must be:'} ${mask}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
// Pre-format the placeholder if it exists so the default value is visual
|
|
98
|
+
const initialValue = placeholder ? formatMask(placeholder, mask) : undefined
|
|
99
|
+
|
|
100
|
+
const response = await prompts(
|
|
101
|
+
{
|
|
102
|
+
type: 'text',
|
|
103
|
+
name: 'value',
|
|
104
|
+
message,
|
|
105
|
+
initial: initialValue,
|
|
106
|
+
validate,
|
|
107
|
+
format: (val) => formatMask(val, mask),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
onCancel: () => {
|
|
111
|
+
throw new CancelError()
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// Ensure the returned value is always formatted
|
|
117
|
+
const formatted = formatMask(response.value, mask)
|
|
118
|
+
|
|
119
|
+
// Manual Stdout Override:
|
|
120
|
+
// prompts library might verify correctly but display raw input on the final line in some environments.
|
|
121
|
+
// We force a clean UI by removing the last line and printing our own.
|
|
122
|
+
if (process.stdout.isTTY) {
|
|
123
|
+
process.stdout.moveCursor(0, -1) // Move up one line
|
|
124
|
+
process.stdout.clearLine(0) // Clear the line
|
|
125
|
+
// Re-print the message and value manually with proper formatting
|
|
126
|
+
// Note: We use '✔' to match prompts style, or we can use our own if desired.
|
|
127
|
+
// prompts default style: '✔ Message … Value'
|
|
128
|
+
console.log(`✔ ${message} … ${formatted}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { value: formatted, cancelled: false }
|
|
132
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multiselect module – provides interactive multiple-choice selection list.
|
|
3
|
+
*
|
|
4
|
+
* @module ui/multiselect
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import prompts from 'prompts'
|
|
8
|
+
import { CancelError } from '@nan0web/ui/core'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Interactive multiple selection with checkboxes.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} config
|
|
14
|
+
* @param {string} config.message - Prompt question
|
|
15
|
+
* @param {Array<string|Object>} config.options - List of choices
|
|
16
|
+
* @param {number} [config.limit=10] - Visible items limit
|
|
17
|
+
* @param {Array<any>} [config.initial=[]] - Initial selected values
|
|
18
|
+
* @param {string|boolean} [config.instructions] - Custom instructions
|
|
19
|
+
* @param {string} [config.hint] - Navigation hint
|
|
20
|
+
* @param {Function} [config.t] - Translation function
|
|
21
|
+
* @returns {Promise<{value: Array<any>, cancelled: boolean}>}
|
|
22
|
+
*/
|
|
23
|
+
export async function multiselect(config) {
|
|
24
|
+
const { message, options, limit = 10, initial = [], t } = config
|
|
25
|
+
|
|
26
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
27
|
+
throw new Error('Options array is required and must not be empty')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const choices = options.map((el) => {
|
|
31
|
+
if (typeof el === 'string') {
|
|
32
|
+
return { title: el, value: el, selected: initial.includes(el) }
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
title: el.label || el.title,
|
|
36
|
+
value: el.value,
|
|
37
|
+
selected: initial.includes(el.value),
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const response = await prompts(
|
|
42
|
+
{
|
|
43
|
+
type: 'multiselect',
|
|
44
|
+
name: 'value',
|
|
45
|
+
message,
|
|
46
|
+
choices,
|
|
47
|
+
limit,
|
|
48
|
+
instructions: config.instructions !== undefined ? config.instructions : false,
|
|
49
|
+
hint: config.hint || (t ? t('hint.multiselect') : undefined),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
onCancel: () => {
|
|
53
|
+
throw new CancelError()
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return { value: response.value || [], cancelled: false }
|
|
59
|
+
}
|
package/src/ui/nav.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation components – Breadcrumbs, Tabs, Steps.
|
|
3
|
+
*
|
|
4
|
+
* @module ui/nav
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import Logger from '@nan0web/log'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders a breadcrumb trail.
|
|
11
|
+
*
|
|
12
|
+
* @param {string[]} items - List of path segments.
|
|
13
|
+
* @param {Object} [options]
|
|
14
|
+
* @param {string} [options.separator='›'] - Separator character.
|
|
15
|
+
* @returns {string} Styled string.
|
|
16
|
+
*/
|
|
17
|
+
export function breadcrumbs(items, options = {}) {
|
|
18
|
+
const { separator = '›' } = options
|
|
19
|
+
const sepStyle = Logger.style(` ${separator} `, { color: Logger.DIM })
|
|
20
|
+
|
|
21
|
+
return items
|
|
22
|
+
.map((item, index) => {
|
|
23
|
+
const isLast = index === items.length - 1
|
|
24
|
+
// Last item usually bold or white, previous dimmed
|
|
25
|
+
return Logger.style(item, { color: isLast ? Logger.WHITE : Logger.DIM })
|
|
26
|
+
})
|
|
27
|
+
.join(sepStyle)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Renders a tab bar (visual only).
|
|
32
|
+
*
|
|
33
|
+
* @param {string[]} items - List of tab labels.
|
|
34
|
+
* @param {number} [active=0] - Index of active tab.
|
|
35
|
+
* @returns {string} Styled string.
|
|
36
|
+
*/
|
|
37
|
+
export function tabs(items, active = 0) {
|
|
38
|
+
return items
|
|
39
|
+
.map((item, index) => {
|
|
40
|
+
const isActive = index === active
|
|
41
|
+
if (isActive) {
|
|
42
|
+
// Active: Inverse or highlighted
|
|
43
|
+
const bg = 'BLUE' // Logger looks up keys or constant values
|
|
44
|
+
return Logger.style(` ${item} `, { color: Logger.WHITE, bgColor: bg })
|
|
45
|
+
}
|
|
46
|
+
// Inactive: Dimmed or standard
|
|
47
|
+
return Logger.style(` ${item} `, { color: Logger.DIM })
|
|
48
|
+
})
|
|
49
|
+
.join(Logger.style('│', { color: Logger.DIM }))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders a step indicator (wizard).
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} items - List of step labels.
|
|
56
|
+
* @param {number} [current=0] - Index of current step.
|
|
57
|
+
* @returns {string} Styled string.
|
|
58
|
+
*/
|
|
59
|
+
export function steps(items, current = 0) {
|
|
60
|
+
return items
|
|
61
|
+
.map((item, index) => {
|
|
62
|
+
if (index < current) {
|
|
63
|
+
// Completed
|
|
64
|
+
return Logger.style(`✔ ${item}`, { color: Logger.GREEN })
|
|
65
|
+
} else if (index === current) {
|
|
66
|
+
// Current
|
|
67
|
+
return Logger.style(`● ${item}`, { color: Logger.CYAN })
|
|
68
|
+
} else {
|
|
69
|
+
// Future
|
|
70
|
+
return Logger.style(`○ ${item}`, { color: Logger.DIM })
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
.join(Logger.style(' ─ ', { color: Logger.DIM }))
|
|
74
|
+
}
|
package/src/ui/next.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @module ui/next
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import process from
|
|
7
|
+
import process from 'node:process'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Pause execution for a given amount of milliseconds.
|
|
@@ -13,7 +13,7 @@ import process from "node:process"
|
|
|
13
13
|
* @returns {Promise<true>} Resolves with `true` after the timeout.
|
|
14
14
|
*/
|
|
15
15
|
export async function pause(ms) {
|
|
16
|
-
return new Promise(resolve => setTimeout(() => resolve(true), ms))
|
|
16
|
+
return new Promise((resolve) => setTimeout(() => resolve(true), ms))
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -24,21 +24,26 @@ export async function pause(ms) {
|
|
|
24
24
|
* @throws {Error} If stdin is already in raw mode.
|
|
25
25
|
*/
|
|
26
26
|
export async function next(conf = undefined) {
|
|
27
|
+
// Automated environments support via PLAY_DEMO_SEQUENCE
|
|
28
|
+
if (process.env.PLAY_DEMO_SEQUENCE) {
|
|
29
|
+
return Promise.resolve('[SIMULATED KEY]')
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
return new Promise((resolve, reject) => {
|
|
28
33
|
if (process.stdin.isRaw) {
|
|
29
|
-
reject(new Error(
|
|
34
|
+
reject(new Error('stdin is already in raw mode'))
|
|
30
35
|
return
|
|
31
36
|
}
|
|
32
|
-
let buffer =
|
|
37
|
+
let buffer = ''
|
|
33
38
|
|
|
34
|
-
const onData = chunk => {
|
|
39
|
+
const onData = (chunk) => {
|
|
35
40
|
const str = chunk.toString()
|
|
36
41
|
buffer += str
|
|
37
42
|
|
|
38
43
|
if (conf === undefined) {
|
|
39
44
|
cleanup()
|
|
40
45
|
resolve(str)
|
|
41
|
-
} else if (typeof conf ===
|
|
46
|
+
} else if (typeof conf === 'string') {
|
|
42
47
|
if (buffer === conf || buffer.endsWith(conf)) {
|
|
43
48
|
cleanup()
|
|
44
49
|
resolve(buffer)
|
|
@@ -54,21 +59,21 @@ export async function next(conf = undefined) {
|
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
const errorHandler = err => {
|
|
62
|
+
const errorHandler = (err) => {
|
|
58
63
|
cleanup()
|
|
59
64
|
reject(err)
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
const cleanup = () => {
|
|
63
|
-
process.stdin.off(
|
|
64
|
-
process.stdin.off(
|
|
68
|
+
process.stdin.off('data', onData)
|
|
69
|
+
process.stdin.off('error', errorHandler)
|
|
65
70
|
process.stdin.setRawMode(false)
|
|
66
|
-
process.stdin.
|
|
71
|
+
process.stdin.pause()
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
process.stdin.setRawMode(true)
|
|
70
75
|
process.stdin.resume()
|
|
71
|
-
process.stdin.once(
|
|
72
|
-
process.stdin.on(
|
|
76
|
+
process.stdin.once('error', errorHandler)
|
|
77
|
+
process.stdin.on('data', onData)
|
|
73
78
|
})
|
|
74
|
-
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress module – visual progress bar with timers.
|
|
3
|
+
* @module ui/progress
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders a progress bar.
|
|
8
|
+
*/
|
|
9
|
+
export class ProgressBar {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {number} options.total
|
|
13
|
+
* @param {string} [options.title]
|
|
14
|
+
* @param {number} [options.width=40]
|
|
15
|
+
*/
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.total = options.total
|
|
18
|
+
this.title = options.title
|
|
19
|
+
this.width = options.width || 40
|
|
20
|
+
this.current = 0
|
|
21
|
+
this.startTime = Date.now()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Update progress.
|
|
26
|
+
* @param {number} current
|
|
27
|
+
*/
|
|
28
|
+
update(current) {
|
|
29
|
+
this.current = current
|
|
30
|
+
this.render()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Increment progress.
|
|
35
|
+
* @param {number} [step=1]
|
|
36
|
+
*/
|
|
37
|
+
tick(step = 1) {
|
|
38
|
+
this.current += step
|
|
39
|
+
this.render()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
render() {
|
|
43
|
+
const percent = Math.min(100, Math.max(0, (this.current / this.total) * 100))
|
|
44
|
+
const filledWidth = Math.round((this.width * percent) / 100)
|
|
45
|
+
const emptyWidth = this.width - filledWidth
|
|
46
|
+
|
|
47
|
+
const elapsed = (Date.now() - this.startTime) / 1000
|
|
48
|
+
const rate = this.current / elapsed
|
|
49
|
+
const remaining = rate > 0 ? (this.total - this.current) / rate : 0
|
|
50
|
+
|
|
51
|
+
const bar =
|
|
52
|
+
'='.repeat(filledWidth) +
|
|
53
|
+
(filledWidth < this.width ? '>' : '') +
|
|
54
|
+
'-'.repeat(Math.max(0, emptyWidth - 1))
|
|
55
|
+
|
|
56
|
+
const timeStr = `[${this.formatTime(elapsed)} < ${this.formatTime(remaining)}]`
|
|
57
|
+
|
|
58
|
+
const output = `\r${this.title ? this.title + ' ' : ''}[${bar}] ${percent.toFixed(0)}% ${timeStr}`
|
|
59
|
+
process.stdout.write(output)
|
|
60
|
+
|
|
61
|
+
if (this.current >= this.total) {
|
|
62
|
+
process.stdout.write('\n')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
formatTime(seconds) {
|
|
67
|
+
if (!isFinite(seconds) || seconds < 0) return '--:--'
|
|
68
|
+
const h = Math.floor(seconds / 3600)
|
|
69
|
+
const m = Math.floor((seconds % 3600) / 60)
|
|
70
|
+
const s = Math.floor(seconds % 60)
|
|
71
|
+
|
|
72
|
+
const parts = []
|
|
73
|
+
if (h > 0) parts.push(h.toString().padStart(2, '0'))
|
|
74
|
+
parts.push(m.toString().padStart(2, '0'))
|
|
75
|
+
parts.push(s.toString().padStart(2, '0'))
|
|
76
|
+
|
|
77
|
+
return parts.join(':')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Functional helper for progress.
|
|
83
|
+
* @param {Object} options
|
|
84
|
+
* @returns {ProgressBar}
|
|
85
|
+
*/
|
|
86
|
+
export function progress(options) {
|
|
87
|
+
return new ProgressBar(options)
|
|
88
|
+
}
|
package/src/ui/select.js
CHANGED
|
@@ -4,97 +4,74 @@
|
|
|
4
4
|
* @module ui/select
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
/** @typedef {import("./input.js").Input} Input */
|
|
11
|
-
/** @typedef {import("./input.js").InputFn} InputFn */
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @typedef {Object} ConsoleLike
|
|
15
|
-
* @property {(...args: any[]) => void} debug
|
|
16
|
-
* @property {(...args: any[]) => void} log
|
|
17
|
-
* @property {(...args: any[]) => void} info
|
|
18
|
-
* @property {(...args: any[]) => void} warn
|
|
19
|
-
* @property {(...args: any[]) => void} error
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @typedef {Object} SelectConfig
|
|
24
|
-
* @property {string} title – Title displayed above the options list.
|
|
25
|
-
* @property {string} prompt – Prompt displayed for the answer.
|
|
26
|
-
* @property {Array|Map} options – Collection of selectable items.
|
|
27
|
-
* @property {ConsoleLike} console – Console‑like object with an `info` method.
|
|
28
|
-
* @property {string[]} [stops=[]] Words that trigger cancellation.
|
|
29
|
-
* @property {InputFn} [ask] Custom ask function (defaults to {@link createInput}).
|
|
30
|
-
* @property {string} [invalidPrompt="Invalid choice, try again: "] Message shown on invalid input.
|
|
31
|
-
*/
|
|
7
|
+
import prompts from 'prompts'
|
|
8
|
+
import { CancelError } from '@nan0web/ui/core'
|
|
9
|
+
import { validateString, validateFunction, validateNumber } from '../core/PropValidation.js'
|
|
32
10
|
|
|
33
11
|
/**
|
|
34
12
|
* Configuration object for {@link select}.
|
|
35
13
|
*
|
|
36
|
-
* @param {
|
|
37
|
-
* @
|
|
14
|
+
* @param {Object} input
|
|
15
|
+
* @param {string} input.title - Title displayed above the options list.
|
|
16
|
+
* @param {string} [input.prompt] - Prompt displayed for the answer.
|
|
17
|
+
* @param {Array|Map} input.options - Collection of selectable items.
|
|
18
|
+
* @param {Object} [input.console] - Deprecated. Ignored in new implementation.
|
|
19
|
+
* @param {string[]} [input.stops=[]] - Deprecated. Ignored in new implementation.
|
|
20
|
+
* @param {any} [input.ask] - Deprecated. Ignored in new implementation.
|
|
21
|
+
* @param {string} [input.invalidPrompt] - Deprecated. Ignored in new implementation.
|
|
22
|
+
* @param {number} [input.limit=10] - Max visible items.
|
|
23
|
+
* @param {string} [input.hint] - Hint text.
|
|
24
|
+
* @param {Function} [input.t] - Translation function.
|
|
25
|
+
* @returns {Promise<{index:number,value:any,cancelled:boolean}>} Resolves with the selected index and its value.
|
|
38
26
|
*
|
|
39
27
|
* @throws {CancelError} When the user cancels the operation.
|
|
40
|
-
* @throws {Error} When options are missing or an incorrect value is supplied and no
|
|
41
|
-
* `invalidPrompt` is defined.
|
|
42
28
|
*/
|
|
43
29
|
export async function select(input) {
|
|
44
|
-
const {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} = input
|
|
30
|
+
const { title, prompt, options: initOptins, limit = 30 } = input
|
|
31
|
+
|
|
32
|
+
// Prop Validation
|
|
33
|
+
validateString(title || prompt, 'title', 'Select', true)
|
|
34
|
+
validateNumber(limit, 'limit', 'Select')
|
|
35
|
+
validateFunction(input.t, 't', 'Select')
|
|
36
|
+
validateString(input.hint, 'hint', 'Select')
|
|
37
|
+
|
|
53
38
|
let options = initOptins
|
|
54
|
-
/** @type {InputFn} */
|
|
55
|
-
const ask = initAsk ?? createInput(stops)
|
|
56
39
|
|
|
57
40
|
// Normalise Map → Array of {label,value}
|
|
58
41
|
if (options instanceof Map) {
|
|
59
|
-
options = Array.from(options.entries()).map(
|
|
60
|
-
([value, label]) => ({ label, value }))
|
|
42
|
+
options = Array.from(options.entries()).map(([value, label]) => ({ label, value }))
|
|
61
43
|
}
|
|
62
44
|
if (!Array.isArray(options) || options.length === 0) {
|
|
63
|
-
throw new Error(
|
|
45
|
+
throw new Error('Options array is required and must not be empty')
|
|
64
46
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
console.info(title)
|
|
70
|
-
list.forEach(({ label }, i) => console.info(` ${i + 1}) ${label}`))
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Validation function passed to `ask` as the *loop* argument.
|
|
74
|
-
* @type {import("./input.js").LoopFn}
|
|
75
|
-
*/
|
|
76
|
-
const validator = async (input) => {
|
|
77
|
-
if (input.cancelled) {
|
|
78
|
-
throw new CancelError()
|
|
47
|
+
// Prepare options for prompts
|
|
48
|
+
const choices = options.map((el) => {
|
|
49
|
+
if (typeof el === 'string') {
|
|
50
|
+
return { title: el, value: el }
|
|
79
51
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
52
|
+
return { title: el.label || el.title, value: el.value }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const response = await prompts(
|
|
56
|
+
{
|
|
57
|
+
type: 'select',
|
|
58
|
+
name: 'value',
|
|
59
|
+
message: title ? title : prompt,
|
|
60
|
+
choices: choices,
|
|
61
|
+
hint: input.hint || (input.t ? input.t('hint.select') : undefined),
|
|
62
|
+
instructions: false,
|
|
63
|
+
limit,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
onCancel: () => {
|
|
67
|
+
throw new CancelError()
|
|
68
|
+
},
|
|
86
69
|
}
|
|
87
|
-
|
|
88
|
-
// we reuse `idx` after ask resolves
|
|
89
|
-
return false // stop looping
|
|
90
|
-
}
|
|
70
|
+
)
|
|
91
71
|
|
|
92
|
-
|
|
93
|
-
const answer = await ask(prompt, validator, invalidPrompt)
|
|
72
|
+
const index = choices.findIndex((c) => c.value === response.value)
|
|
94
73
|
|
|
95
|
-
|
|
96
|
-
const finalIdx = Number(answer.value) - 1
|
|
97
|
-
return { index: finalIdx, value: list[finalIdx].value }
|
|
74
|
+
return { index, value: response.value, cancelled: response.value === undefined }
|
|
98
75
|
}
|
|
99
76
|
|
|
100
77
|
/**
|