mscfetch 1.0.1 → 1.1.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 +115 -18
- package/package.json +6 -2
- package/src/controllers/appController.js +56 -23
- package/src/views/customPrompts.js +663 -0
- package/src/views/terminalView.js +18 -9
package/README.md
CHANGED
|
@@ -1,37 +1,134 @@
|
|
|
1
|
-
# mscfetch
|
|
1
|
+
# 🎵 mscfetch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/mscfetch)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
An interactive terminal CLI that searches YouTube for audio tracks, accumulates songs into a batch queue across multiple searches, extracts high-quality audio as MP3 (VBR 0, ~320kbps), and embeds ID3 metadata automatically.
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
- [FFmpeg](https://ffmpeg.org/download.html) in your system PATH (required for audio conversion)
|
|
9
|
+
---
|
|
9
10
|
|
|
10
|
-
##
|
|
11
|
+
## ⚡ Quick Start
|
|
11
12
|
|
|
12
|
-
Run directly via `npx
|
|
13
|
+
Run directly with zero installation via `npx`:
|
|
13
14
|
|
|
14
15
|
```bash
|
|
15
16
|
npx mscfetch
|
|
16
17
|
```
|
|
17
18
|
|
|
18
|
-
Or install globally:
|
|
19
|
+
Or install globally on your system:
|
|
19
20
|
|
|
20
21
|
```bash
|
|
21
22
|
npm install -g mscfetch
|
|
22
23
|
mscfetch
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
---
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
- **Multi-Search Queue**: Accumulate tracks from different searches into a batch queue before downloading.
|
|
29
|
-
- **High Quality Audio**: Extracts highest available audio quality and converts to MP3 (VBR 0, ~320kbps).
|
|
30
|
-
- **Metadata Tags**: Automatically embeds track title, artist, and metadata into MP3 files.
|
|
31
|
-
- **Concurrent Downloads**: Downloads batch items concurrently with a pool of 2 workers.
|
|
32
|
-
- **Sanitized Filenames**: Automatically cleans illegal Windows/Unix filesystem characters.
|
|
33
|
-
- **Cross-Platform**: Saves all downloads directly to `~/Downloads/MusicDownloads`.
|
|
28
|
+
## 📋 Prerequisites
|
|
34
29
|
|
|
35
|
-
|
|
30
|
+
1. **[Node.js](https://nodejs.org/)** (v18 or higher)
|
|
31
|
+
2. **[FFmpeg](https://ffmpeg.org/download.html)** in your system PATH (required for audio conversion and metadata embedding)
|
|
36
32
|
|
|
37
|
-
|
|
33
|
+
### Installing FFmpeg
|
|
34
|
+
|
|
35
|
+
If FFmpeg is not already installed, `mscfetch` will automatically detect your OS and offer to install it for you (with an explicit option to decline). You can also install it manually:
|
|
36
|
+
|
|
37
|
+
- **Windows**:
|
|
38
|
+
```powershell
|
|
39
|
+
winget install Gyan.FFmpeg
|
|
40
|
+
```
|
|
41
|
+
*(or via Chocolatey: `choco install ffmpeg`)*
|
|
42
|
+
|
|
43
|
+
- **macOS**:
|
|
44
|
+
```bash
|
|
45
|
+
brew install ffmpeg
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Linux (Ubuntu/Debian)**:
|
|
49
|
+
```bash
|
|
50
|
+
sudo apt-get update && sudo apt-get install -y ffmpeg
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 🚀 Features
|
|
56
|
+
|
|
57
|
+
- **🔍 Metadata-Only Search**: Rapidly searches YouTube using `yt-dlp` without preloading or buffering audio stream data.
|
|
58
|
+
- **📋 Multi-Search Queue**: Search for track 1 (e.g. *When September Ends*), add it to your queue, search for track 2 (e.g. *All I Want*), and download everything together.
|
|
59
|
+
- **🎧 High Quality Audio (VBR 0)**: Converts best audio streams to MP3 using VBR 0 (~320kbps) via FFmpeg.
|
|
60
|
+
- **🏷️ Metadata Tags Embedded**: Embeds artist, track title, and media tags directly into the generated MP3 files.
|
|
61
|
+
- **⚡ 2-Worker Concurrency Pool**: Processes downloads in parallel using an internal concurrency queue limited to 2 workers to prevent throttling.
|
|
62
|
+
- **🛡️ Windows/Unix Filename Sanitizer**: Automatically cleans illegal characters (`<>:"/\|?*`) and protects against path traversal.
|
|
63
|
+
- **📂 Cross-Platform Downloads**: Automatically resolves and saves output directly to `~/Downloads/MusicDownloads`.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 💻 Workflow Example
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
🎵 Music Fetcher CLI 🎵
|
|
71
|
+
|
|
72
|
+
? Enter search query (artist or song): Green Day Wake Me Up When September Ends
|
|
73
|
+
✔ Found tracks
|
|
74
|
+
|
|
75
|
+
? Select tracks to add to queue ('d' to toggle, Enter to confirm, Esc to go back):
|
|
76
|
+
[ ] Green Day - Wake Me Up When September Ends (Green Day - 4:45)
|
|
77
|
+
[x] Green Day - Wake Me Up When September Ends [Official Video] (4:45)
|
|
78
|
+
|
|
79
|
+
✔ Added 1 track(s) to queue. Total in queue: 1
|
|
80
|
+
|
|
81
|
+
? Queue Menu - choose action:
|
|
82
|
+
❯ ➕ Add more songs to queue (search again)
|
|
83
|
+
⬇️ Download queue (1 track)
|
|
84
|
+
🗑️ Clear queue
|
|
85
|
+
❌ Exit
|
|
86
|
+
|
|
87
|
+
? Enter search query (artist or song): Paramore All I Want
|
|
88
|
+
...
|
|
89
|
+
✔ Added 1 track(s) to queue. Total in queue: 2
|
|
90
|
+
|
|
91
|
+
? Queue Menu - choose action:
|
|
92
|
+
➕ Add more songs to queue (search again)
|
|
93
|
+
❯ ⬇️ Download queue (2 tracks)
|
|
94
|
+
🗑️ Clear queue
|
|
95
|
+
❌ Exit
|
|
96
|
+
|
|
97
|
+
Starting batch download of 2 track(s)...
|
|
98
|
+
✔ Downloaded: Green Day - Wake Me Up When September Ends [Official Video]
|
|
99
|
+
✔ Downloaded: Paramore - All I Want
|
|
100
|
+
✔ All downloads saved to: C:\Users\...\Downloads\MusicDownloads
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 🏗️ Architecture
|
|
106
|
+
|
|
107
|
+
Built using native ES Modules (`"type": "module"`) following a strict Model-View-Controller (MVC) pattern:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
├── bin/
|
|
111
|
+
│ └── index.js # Executable entrypoint (#!/usr/bin/env node)
|
|
112
|
+
├── src/
|
|
113
|
+
│ ├── config/
|
|
114
|
+
│ │ └── paths.js # Cross-platform ~/Downloads/MusicDownloads resolution
|
|
115
|
+
│ ├── controllers/
|
|
116
|
+
│ │ └── appController.js # Main application event & queue loop
|
|
117
|
+
│ ├── models/
|
|
118
|
+
│ │ └── track.js # Track data structure & duration formatting
|
|
119
|
+
│ ├── services/
|
|
120
|
+
│ │ ├── searchService.js # yt-dlp metadata query
|
|
121
|
+
│ │ └── downloadService.js# yt-dlp audio extraction & concurrency queue
|
|
122
|
+
│ ├── utils/
|
|
123
|
+
│ │ └── envCheck.js # FFmpeg presence verification & auto-installer
|
|
124
|
+
│ └── views/
|
|
125
|
+
│ └── terminalView.js # Terminal prompts, spinners, & menus
|
|
126
|
+
├── package.json
|
|
127
|
+
└── README.md
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 📄 License
|
|
133
|
+
|
|
134
|
+
MIT © [EaRebusa](https://github.com/EaRebusa)
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mscfetch",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Interactive terminal music search and batch MP3 downloader",
|
|
5
5
|
"main": "./bin/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mscfetch": "bin/index.js",
|
|
8
8
|
"music-fetch": "bin/index.js"
|
|
9
9
|
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/EaRebusa/mscfetch.git"
|
|
13
|
+
},
|
|
10
14
|
"files": [
|
|
11
15
|
"bin",
|
|
12
16
|
"src",
|
|
@@ -25,7 +29,7 @@
|
|
|
25
29
|
"yt-dlp",
|
|
26
30
|
"mscfetch"
|
|
27
31
|
],
|
|
28
|
-
"author": "",
|
|
32
|
+
"author": "EaRebusa",
|
|
29
33
|
"license": "MIT",
|
|
30
34
|
"dependencies": {
|
|
31
35
|
"@inquirer/prompts": "^8.7.4",
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
displayQueueSummary,
|
|
11
11
|
promptContinue,
|
|
12
12
|
createSpinner,
|
|
13
|
+
BACK_ACTION,
|
|
13
14
|
} from '../views/terminalView.js';
|
|
14
15
|
import chalk from 'chalk';
|
|
15
16
|
|
|
@@ -21,22 +22,42 @@ export async function runController() {
|
|
|
21
22
|
|
|
22
23
|
let queue = [];
|
|
23
24
|
let isRunning = true;
|
|
25
|
+
let currentScreen = 'search';
|
|
26
|
+
let canGoBackFromSearch = false;
|
|
24
27
|
|
|
25
28
|
while (isRunning) {
|
|
26
29
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
if (currentScreen === 'search') {
|
|
31
|
+
const query = await promptSearchQuery(canGoBackFromSearch);
|
|
32
|
+
if (query === BACK_ACTION) {
|
|
33
|
+
if (canGoBackFromSearch) {
|
|
34
|
+
currentScreen = 'queue_menu';
|
|
35
|
+
continue;
|
|
36
|
+
} else {
|
|
37
|
+
isRunning = false;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const spinner = createSpinner('Searching YouTube...').start();
|
|
43
|
+
let tracks;
|
|
44
|
+
try {
|
|
45
|
+
tracks = await searchTracks(query);
|
|
46
|
+
} finally {
|
|
47
|
+
spinner.stop();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!tracks || tracks.length === 0) {
|
|
51
|
+
console.log(chalk.yellow('No tracks found for your query.'));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
35
54
|
|
|
36
|
-
if (!tracks || tracks.length === 0) {
|
|
37
|
-
console.log(chalk.yellow('No tracks found for your query.'));
|
|
38
|
-
} else {
|
|
39
55
|
const selected = await promptTrackSelection(tracks);
|
|
56
|
+
if (selected === BACK_ACTION) {
|
|
57
|
+
console.log(chalk.gray('\nTrack selection canceled.'));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
if (selected.length > 0) {
|
|
41
62
|
const newTracks = selected.filter(
|
|
42
63
|
(item) => !queue.some((q) => q.id === item.id || q.url === item.url)
|
|
@@ -51,16 +72,20 @@ export async function runController() {
|
|
|
51
72
|
} else {
|
|
52
73
|
console.log(chalk.yellow('\nNo tracks selected from this search.'));
|
|
53
74
|
}
|
|
54
|
-
}
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
while (deciding) {
|
|
76
|
+
currentScreen = 'queue_menu';
|
|
77
|
+
} else if (currentScreen === 'queue_menu') {
|
|
59
78
|
displayQueueSummary(queue);
|
|
60
79
|
const action = await promptQueueAction(queue);
|
|
61
80
|
|
|
81
|
+
if (action === BACK_ACTION || action === 'exit') {
|
|
82
|
+
isRunning = false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
if (action === 'search') {
|
|
63
|
-
|
|
87
|
+
currentScreen = 'search';
|
|
88
|
+
canGoBackFromSearch = true;
|
|
64
89
|
} else if (action === 'download') {
|
|
65
90
|
if (queue.length === 0) {
|
|
66
91
|
console.log(chalk.yellow('Queue is empty. Search and select tracks first.'));
|
|
@@ -90,16 +115,18 @@ export async function runController() {
|
|
|
90
115
|
|
|
91
116
|
downloadSpinner.stop();
|
|
92
117
|
console.log(chalk.green(`\n✔ All downloads saved to: ${getDownloadDir()}`));
|
|
93
|
-
queue = [];
|
|
94
|
-
deciding = false;
|
|
118
|
+
queue = [];
|
|
95
119
|
|
|
96
|
-
|
|
120
|
+
const continueSession = await promptContinue();
|
|
121
|
+
if (continueSession) {
|
|
122
|
+
currentScreen = 'search';
|
|
123
|
+
canGoBackFromSearch = false;
|
|
124
|
+
} else {
|
|
125
|
+
isRunning = false;
|
|
126
|
+
}
|
|
97
127
|
} else if (action === 'clear') {
|
|
98
128
|
queue = [];
|
|
99
129
|
console.log(chalk.yellow('\nQueue cleared.'));
|
|
100
|
-
} else if (action === 'exit') {
|
|
101
|
-
deciding = false;
|
|
102
|
-
isRunning = false;
|
|
103
130
|
}
|
|
104
131
|
}
|
|
105
132
|
} catch (error) {
|
|
@@ -109,7 +136,13 @@ export async function runController() {
|
|
|
109
136
|
}
|
|
110
137
|
console.error(chalk.red(`Error: ${error.message}`));
|
|
111
138
|
try {
|
|
112
|
-
|
|
139
|
+
const continueSession = await promptContinue();
|
|
140
|
+
if (continueSession) {
|
|
141
|
+
currentScreen = 'search';
|
|
142
|
+
canGoBackFromSearch = queue.length > 0;
|
|
143
|
+
} else {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
113
146
|
} catch (promptError) {
|
|
114
147
|
if (promptError?.name === 'ExitPromptError') break;
|
|
115
148
|
throw promptError;
|
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPrompt,
|
|
3
|
+
useState,
|
|
4
|
+
useKeypress,
|
|
5
|
+
usePrefix,
|
|
6
|
+
usePagination,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useEffect,
|
|
10
|
+
makeTheme,
|
|
11
|
+
isUpKey,
|
|
12
|
+
isDownKey,
|
|
13
|
+
isNumberKey,
|
|
14
|
+
isEnterKey,
|
|
15
|
+
isBackspaceKey,
|
|
16
|
+
isTabKey,
|
|
17
|
+
ValidationError,
|
|
18
|
+
Separator,
|
|
19
|
+
} from '@inquirer/core';
|
|
20
|
+
import { cursorHide } from '@inquirer/ansi';
|
|
21
|
+
import { styleText } from 'node:util';
|
|
22
|
+
import figures from '@inquirer/figures';
|
|
23
|
+
|
|
24
|
+
export const BACK_ACTION = Symbol('BACK');
|
|
25
|
+
|
|
26
|
+
export function isEscapeKey(key) {
|
|
27
|
+
return (
|
|
28
|
+
key.name === 'escape' ||
|
|
29
|
+
key.name === 'esc' ||
|
|
30
|
+
(key.sequence === '\x1b' && !key.ctrl && !key.meta && !key.shift)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isToggleKey(key) {
|
|
35
|
+
return key.name === 'd' || key.name === 'D';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------
|
|
39
|
+
// Custom Checkbox Prompt: 'd' toggles, Esc cancels/goes back
|
|
40
|
+
// ---------------------------------------------------------
|
|
41
|
+
const checkboxTheme = {
|
|
42
|
+
icon: {
|
|
43
|
+
checked: styleText('green', figures.circleFilled),
|
|
44
|
+
unchecked: figures.circle,
|
|
45
|
+
cursor: figures.pointer,
|
|
46
|
+
disabledChecked: styleText('green', figures.circleDouble),
|
|
47
|
+
disabledUnchecked: '-',
|
|
48
|
+
},
|
|
49
|
+
style: {
|
|
50
|
+
disabled: (text) => styleText('dim', text),
|
|
51
|
+
renderSelectedChoices: (selectedChoices) =>
|
|
52
|
+
selectedChoices.map((choice) => choice.short).join(', '),
|
|
53
|
+
description: (text) => styleText('cyan', text),
|
|
54
|
+
keysHelpTip: (keys) =>
|
|
55
|
+
keys
|
|
56
|
+
.map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`)
|
|
57
|
+
.join(styleText('dim', ' • ')),
|
|
58
|
+
},
|
|
59
|
+
i18n: { disabledError: 'This option is disabled and cannot be toggled.' },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function isSelectable(item) {
|
|
63
|
+
return !Separator.isSeparator(item) && !item.disabled;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isNavigable(item) {
|
|
67
|
+
return !Separator.isSeparator(item);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isChecked(item) {
|
|
71
|
+
return !Separator.isSeparator(item) && item.checked;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toggle(item) {
|
|
75
|
+
return isSelectable(item) ? { ...item, checked: !item.checked } : item;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function check(checked) {
|
|
79
|
+
return function (item) {
|
|
80
|
+
return isSelectable(item) ? { ...item, checked } : item;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeCheckboxChoices(choices) {
|
|
85
|
+
return choices.map((choice) => {
|
|
86
|
+
if (Separator.isSeparator(choice)) return choice;
|
|
87
|
+
if (typeof choice !== 'object' || choice === null || !('value' in choice)) {
|
|
88
|
+
const name = String(choice);
|
|
89
|
+
return {
|
|
90
|
+
value: choice,
|
|
91
|
+
name,
|
|
92
|
+
short: name,
|
|
93
|
+
checkedName: name,
|
|
94
|
+
disabled: false,
|
|
95
|
+
checked: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const name = choice.name ?? String(choice.value);
|
|
99
|
+
const normalizedChoice = {
|
|
100
|
+
value: choice.value,
|
|
101
|
+
name,
|
|
102
|
+
short: choice.short ?? name,
|
|
103
|
+
checkedName: choice.checkedName ?? name,
|
|
104
|
+
disabled: choice.disabled ?? false,
|
|
105
|
+
checked: choice.checked ?? false,
|
|
106
|
+
};
|
|
107
|
+
if (choice.description) {
|
|
108
|
+
normalizedChoice.description = choice.description;
|
|
109
|
+
}
|
|
110
|
+
return normalizedChoice;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const customCheckbox = createPrompt((config, done) => {
|
|
115
|
+
const { pageSize = 7, loop = true, required, validate = () => true } = config;
|
|
116
|
+
const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts };
|
|
117
|
+
const theme = makeTheme(checkboxTheme, config.theme);
|
|
118
|
+
const { keybindings } = theme;
|
|
119
|
+
const [status, setStatus] = useState('idle');
|
|
120
|
+
const prefix = usePrefix({ status, theme });
|
|
121
|
+
const [items, setItems] = useState(normalizeCheckboxChoices(config.choices));
|
|
122
|
+
const isCancelledRef = useRef(false);
|
|
123
|
+
|
|
124
|
+
const bounds = useMemo(() => {
|
|
125
|
+
const first = items.findIndex(isNavigable);
|
|
126
|
+
const last = items.findLastIndex(isNavigable);
|
|
127
|
+
if (first === -1) {
|
|
128
|
+
throw new ValidationError('[checkbox prompt] No selectable choices. All choices are disabled.');
|
|
129
|
+
}
|
|
130
|
+
return { first, last };
|
|
131
|
+
}, [items]);
|
|
132
|
+
|
|
133
|
+
const [active, setActive] = useState(bounds.first);
|
|
134
|
+
const [errorMsg, setError] = useState();
|
|
135
|
+
|
|
136
|
+
useKeypress(async (key) => {
|
|
137
|
+
if (status !== 'idle') return;
|
|
138
|
+
|
|
139
|
+
if (isEscapeKey(key)) {
|
|
140
|
+
isCancelledRef.current = true;
|
|
141
|
+
setStatus('done');
|
|
142
|
+
done(BACK_ACTION);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (isEnterKey(key)) {
|
|
147
|
+
setStatus('loading');
|
|
148
|
+
const selection = items.filter(isChecked);
|
|
149
|
+
const isValid = await validate([...selection]);
|
|
150
|
+
setStatus('idle');
|
|
151
|
+
if (required && !selection.length) {
|
|
152
|
+
setError('At least one choice must be selected');
|
|
153
|
+
} else if (isValid === true) {
|
|
154
|
+
setStatus('done');
|
|
155
|
+
done(selection.map((choice) => choice.value));
|
|
156
|
+
} else {
|
|
157
|
+
setError(isValid || 'You must select a valid value');
|
|
158
|
+
}
|
|
159
|
+
} else if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) {
|
|
160
|
+
if (errorMsg) setError(undefined);
|
|
161
|
+
if (
|
|
162
|
+
loop ||
|
|
163
|
+
(isUpKey(key, keybindings) && active !== bounds.first) ||
|
|
164
|
+
(isDownKey(key, keybindings) && active !== bounds.last)
|
|
165
|
+
) {
|
|
166
|
+
const offset = isUpKey(key, keybindings) ? -1 : 1;
|
|
167
|
+
let next = active;
|
|
168
|
+
do {
|
|
169
|
+
next = (next + offset + items.length) % items.length;
|
|
170
|
+
} while (!isNavigable(items[next]));
|
|
171
|
+
setActive(next);
|
|
172
|
+
}
|
|
173
|
+
} else if (isToggleKey(key)) {
|
|
174
|
+
const activeItem = items[active];
|
|
175
|
+
if (activeItem && !Separator.isSeparator(activeItem)) {
|
|
176
|
+
if (activeItem.disabled) {
|
|
177
|
+
setError(theme.i18n.disabledError);
|
|
178
|
+
} else {
|
|
179
|
+
setError(undefined);
|
|
180
|
+
setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice)));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if (key.name === shortcuts.all) {
|
|
184
|
+
const selectAll = items.some((choice) => isSelectable(choice) && !choice.checked);
|
|
185
|
+
setItems(items.map(check(selectAll)));
|
|
186
|
+
} else if (key.name === shortcuts.invert) {
|
|
187
|
+
setItems(items.map(toggle));
|
|
188
|
+
} else if (isNumberKey(key)) {
|
|
189
|
+
const selectedIndex = Number(key.name) - 1;
|
|
190
|
+
let selectableIndex = -1;
|
|
191
|
+
const position = items.findIndex((item) => {
|
|
192
|
+
if (Separator.isSeparator(item)) return false;
|
|
193
|
+
selectableIndex++;
|
|
194
|
+
return selectableIndex === selectedIndex;
|
|
195
|
+
});
|
|
196
|
+
const selectedItem = items[position];
|
|
197
|
+
if (selectedItem && isSelectable(selectedItem)) {
|
|
198
|
+
setActive(position);
|
|
199
|
+
setItems(items.map((choice, i) => (i === position ? toggle(choice) : choice)));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const message = theme.style.message(config.message, status);
|
|
205
|
+
let description;
|
|
206
|
+
const page = usePagination({
|
|
207
|
+
items,
|
|
208
|
+
active,
|
|
209
|
+
renderItem({ item, isActive }) {
|
|
210
|
+
if (Separator.isSeparator(item)) {
|
|
211
|
+
return ` ${item.separator}`;
|
|
212
|
+
}
|
|
213
|
+
const cursor = isActive ? theme.icon.cursor : ' ';
|
|
214
|
+
if (item.disabled) {
|
|
215
|
+
const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)';
|
|
216
|
+
const checkbox = item.checked
|
|
217
|
+
? theme.icon.disabledChecked
|
|
218
|
+
: theme.icon.disabledUnchecked;
|
|
219
|
+
return theme.style.disabled(`${cursor}${checkbox} ${item.name} ${disabledLabel}`);
|
|
220
|
+
}
|
|
221
|
+
if (isActive) {
|
|
222
|
+
description = item.description;
|
|
223
|
+
}
|
|
224
|
+
const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
|
|
225
|
+
const name = item.checked ? item.checkedName : item.name;
|
|
226
|
+
const color = isActive ? theme.style.highlight : (x) => x;
|
|
227
|
+
return color(`${cursor}${checkbox} ${name}`);
|
|
228
|
+
},
|
|
229
|
+
pageSize,
|
|
230
|
+
loop,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (status === 'done') {
|
|
234
|
+
if (isCancelledRef.current) {
|
|
235
|
+
return [prefix, message, theme.style.disabled('(back)')].filter(Boolean).join(' ');
|
|
236
|
+
}
|
|
237
|
+
const selection = items.filter(isChecked);
|
|
238
|
+
const answer = theme.style.answer(theme.style.renderSelectedChoices(selection, items));
|
|
239
|
+
return [prefix, message, answer].filter(Boolean).join(' ');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const keys = [
|
|
243
|
+
['↑↓', 'navigate'],
|
|
244
|
+
['d', 'toggle'],
|
|
245
|
+
];
|
|
246
|
+
if (shortcuts.all) keys.push([shortcuts.all, 'all']);
|
|
247
|
+
if (shortcuts.invert) keys.push([shortcuts.invert, 'invert']);
|
|
248
|
+
keys.push(['esc', 'back']);
|
|
249
|
+
keys.push(['⏎', 'submit']);
|
|
250
|
+
const helpLine = theme.style.keysHelpTip(keys);
|
|
251
|
+
|
|
252
|
+
const lines = [
|
|
253
|
+
[prefix, message].filter(Boolean).join(' '),
|
|
254
|
+
page,
|
|
255
|
+
' ',
|
|
256
|
+
description ? theme.style.description(description) : '',
|
|
257
|
+
errorMsg ? theme.style.error(errorMsg) : '',
|
|
258
|
+
helpLine,
|
|
259
|
+
]
|
|
260
|
+
.filter(Boolean)
|
|
261
|
+
.join('\n')
|
|
262
|
+
.trimEnd();
|
|
263
|
+
|
|
264
|
+
return `${lines}${cursorHide}`;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------
|
|
268
|
+
// Custom Input Prompt: Esc cancels / goes back
|
|
269
|
+
// ---------------------------------------------------------
|
|
270
|
+
const inputTheme = {
|
|
271
|
+
validationFailureMode: 'keep',
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export const customInput = createPrompt((config, done) => {
|
|
275
|
+
const { prefill = 'tab' } = config;
|
|
276
|
+
const theme = makeTheme(inputTheme, config.theme);
|
|
277
|
+
const [status, setStatus] = useState('idle');
|
|
278
|
+
const [defaultValue, setDefaultValue] = useState(String(config.default ?? ''));
|
|
279
|
+
const [errorMsg, setError] = useState();
|
|
280
|
+
const [value, setValue] = useState('');
|
|
281
|
+
const isCancelledRef = useRef(false);
|
|
282
|
+
const prefix = usePrefix({ status, theme });
|
|
283
|
+
|
|
284
|
+
async function validate(val) {
|
|
285
|
+
const { required, pattern, patternError = 'Invalid input' } = config;
|
|
286
|
+
if (required && !val) return 'You must provide a value';
|
|
287
|
+
if (pattern && !pattern.test(val)) return patternError;
|
|
288
|
+
if (typeof config.validate === 'function') {
|
|
289
|
+
return (await config.validate(val)) || 'You must provide a valid value';
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
useKeypress(async (key, rl) => {
|
|
295
|
+
if (status !== 'idle') return;
|
|
296
|
+
|
|
297
|
+
if (isEscapeKey(key)) {
|
|
298
|
+
rl.clearLine(0);
|
|
299
|
+
isCancelledRef.current = true;
|
|
300
|
+
setStatus('done');
|
|
301
|
+
done(BACK_ACTION);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (isEnterKey(key)) {
|
|
306
|
+
const answer = value || defaultValue;
|
|
307
|
+
setStatus('loading');
|
|
308
|
+
const isValid = await validate(answer);
|
|
309
|
+
if (isValid === true) {
|
|
310
|
+
setValue(answer);
|
|
311
|
+
setStatus('done');
|
|
312
|
+
done(answer);
|
|
313
|
+
} else {
|
|
314
|
+
if (theme.validationFailureMode === 'clear') {
|
|
315
|
+
setValue('');
|
|
316
|
+
} else {
|
|
317
|
+
rl.write(value);
|
|
318
|
+
}
|
|
319
|
+
setError(isValid);
|
|
320
|
+
setStatus('idle');
|
|
321
|
+
}
|
|
322
|
+
} else if (isBackspaceKey(key) && !value) {
|
|
323
|
+
setDefaultValue('');
|
|
324
|
+
} else if (isTabKey(key) && !value) {
|
|
325
|
+
setDefaultValue('');
|
|
326
|
+
rl.clearLine(0);
|
|
327
|
+
rl.write(defaultValue);
|
|
328
|
+
setValue(defaultValue);
|
|
329
|
+
} else {
|
|
330
|
+
setDefaultValue('');
|
|
331
|
+
setValue(rl.line);
|
|
332
|
+
setError(undefined);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
useEffect((rl) => {
|
|
337
|
+
if (prefill === 'editable' && defaultValue) {
|
|
338
|
+
rl.write(defaultValue);
|
|
339
|
+
setValue(defaultValue);
|
|
340
|
+
}
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
const message = theme.style.message(config.message, status);
|
|
344
|
+
let formattedValue = value;
|
|
345
|
+
if (status === 'done' && isCancelledRef.current) {
|
|
346
|
+
formattedValue = theme.style.disabled ? theme.style.disabled('(back)') : '(back)';
|
|
347
|
+
} else if (typeof config.transformer === 'function') {
|
|
348
|
+
formattedValue = config.transformer(value, { isFinal: status === 'done' });
|
|
349
|
+
} else if (status === 'done') {
|
|
350
|
+
formattedValue = theme.style.answer(value);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let defaultStr;
|
|
354
|
+
if (defaultValue && status !== 'done' && !value) {
|
|
355
|
+
defaultStr = theme.style.defaultAnswer(defaultValue);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let error = '';
|
|
359
|
+
if (errorMsg) {
|
|
360
|
+
error = theme.style.error(errorMsg);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return [
|
|
364
|
+
[prefix, message, defaultStr, formattedValue]
|
|
365
|
+
.filter((v) => v !== undefined)
|
|
366
|
+
.join(' '),
|
|
367
|
+
error,
|
|
368
|
+
];
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------
|
|
372
|
+
// Custom Select Prompt: Esc cancels / goes back
|
|
373
|
+
// ---------------------------------------------------------
|
|
374
|
+
const selectTheme = {
|
|
375
|
+
icon: { cursor: figures.pointer },
|
|
376
|
+
style: {
|
|
377
|
+
disabled: (text) => styleText('dim', text),
|
|
378
|
+
description: (text) => styleText('cyan', text),
|
|
379
|
+
keysHelpTip: (keys) =>
|
|
380
|
+
keys
|
|
381
|
+
.map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`)
|
|
382
|
+
.join(styleText('dim', ' • ')),
|
|
383
|
+
},
|
|
384
|
+
i18n: { disabledError: 'This option is disabled and cannot be selected.' },
|
|
385
|
+
indexMode: 'hidden',
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
function normalizeSelectChoices(choices) {
|
|
389
|
+
return choices.map((choice) => {
|
|
390
|
+
if (Separator.isSeparator(choice)) return choice;
|
|
391
|
+
if (typeof choice !== 'object' || choice === null || !('value' in choice)) {
|
|
392
|
+
const name = String(choice);
|
|
393
|
+
return {
|
|
394
|
+
value: choice,
|
|
395
|
+
name,
|
|
396
|
+
short: name,
|
|
397
|
+
disabled: false,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const name = choice.name ?? String(choice.value);
|
|
401
|
+
const normalizedChoice = {
|
|
402
|
+
value: choice.value,
|
|
403
|
+
name,
|
|
404
|
+
short: choice.short ?? name,
|
|
405
|
+
disabled: choice.disabled ?? false,
|
|
406
|
+
};
|
|
407
|
+
if (choice.description) {
|
|
408
|
+
normalizedChoice.description = choice.description;
|
|
409
|
+
}
|
|
410
|
+
return normalizedChoice;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const customSelect = createPrompt((config, done) => {
|
|
415
|
+
const { loop = true, pageSize = 7 } = config;
|
|
416
|
+
const theme = makeTheme(selectTheme, config.theme);
|
|
417
|
+
const { keybindings } = theme;
|
|
418
|
+
const [status, setStatus] = useState('idle');
|
|
419
|
+
const prefix = usePrefix({ status, theme });
|
|
420
|
+
const searchTimeoutRef = useRef();
|
|
421
|
+
const isCancelledRef = useRef(false);
|
|
422
|
+
const searchEnabled = !keybindings.includes('vim');
|
|
423
|
+
const items = useMemo(() => normalizeSelectChoices(config.choices), [config.choices]);
|
|
424
|
+
const bounds = useMemo(() => {
|
|
425
|
+
const first = items.findIndex(isNavigable);
|
|
426
|
+
const last = items.findLastIndex(isNavigable);
|
|
427
|
+
if (first === -1) {
|
|
428
|
+
throw new ValidationError('[select prompt] No selectable choices. All choices are disabled.');
|
|
429
|
+
}
|
|
430
|
+
return { first, last };
|
|
431
|
+
}, [items]);
|
|
432
|
+
|
|
433
|
+
const [active, setActive] = useState(bounds.first);
|
|
434
|
+
const [errorMsg, setError] = useState();
|
|
435
|
+
const selectedChoice = items[active];
|
|
436
|
+
|
|
437
|
+
useKeypress((key, rl) => {
|
|
438
|
+
clearTimeout(searchTimeoutRef.current);
|
|
439
|
+
if (errorMsg) setError(undefined);
|
|
440
|
+
|
|
441
|
+
if (isEscapeKey(key)) {
|
|
442
|
+
isCancelledRef.current = true;
|
|
443
|
+
setStatus('done');
|
|
444
|
+
done(BACK_ACTION);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (isEnterKey(key)) {
|
|
449
|
+
if (selectedChoice.disabled) {
|
|
450
|
+
setError(theme.i18n.disabledError);
|
|
451
|
+
} else {
|
|
452
|
+
setStatus('done');
|
|
453
|
+
done(selectedChoice.value);
|
|
454
|
+
}
|
|
455
|
+
} else if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) {
|
|
456
|
+
rl.clearLine(0);
|
|
457
|
+
if (
|
|
458
|
+
loop ||
|
|
459
|
+
(isUpKey(key, keybindings) && active !== bounds.first) ||
|
|
460
|
+
(isDownKey(key, keybindings) && active !== bounds.last)
|
|
461
|
+
) {
|
|
462
|
+
const offset = isUpKey(key, keybindings) ? -1 : 1;
|
|
463
|
+
let next = active;
|
|
464
|
+
do {
|
|
465
|
+
next = (next + offset + items.length) % items.length;
|
|
466
|
+
} while (!isNavigable(items[next]));
|
|
467
|
+
setActive(next);
|
|
468
|
+
}
|
|
469
|
+
} else if (isNumberKey(key) && !Number.isNaN(Number(rl.line))) {
|
|
470
|
+
const selectedIndex = Number(rl.line) - 1;
|
|
471
|
+
let selectableIndex = -1;
|
|
472
|
+
const position = items.findIndex((item) => {
|
|
473
|
+
if (Separator.isSeparator(item)) return false;
|
|
474
|
+
selectableIndex++;
|
|
475
|
+
return selectableIndex === selectedIndex;
|
|
476
|
+
});
|
|
477
|
+
const item = items[position];
|
|
478
|
+
if (item != null && isSelectable(item)) {
|
|
479
|
+
setActive(position);
|
|
480
|
+
}
|
|
481
|
+
searchTimeoutRef.current = setTimeout(() => {
|
|
482
|
+
rl.clearLine(0);
|
|
483
|
+
}, 700);
|
|
484
|
+
} else if (isBackspaceKey(key)) {
|
|
485
|
+
rl.clearLine(0);
|
|
486
|
+
} else if (searchEnabled) {
|
|
487
|
+
const searchTerm = rl.line.toLowerCase();
|
|
488
|
+
const matchIndex = items.findIndex((item) => {
|
|
489
|
+
if (Separator.isSeparator(item) || !isSelectable(item)) return false;
|
|
490
|
+
return item.name.toLowerCase().startsWith(searchTerm);
|
|
491
|
+
});
|
|
492
|
+
if (matchIndex !== -1) {
|
|
493
|
+
setActive(matchIndex);
|
|
494
|
+
}
|
|
495
|
+
searchTimeoutRef.current = setTimeout(() => {
|
|
496
|
+
rl.clearLine(0);
|
|
497
|
+
}, 700);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
useEffect(() => () => {
|
|
502
|
+
clearTimeout(searchTimeoutRef.current);
|
|
503
|
+
}, []);
|
|
504
|
+
|
|
505
|
+
const message = theme.style.message(config.message, status);
|
|
506
|
+
const helpLine = theme.style.keysHelpTip([
|
|
507
|
+
['↑↓', 'navigate'],
|
|
508
|
+
['esc', 'back'],
|
|
509
|
+
['⏎', 'select'],
|
|
510
|
+
]);
|
|
511
|
+
|
|
512
|
+
let separatorCount = 0;
|
|
513
|
+
const page = usePagination({
|
|
514
|
+
items,
|
|
515
|
+
active,
|
|
516
|
+
renderItem({ item, isActive, index }) {
|
|
517
|
+
if (Separator.isSeparator(item)) {
|
|
518
|
+
separatorCount++;
|
|
519
|
+
return ` ${item.separator}`;
|
|
520
|
+
}
|
|
521
|
+
const cursor = isActive ? theme.icon.cursor : ' ';
|
|
522
|
+
const indexLabel = theme.indexMode === 'number' ? `${index + 1 - separatorCount}. ` : '';
|
|
523
|
+
if (item.disabled) {
|
|
524
|
+
const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)';
|
|
525
|
+
const disabledCursor = isActive ? theme.icon.cursor : '-';
|
|
526
|
+
return theme.style.disabled(`${disabledCursor} ${indexLabel}${item.name} ${disabledLabel}`);
|
|
527
|
+
}
|
|
528
|
+
const color = isActive ? theme.style.highlight : (x) => x;
|
|
529
|
+
return color(`${cursor} ${indexLabel}${item.name}`);
|
|
530
|
+
},
|
|
531
|
+
pageSize,
|
|
532
|
+
loop,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
if (status === 'done') {
|
|
536
|
+
if (isCancelledRef.current) {
|
|
537
|
+
return [prefix, message, theme.style.disabled ? theme.style.disabled('(back)') : '(back)']
|
|
538
|
+
.filter(Boolean)
|
|
539
|
+
.join(' ');
|
|
540
|
+
}
|
|
541
|
+
return [prefix, message, theme.style.answer(selectedChoice.short)]
|
|
542
|
+
.filter(Boolean)
|
|
543
|
+
.join(' ');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const { description } = selectedChoice;
|
|
547
|
+
const lines = [
|
|
548
|
+
[prefix, message].filter(Boolean).join(' '),
|
|
549
|
+
page,
|
|
550
|
+
' ',
|
|
551
|
+
description ? theme.style.description(description) : '',
|
|
552
|
+
errorMsg ? theme.style.error(errorMsg) : '',
|
|
553
|
+
helpLine,
|
|
554
|
+
]
|
|
555
|
+
.filter(Boolean)
|
|
556
|
+
.join('\n')
|
|
557
|
+
.trimEnd();
|
|
558
|
+
|
|
559
|
+
return `${lines}${cursorHide}`;
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------
|
|
563
|
+
// Custom Confirm Prompt: Esc acts as 'no'
|
|
564
|
+
// ---------------------------------------------------------
|
|
565
|
+
const confirmTheme = {
|
|
566
|
+
keywords: {
|
|
567
|
+
yes: 'Yes',
|
|
568
|
+
no: 'No',
|
|
569
|
+
error: ({ yes, no }) => `You must answer with "${yes}" or "${no}"`,
|
|
570
|
+
},
|
|
571
|
+
style: {
|
|
572
|
+
confirmDefault: (text) => {
|
|
573
|
+
const first = text[0] ?? '';
|
|
574
|
+
if (first.toLowerCase() === first.toUpperCase()) {
|
|
575
|
+
return styleText('cyan', text);
|
|
576
|
+
}
|
|
577
|
+
return first.toUpperCase() + text.slice(1);
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
export const customConfirm = createPrompt((config, done) => {
|
|
583
|
+
const [status, setStatus] = useState('idle');
|
|
584
|
+
const [value, setValue] = useState('');
|
|
585
|
+
const [errorMsg, setError] = useState();
|
|
586
|
+
const theme = makeTheme(confirmTheme, config.theme);
|
|
587
|
+
const prefix = usePrefix({ status, theme });
|
|
588
|
+
const { yes, no } = theme.keywords;
|
|
589
|
+
const isCancelledRef = useRef(false);
|
|
590
|
+
|
|
591
|
+
const yesHint = (yes[0] ?? '').toLowerCase();
|
|
592
|
+
const noHint = (no[0] ?? '').toLowerCase();
|
|
593
|
+
const hint =
|
|
594
|
+
config.default === false
|
|
595
|
+
? `${yesHint}/${theme.style.confirmDefault(noHint)}`
|
|
596
|
+
: `${theme.style.confirmDefault(yesHint)}/${noHint}`;
|
|
597
|
+
|
|
598
|
+
function boolToString(val) {
|
|
599
|
+
return val ? yes : no;
|
|
600
|
+
}
|
|
601
|
+
const { transformer = boolToString } = config;
|
|
602
|
+
|
|
603
|
+
function getBooleanValue(val) {
|
|
604
|
+
const v = val.trim().toLowerCase();
|
|
605
|
+
if (v === '') return config.default !== false;
|
|
606
|
+
if (yes.toLowerCase().startsWith(v)) return true;
|
|
607
|
+
if (no.toLowerCase().startsWith(v)) return false;
|
|
608
|
+
if ('yes'.startsWith(v)) return true;
|
|
609
|
+
if ('no'.startsWith(v)) return false;
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
useKeypress((key, rl) => {
|
|
614
|
+
if (status !== 'idle') return;
|
|
615
|
+
|
|
616
|
+
if (isEscapeKey(key)) {
|
|
617
|
+
isCancelledRef.current = true;
|
|
618
|
+
setStatus('done');
|
|
619
|
+
done(false);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (isEnterKey(key)) {
|
|
624
|
+
const answer = getBooleanValue(value);
|
|
625
|
+
if (typeof answer === 'boolean') {
|
|
626
|
+
setStatus('done');
|
|
627
|
+
done(answer);
|
|
628
|
+
} else {
|
|
629
|
+
setError(theme.keywords.error({ yes, no }));
|
|
630
|
+
}
|
|
631
|
+
} else if (isBackspaceKey(key)) {
|
|
632
|
+
rl.clearLine(0);
|
|
633
|
+
setValue('');
|
|
634
|
+
setError(undefined);
|
|
635
|
+
} else {
|
|
636
|
+
setValue(rl.line);
|
|
637
|
+
setError(undefined);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const message = theme.style.message(config.message, status);
|
|
642
|
+
let formattedValue = value;
|
|
643
|
+
let defaultValue = '';
|
|
644
|
+
if (status === 'done') {
|
|
645
|
+
if (isCancelledRef.current) {
|
|
646
|
+
formattedValue = theme.style.answer(no);
|
|
647
|
+
} else {
|
|
648
|
+
formattedValue = theme.style.answer(value || transformer(config.default !== false));
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
defaultValue = ` ${theme.style.defaultAnswer(hint)}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let error = '';
|
|
655
|
+
if (errorMsg) {
|
|
656
|
+
error = theme.style.error(errorMsg);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return [
|
|
660
|
+
`${prefix} ${message}${defaultValue} ${formattedValue}`,
|
|
661
|
+
error,
|
|
662
|
+
];
|
|
663
|
+
});
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { input, checkbox, confirm, select } from '@inquirer/prompts';
|
|
2
1
|
import chalk from 'chalk';
|
|
3
2
|
import ora from 'ora';
|
|
3
|
+
import {
|
|
4
|
+
customCheckbox,
|
|
5
|
+
customInput,
|
|
6
|
+
customSelect,
|
|
7
|
+
customConfirm,
|
|
8
|
+
BACK_ACTION,
|
|
9
|
+
} from './customPrompts.js';
|
|
4
10
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
export { BACK_ACTION };
|
|
12
|
+
|
|
13
|
+
export async function promptSearchQuery(canGoBack = false) {
|
|
14
|
+
const escHint = canGoBack ? 'Esc to go back' : 'Esc to exit';
|
|
15
|
+
return await customInput({
|
|
16
|
+
message: chalk.cyan(`Enter search query (artist or song) [${escHint}]:`),
|
|
8
17
|
validate: (value) => value.trim().length > 0 || 'Query cannot be empty',
|
|
9
18
|
});
|
|
10
19
|
}
|
|
@@ -16,8 +25,8 @@ export async function promptTrackSelection(tracks) {
|
|
|
16
25
|
checked: false, // Default to unchecked per user preference
|
|
17
26
|
}));
|
|
18
27
|
|
|
19
|
-
return await
|
|
20
|
-
message: chalk.cyan(
|
|
28
|
+
return await customCheckbox({
|
|
29
|
+
message: chalk.cyan("Select tracks to add to queue ('d' to toggle, Enter to confirm, Esc to go back):"),
|
|
21
30
|
choices,
|
|
22
31
|
});
|
|
23
32
|
}
|
|
@@ -47,8 +56,8 @@ export async function promptQueueAction(queue) {
|
|
|
47
56
|
value: 'exit',
|
|
48
57
|
});
|
|
49
58
|
|
|
50
|
-
return await
|
|
51
|
-
message: chalk.cyan('Queue Menu - choose action:'),
|
|
59
|
+
return await customSelect({
|
|
60
|
+
message: chalk.cyan('Queue Menu - choose action (Esc to exit):'),
|
|
52
61
|
choices,
|
|
53
62
|
});
|
|
54
63
|
}
|
|
@@ -67,7 +76,7 @@ export function displayQueueSummary(queue) {
|
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
export async function promptContinue() {
|
|
70
|
-
return await
|
|
79
|
+
return await customConfirm({
|
|
71
80
|
message: chalk.cyan('Would you like to start another search session?'),
|
|
72
81
|
default: true,
|
|
73
82
|
});
|