@nan0web/ui-cli 1.1.1 → 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 -30
  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
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Slider module – numeric range selection with a visual bar and Shift-jumps.
3
+ * @module ui/slider
4
+ */
5
+ import NumberPrompt from 'prompts/lib/elements/number.js'
6
+ import prompts from 'prompts'
7
+ import { CancelError } from '@nan0web/ui/core'
8
+ import { validateString, validateNumber, validateFunction } from '../core/PropValidation.js'
9
+
10
+ /**
11
+ * Custom SliderPrompt that adds visual bar and Shift+Up/Down jumps.
12
+ */
13
+ class SliderPrompt extends NumberPrompt {
14
+ /** @param {any} opts */
15
+ constructor(opts) {
16
+ super(opts)
17
+ this.jump = opts.jump || 10
18
+ /** @type {boolean} */
19
+ this.shift = false
20
+ }
21
+
22
+ up() {
23
+ const self = /** @type {any} */ (this)
24
+ const max = self.max ?? 100
25
+ const step = self.increment ?? 1
26
+ self.value = Math.min(max, self.value + (this.shift ? this.jump : step))
27
+ this.render()
28
+ }
29
+
30
+ down() {
31
+ const self = /** @type {any} */ (this)
32
+ const min = self.min ?? 0
33
+ const step = self.increment ?? 1
34
+ self.value = Math.max(min, self.value - (this.shift ? this.jump : step))
35
+ this.render()
36
+ }
37
+
38
+ /**
39
+ * @param {string} key
40
+ * @param {any} keypress
41
+ */
42
+ _(key, keypress) {
43
+ this.shift = !!keypress?.shift
44
+ if (key === '+' || key === '=') {
45
+ this.up()
46
+ return
47
+ }
48
+ if (key === '-' || key === '_') {
49
+ this.down()
50
+ return
51
+ }
52
+ if (super._) super._(key, keypress)
53
+ }
54
+
55
+ render() {
56
+ const self = /** @type {any} */ (this)
57
+ if (self.closed || self.aborted) return
58
+
59
+ const min = self.min ?? 0
60
+ const max = self.max ?? 100
61
+ const width = 20
62
+ const range = max - min || 1
63
+ const percent = Math.max(0, Math.min(1, (self.value - min) / range))
64
+ const filled = Math.round(width * percent)
65
+ const bar = '━'.repeat(filled) + '─'.repeat(width - filled)
66
+ const label = self.msg || self.message || ''
67
+ const val = self.value
68
+
69
+ const out = self.out || process.stdout
70
+ // Clear prompt area and redraw
71
+ out.write('\r\x1B[K')
72
+ out.write(`${label} [${bar}] ${val}`)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {Object} config
78
+ * @param {string} config.message
79
+ * @param {number} [config.initial]
80
+ * @param {number} [config.min=0]
81
+ * @param {number} [config.max=100]
82
+ * @param {number} [config.step=1]
83
+ * @param {number} [config.jump]
84
+ * @param {Function} [config.t] - Optional translation function.
85
+ * @returns {Promise<{value:number, cancelled:boolean}>}
86
+ */
87
+ export async function slider(config) {
88
+ validateString(config.message, 'message', 'Slider', true)
89
+ validateNumber(config.initial, 'initial', 'Slider')
90
+ validateNumber(config.min, 'min', 'Slider')
91
+ validateNumber(config.max, 'max', 'Slider')
92
+ validateNumber(config.step, 'step', 'Slider')
93
+ validateFunction(config.t, 't', 'Slider')
94
+
95
+ const { message, initial, min = 0, max = 100, step = 1, t = (k) => k } = config
96
+ const range = max - min
97
+ const jump = config.jump || Math.max(step, Math.round(range / 10))
98
+
99
+ try {
100
+ const isTest = process.env.NODE_TEST_CONTEXT || process.env.PLAY_DEMO_SEQUENCE
101
+
102
+ if (isTest) {
103
+ // In tests, fallback to a simple text-based number input to avoid complex TTY interactions
104
+ const res = await prompts(
105
+ {
106
+ type: 'text',
107
+ name: 'value',
108
+ message: t(message),
109
+ initial: String(initial ?? min),
110
+ validate: (v) => {
111
+ const n = Number(v)
112
+ return (!isNaN(n) && n >= min && n <= max) || `Enter number ${min}-${max}`
113
+ },
114
+ },
115
+ {
116
+ onCancel: () => {
117
+ throw new CancelError()
118
+ },
119
+ }
120
+ )
121
+ return { value: Number(res.value), cancelled: res.value === undefined }
122
+ }
123
+
124
+ // For interactive TTY, use the built-in number prompt (works reliably)
125
+ const res = await prompts(
126
+ {
127
+ type: 'number',
128
+ name: 'value',
129
+ message: t(message),
130
+ initial: initial ?? min,
131
+ min,
132
+ max,
133
+ increment: step,
134
+ },
135
+ {
136
+ onCancel: () => {
137
+ throw new CancelError()
138
+ },
139
+ }
140
+ )
141
+ if (res.value === undefined) {
142
+ return { value: initial ?? min, cancelled: true }
143
+ }
144
+
145
+ return { value: res.value, cancelled: false }
146
+ } catch (err) {
147
+ const error = /** @type {any} */ (err)
148
+ if (error instanceof CancelError || error.message === 'canceled') {
149
+ // prompts throws 'canceled' sometimes
150
+ throw new CancelError()
151
+ }
152
+ throw error
153
+ }
154
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Spinner module – loading indicators.
3
+ * @module ui/spinner
4
+ */
5
+
6
+ /**
7
+ * Visual spinner for async operations.
8
+ */
9
+ export class Spinner {
10
+ static FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
11
+
12
+ /**
13
+ * @param {string} [message]
14
+ */
15
+ constructor(message) {
16
+ this.message = message || ''
17
+ this.frameIndex = 0
18
+ this.interval = null
19
+ this.startTime = Date.now()
20
+ }
21
+
22
+ start() {
23
+ this.startTime = Date.now()
24
+ this.interval = setInterval(() => {
25
+ const frame = Spinner.FRAMES[this.frameIndex]
26
+ this.frameIndex = (this.frameIndex + 1) % Spinner.FRAMES.length
27
+
28
+ const elapsed = Math.floor((Date.now() - this.startTime) / 1000)
29
+ const timeStr = `${Math.floor(elapsed / 60)
30
+ .toString()
31
+ .padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`
32
+
33
+ process.stdout.write(`\r${frame} ${this.message} [${timeStr}]`)
34
+ }, 80)
35
+ }
36
+
37
+ stop(status = '') {
38
+ if (this.interval) {
39
+ clearInterval(this.interval)
40
+ this.interval = null
41
+ process.stdout.write(`\r${status} ${this.message}\x1b[K\n`)
42
+ }
43
+ }
44
+
45
+ success(msg) {
46
+ if (msg) this.message = msg
47
+ this.stop('✔')
48
+ }
49
+
50
+ error(msg) {
51
+ if (msg) this.message = msg
52
+ this.stop('✖')
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Functional helper for spinner.
58
+ * @param {string} message
59
+ * @returns {Spinner}
60
+ */
61
+ export function spinner(message) {
62
+ const s = new Spinner(message)
63
+ s.start()
64
+ return s
65
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Table module – provides interactive tables with filtering and selection.
3
+ *
4
+ * @module ui/table
5
+ */
6
+
7
+ import Logger from '@nan0web/log'
8
+ import { CancelError } from '@nan0web/ui/core'
9
+ import { text } from './input.js'
10
+
11
+ /**
12
+ * Highlights matches in text.
13
+ * @param {string} text
14
+ * @param {string} query
15
+ * @returns {string}
16
+ */
17
+ function highlight(text, query) {
18
+ if (!query) return String(text)
19
+ const str = String(text)
20
+ const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
21
+ const regex = new RegExp(`(${escapedQuery})`, 'gi')
22
+ return str.replace(regex, `${Logger.MAGENTA}$1${Logger.RESET}`)
23
+ }
24
+
25
+ /**
26
+ * Renders an interactive table.
27
+ *
28
+ * @param {Object} input
29
+ * @param {Array<Object>} input.data - Data to display.
30
+ * @param {Array<string>} [input.columns] - Columns to include.
31
+ * @param {string} [input.title] - Table title.
32
+ * @param {boolean} [input.interactive=true] - Whether to allow filtering.
33
+ * @param {boolean} [input.instant=false] - Whether to use instant search (char-by-char).
34
+ * @param {(val:string)=>string} [input.t] - Translation function.
35
+ * @param {Logger} [input.logger] - Logger instance.
36
+ * @param {Function} [input.prompt] - Prompt function.
37
+ * @returns {Promise<{value:any, cancelled:boolean}>} Selected row (if interactive) or last state.
38
+ */
39
+ export async function table(input) {
40
+ const {
41
+ data: rawData,
42
+ columns = [],
43
+ title,
44
+ interactive = true,
45
+ instant = false,
46
+ t = (k) => k,
47
+ } = input
48
+
49
+ const logger = input.logger || new Logger()
50
+
51
+ if (!interactive) {
52
+ if (title) logger.info(title)
53
+ logger.table(rawData, columns)
54
+ return { value: rawData, cancelled: false }
55
+ }
56
+
57
+ let query = ''
58
+
59
+ if (instant && process.stdin.isTTY) {
60
+ return new Promise((resolve) => {
61
+ const render = () => {
62
+ const filteredData = rawData.filter((row) => {
63
+ if (!query) return true
64
+ return Object.values(row).some((val) =>
65
+ String(val).toLowerCase().includes(query.toLowerCase())
66
+ )
67
+ })
68
+ const displayData = filteredData.map((row) => {
69
+ const highlightedRow = {}
70
+ for (const key in row) {
71
+ highlightedRow[key] = highlight(row[key], query)
72
+ }
73
+ return highlightedRow
74
+ })
75
+ const displayColumns = columns.map((c) => highlight(c, query))
76
+
77
+ logger.clear()
78
+ const infoMsg = title
79
+ ? `${title} (${t('filter')}: "${query}")`
80
+ : `(${t('filter')}: "${query}")`
81
+ logger.info(infoMsg)
82
+ logger.table(displayData, displayColumns)
83
+ process.stdout.write('> ' + query)
84
+ }
85
+
86
+ render()
87
+
88
+ const onData = (chunk) => {
89
+ const char = chunk.toString()
90
+ if (char === '\r' || char === '\n') {
91
+ // Enter
92
+ cleanup()
93
+ resolve({ value: query, cancelled: false })
94
+ } else if (char === '\u0003' || char === '\u001b') {
95
+ // Ctrl+C or Esc
96
+ cleanup()
97
+ resolve({ value: query, cancelled: true })
98
+ } else if (char === '\u007f') {
99
+ // Backspace
100
+ query = query.slice(0, -1)
101
+ render()
102
+ } else {
103
+ // Only add printable characters
104
+ if (char.length === 1 && char.charCodeAt(0) >= 32) {
105
+ query += char
106
+ render()
107
+ }
108
+ }
109
+ }
110
+
111
+ const cleanup = () => {
112
+ process.stdin.off('data', onData)
113
+ process.stdin.setRawMode(false)
114
+ process.stdin.pause()
115
+ process.stdout.write('\n')
116
+ }
117
+
118
+ process.stdin.setRawMode(true)
119
+ process.stdin.resume()
120
+ process.stdin.on('data', onData)
121
+ })
122
+ }
123
+
124
+ // Default loop (Enter based)
125
+ // eslint-disable-next-line no-constant-condition
126
+ while (true) {
127
+ const filteredData = rawData.filter((row) => {
128
+ if (!query) return true
129
+ return Object.values(row).some((val) =>
130
+ String(val).toLowerCase().includes(query.toLowerCase())
131
+ )
132
+ })
133
+
134
+ const displayData = filteredData.map((row) => {
135
+ const highlightedRow = {}
136
+ for (const key in row) {
137
+ highlightedRow[key] = highlight(row[key], query)
138
+ }
139
+ return highlightedRow
140
+ })
141
+ const displayColumns = columns.map((c) => highlight(c, query))
142
+
143
+ logger.clear()
144
+ if (title) logger.info(`${title} (${t('filter')}: "${query || t('none')}")`)
145
+ logger.table(displayData, displayColumns)
146
+
147
+ const promptFn = input.prompt || text
148
+ const res = await promptFn({
149
+ message: t('table.filter_prompt'),
150
+ initial: query,
151
+ })
152
+
153
+ if (res.cancelled || res.value === '::exit') {
154
+ return { value: filteredData, cancelled: res.cancelled }
155
+ }
156
+
157
+ if (res.value === '::clear') {
158
+ query = ''
159
+ } else {
160
+ query = res.value
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Toast component – displays a brief notification.
3
+ *
4
+ * @module ui/toast
5
+ */
6
+
7
+ import Logger from '@nan0web/log'
8
+
9
+ /**
10
+ * Renders a toast message.
11
+ *
12
+ * @param {string} message - Message content.
13
+ * @param {'info'|'success'|'warning'|'error'} [variant='info']
14
+ * @returns {string} Styled string.
15
+ */
16
+ export function toast(message, variant = 'info') {
17
+ const icons = {
18
+ info: 'ℹ',
19
+ success: '✔',
20
+ warning: '⚠',
21
+ error: '✖',
22
+ }
23
+ const colors = {
24
+ info: Logger.BLUE,
25
+ success: Logger.GREEN,
26
+ warning: Logger.YELLOW,
27
+ error: Logger.RED,
28
+ }
29
+
30
+ const icon = icons[variant] || '•'
31
+ const color = colors[variant] || Logger.WHITE
32
+
33
+ return Logger.style(`${icon} ${message}`, { color })
34
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Toggle module – a more visual boolean switch.
3
+ * @module ui/toggle
4
+ */
5
+ import prompts from 'prompts'
6
+ import { CancelError } from '@nan0web/ui/core'
7
+
8
+ /**
9
+ * @param {Object} config
10
+ * @param {string} config.message
11
+ * @param {boolean} [config.initial=false]
12
+ * @param {string} [config.active='yes']
13
+ * @param {string} [config.inactive='no']
14
+ * @returns {Promise<{value:boolean, cancelled:boolean}>}
15
+ */
16
+ export async function toggle(config) {
17
+ const { message, initial = false, active = 'yes', inactive = 'no' } = config
18
+ const response = await prompts(
19
+ {
20
+ type: 'toggle',
21
+ name: 'value',
22
+ message,
23
+ initial,
24
+ active,
25
+ inactive,
26
+ },
27
+ {
28
+ onCancel: () => {
29
+ throw new CancelError()
30
+ },
31
+ }
32
+ )
33
+ return { value: response.value, cancelled: false }
34
+ }