@kikkimo/claude-launcher 1.0.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/lib/ui/menu.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Menu Module - Handles menu display and navigation
3
+ */
4
+
5
+ const readline = require('readline');
6
+ const colors = require('./colors');
7
+ const i18n = require('../i18n');
8
+ const { padStringToWidth } = require('../utils/string-width');
9
+
10
+ /**
11
+ * Force cleanup stdin state before displaying any menu
12
+ * This ensures clean state and prevents navigation issues
13
+ */
14
+ function forceCleanupBeforeMenu() {
15
+ try {
16
+ if (process.stdin.isTTY) {
17
+ process.stdin.setRawMode(false);
18
+ process.stdin.removeAllListeners('data');
19
+ process.stdin.removeAllListeners('keypress');
20
+ process.stdin.pause();
21
+ }
22
+ } catch (error) {
23
+ // Ignore cleanup errors - we just want to ensure clean state
24
+ }
25
+ }
26
+
27
+ class Menu {
28
+ constructor() {
29
+ this.selectedIndex = 0;
30
+ this.menuOptions = [];
31
+ }
32
+
33
+ /**
34
+ * Display Claude Code style header
35
+ */
36
+ displayHeader() {
37
+ // Force cleanup stdin state before any menu display
38
+ forceCleanupBeforeMenu();
39
+
40
+ // Use console.clear and console.log for proper screen clearing
41
+ console.clear();
42
+ console.log('');
43
+ console.log(colors.orange + ' ┌───────────────────────────────────────────────────────────────────────────┐' + colors.reset);
44
+ console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
45
+ console.log(colors.orange + ' └───────────────────────────────────────────────────────────────────────────┘' + colors.reset);
46
+ console.log('');
47
+ console.log(colors.gray + ' ' + i18n.tSync('navigation.use_arrows') + colors.reset);
48
+ console.log('');
49
+ }
50
+
51
+ /**
52
+ * Display menu with current selection
53
+ * @param {boolean} clearScreen - Whether to clear screen before displaying (default: true)
54
+ * @param {string} versionInfo - Optional version info to display between banner and navigation
55
+ */
56
+ displayMenu(clearScreen = true, versionInfo = null) {
57
+ // Clear screen and display header + menu together (like old version)
58
+ if (clearScreen) {
59
+ console.clear();
60
+ }
61
+ console.log('');
62
+ console.log(colors.orange + ' ┌───────────────────────────────────────────────────────────────────────────┐' + colors.reset);
63
+ console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
64
+ console.log(colors.orange + ' └───────────────────────────────────────────────────────────────────────────┘' + colors.reset);
65
+ console.log('');
66
+
67
+ // Display version info if provided (between banner and navigation tips, like Claude Code)
68
+ if (versionInfo) {
69
+ console.log(versionInfo);
70
+ console.log('');
71
+ }
72
+
73
+ console.log(colors.gray + ' ' + i18n.tSync('navigation.use_arrows') + colors.reset);
74
+ console.log('');
75
+
76
+ // Display menu options
77
+ this.menuOptions.forEach((option, index) => {
78
+ if (index === this.selectedIndex) {
79
+ // Pad the selected option to ensure complete background coverage
80
+ const paddedOption = padStringToWidth(option, Math.max(40, option.length + 2));
81
+ console.log(colors.orange + ' → ' + colors.black + colors.bgAmber + paddedOption + colors.reset);
82
+ } else {
83
+ console.log(colors.gray + ' ' + option + colors.reset);
84
+ }
85
+ });
86
+
87
+ console.log('');
88
+ }
89
+
90
+ /**
91
+ * Set menu options
92
+ */
93
+ setOptions(options) {
94
+ this.menuOptions = options;
95
+ this.selectedIndex = 0;
96
+ }
97
+
98
+ /**
99
+ * Handle keyboard navigation
100
+ * @param {boolean} clearScreen - Whether to clear screen on initial display (default: true)
101
+ * @param {string} versionInfo - Optional version info to display
102
+ */
103
+ async navigate(clearScreen = true, versionInfo = null) {
104
+ let ctrlCCount = 0;
105
+ this.versionInfo = versionInfo; // Store for redrawing
106
+
107
+ return new Promise((resolve) => {
108
+ this.displayMenu(clearScreen, versionInfo);
109
+
110
+ if (process.stdin.isTTY) {
111
+ process.stdin.setRawMode(true);
112
+ process.stdin.resume();
113
+ process.stdin.setEncoding('utf8');
114
+
115
+ const handleKeyPress = (key) => {
116
+ switch (key) {
117
+ case '\u0003': // Ctrl+C - 2-step exit
118
+ ctrlCCount++;
119
+ if (ctrlCCount === 1) {
120
+ console.log('');
121
+ console.log(colors.yellow + '⚠️ ' + i18n.tSync('messages.prompts.ctrl_c_again') + colors.reset);
122
+ setTimeout(() => {
123
+ ctrlCCount = 0; // Reset after 3 seconds
124
+ }, 3000);
125
+ } else if (ctrlCCount >= 2) {
126
+ process.stdin.removeListener('data', handleKeyPress);
127
+ if (process.stdin.isTTY) {
128
+ process.stdin.setRawMode(false);
129
+ }
130
+ console.log('');
131
+ console.log(colors.green + '👋 Goodbye!' + colors.reset);
132
+ process.exit(0);
133
+ }
134
+ break;
135
+
136
+ case '\u001b[A': // Up arrow
137
+ ctrlCCount = 0; // Reset Ctrl+C count on other keys
138
+ this.selectedIndex = (this.selectedIndex - 1 + this.menuOptions.length) % this.menuOptions.length;
139
+ this.displayMenu(true, this.versionInfo);
140
+ break;
141
+
142
+ case '\u001b[B': // Down arrow
143
+ ctrlCCount = 0; // Reset Ctrl+C count on other keys
144
+ this.selectedIndex = (this.selectedIndex + 1) % this.menuOptions.length;
145
+ this.displayMenu(true, this.versionInfo);
146
+ break;
147
+
148
+ case '\r': // Enter
149
+ process.stdin.removeListener('data', handleKeyPress);
150
+ if (process.stdin.isTTY) {
151
+ process.stdin.setRawMode(false);
152
+ }
153
+ process.stdin.pause();
154
+ resolve(this.selectedIndex);
155
+ break;
156
+
157
+ case '\u001b': // Escape
158
+ case 'q':
159
+ case 'Q':
160
+ process.stdin.removeListener('data', handleKeyPress);
161
+ if (process.stdin.isTTY) {
162
+ process.stdin.setRawMode(false);
163
+ }
164
+ process.stdin.pause();
165
+ resolve(-1);
166
+ break;
167
+
168
+ }
169
+ };
170
+
171
+ process.stdin.on('data', handleKeyPress);
172
+ } else {
173
+ // Fallback for non-TTY environments
174
+ const rl = readline.createInterface({
175
+ input: process.stdin,
176
+ output: process.stdout
177
+ });
178
+
179
+ console.log(colors.yellow + ' ' + i18n.tSync('navigation.arrow_keys_not_available', this.menuOptions.length) + colors.reset);
180
+
181
+ rl.on('line', (input) => {
182
+ const choice = parseInt(input.trim());
183
+ if (choice >= 1 && choice <= this.menuOptions.length) {
184
+ rl.close();
185
+ resolve(choice - 1);
186
+ } else if (input.toLowerCase() === 'q' || input.toLowerCase() === 'exit') {
187
+ rl.close();
188
+ resolve(-1);
189
+ } else {
190
+ console.log(colors.red + ' Invalid selection. Please enter 1-' + this.menuOptions.length + '.' + colors.reset);
191
+ }
192
+ });
193
+ }
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Display a list for selection
199
+ */
200
+ async selectFromList(title, items, activeIndex = -1) {
201
+ // Force cleanup stdin state before any list selection
202
+ forceCleanupBeforeMenu();
203
+
204
+ let selectedIndex = activeIndex >= 0 ? activeIndex : 0;
205
+ let ctrlCCount = 0;
206
+
207
+ function displayList() {
208
+ console.clear();
209
+ console.log('');
210
+ console.log(colors.bright + colors.orange + `[*] ${title}` + colors.reset);
211
+ console.log('');
212
+ console.log(colors.gray + ' Use ↑↓ arrow keys to navigate, Enter to select, Esc to cancel' + colors.reset);
213
+ console.log('');
214
+
215
+ items.forEach((item, index) => {
216
+ const isActive = index === activeIndex;
217
+ const prefix = index === selectedIndex ? ' → ' : ' ';
218
+ const activeIndicator = isActive ? ' (ACTIVE)' : '';
219
+
220
+ if (index === selectedIndex) {
221
+ // Pad the selected item to ensure complete background coverage
222
+ const itemText = `${item.name}${activeIndicator}`;
223
+ const paddedItem = padStringToWidth(itemText, Math.max(40, itemText.length + 2));
224
+ console.log(colors.orange + prefix + colors.black + colors.bgAmber + paddedItem + colors.reset);
225
+ if (item.details) {
226
+ item.details.forEach(detail => {
227
+ console.log(colors.gray + ' ' + detail + colors.reset);
228
+ });
229
+ }
230
+ } else {
231
+ console.log(colors.gray + prefix + `${item.name}${activeIndicator}` + colors.reset);
232
+ }
233
+ });
234
+
235
+ console.log('');
236
+ }
237
+
238
+ return new Promise((resolve) => {
239
+ displayList();
240
+
241
+ if (process.stdin.isTTY) {
242
+ process.stdin.setRawMode(true);
243
+ process.stdin.resume();
244
+ process.stdin.setEncoding('utf8');
245
+
246
+ const handleKeyPress = (key) => {
247
+ switch (key) {
248
+ case '\u0003': // Ctrl+C - 2-step exit
249
+ ctrlCCount++;
250
+ if (ctrlCCount === 1) {
251
+ console.log('');
252
+ console.log(colors.yellow + '⚠️ ' + i18n.tSync('messages.prompts.ctrl_c_again') + colors.reset);
253
+ setTimeout(() => {
254
+ ctrlCCount = 0; // Reset after 3 seconds
255
+ }, 3000);
256
+ } else if (ctrlCCount >= 2) {
257
+ process.stdin.removeListener('data', handleKeyPress);
258
+ if (process.stdin.isTTY) {
259
+ process.stdin.setRawMode(false);
260
+ }
261
+ console.log('');
262
+ console.log(colors.green + '👋 Goodbye!' + colors.reset);
263
+ process.exit(0);
264
+ }
265
+ break;
266
+
267
+ case '\u001b[A': // Up arrow
268
+ ctrlCCount = 0; // Reset Ctrl+C count on other keys
269
+ selectedIndex = (selectedIndex - 1 + items.length) % items.length;
270
+ displayList();
271
+ break;
272
+
273
+ case '\u001b[B': // Down arrow
274
+ ctrlCCount = 0; // Reset Ctrl+C count on other keys
275
+ selectedIndex = (selectedIndex + 1) % items.length;
276
+ displayList();
277
+ break;
278
+
279
+ case '\r': // Enter
280
+ process.stdin.removeListener('data', handleKeyPress);
281
+ if (process.stdin.isTTY) {
282
+ process.stdin.setRawMode(false);
283
+ }
284
+ process.stdin.pause();
285
+ resolve(selectedIndex);
286
+ break;
287
+
288
+ case '\u001b': // Escape
289
+ case 'q':
290
+ case 'Q':
291
+ process.stdin.removeListener('data', handleKeyPress);
292
+ if (process.stdin.isTTY) {
293
+ process.stdin.setRawMode(false);
294
+ }
295
+ process.stdin.pause();
296
+ resolve(null);
297
+ break;
298
+
299
+ default:
300
+ // Any other key resets Ctrl+C count
301
+ ctrlCCount = 0;
302
+ break;
303
+ }
304
+ };
305
+
306
+ process.stdin.on('data', handleKeyPress);
307
+ } else {
308
+ resolve(null);
309
+ }
310
+ });
311
+ }
312
+ }
313
+
314
+ module.exports = Menu;