@kikkimo/claude-launcher 1.0.0 → 2.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/lib/ui/menu.js ADDED
@@ -0,0 +1,383 @@
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
+ const stdinManager = require('../utils/stdin-manager');
10
+
11
+ /**
12
+ * Force cleanup stdin state before displaying any menu
13
+ * This ensures clean state and prevents navigation issues
14
+ */
15
+ function forceCleanupBeforeMenu() {
16
+ try {
17
+ if (process.stdin.isTTY) {
18
+ // Only reset mode, don't remove listeners that might be in use
19
+ process.stdin.setRawMode(false);
20
+ // NOTE: Removed removeAllListeners to prevent conflicts with other modules
21
+ // The menu will manage its own listeners
22
+ // Only pause if not already paused
23
+ if (typeof process.stdin.isPaused !== 'function' || !process.stdin.isPaused()) {
24
+ process.stdin.pause();
25
+ }
26
+ }
27
+ } catch (error) {
28
+ // Ignore cleanup errors but log for debugging
29
+ if (process.env.DEBUG_STDIN) {
30
+ console.error('[DEBUG] forceCleanupBeforeMenu error:', error.message);
31
+ }
32
+ }
33
+ }
34
+
35
+ class Menu {
36
+ constructor() {
37
+ this.selectedIndex = 0;
38
+ this.menuOptions = [];
39
+ }
40
+
41
+ /**
42
+ * Display Claude Code style header
43
+ */
44
+ displayHeader() {
45
+ // Force cleanup stdin state before any menu display
46
+ forceCleanupBeforeMenu();
47
+
48
+ // Use console.clear and console.log for proper screen clearing
49
+ console.clear();
50
+ console.log('');
51
+ console.log(colors.orange + ' ┌──────────────────────────────────────────────────────┐' + colors.reset);
52
+ console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
53
+ console.log(colors.orange + ' └──────────────────────────────────────────────────────┘' + colors.reset);
54
+ console.log('');
55
+ console.log(colors.gray + ' ' + i18n.tSync('navigation.use_arrows') + colors.reset);
56
+ console.log('');
57
+ }
58
+
59
+ /**
60
+ * Display menu with current selection
61
+ * @param {boolean} clearScreen - Whether to clear screen before displaying (default: true)
62
+ * @param {string} versionInfo - Optional version info to display between banner and navigation
63
+ */
64
+ displayMenu(clearScreen = true, versionInfo = null) {
65
+ // Clear screen and display header + menu together (like old version)
66
+ if (clearScreen) {
67
+ console.clear();
68
+ }
69
+ console.log('');
70
+ console.log(colors.orange + ' ┌──────────────────────────────────────────────────────┐' + colors.reset);
71
+ console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
72
+ console.log(colors.orange + ' └──────────────────────────────────────────────────────┘' + colors.reset);
73
+ console.log('');
74
+
75
+ // Display version info if provided (between banner and navigation tips, like Claude Code)
76
+ if (versionInfo) {
77
+ console.log(versionInfo);
78
+ console.log('');
79
+ }
80
+
81
+ console.log(colors.gray + ' ' + i18n.tSync('navigation.use_arrows') + colors.reset);
82
+ console.log('');
83
+
84
+ // Display menu options
85
+ this.menuOptions.forEach((option, index) => {
86
+ if (index === this.selectedIndex) {
87
+ // Pad the selected option to ensure complete background coverage
88
+ const paddedOption = padStringToWidth(option, Math.max(40, option.length + 2));
89
+ console.log(colors.orange + ' → ' + colors.black + colors.bgAmber + paddedOption + colors.reset);
90
+ } else {
91
+ console.log(colors.gray + ' ' + option + colors.reset);
92
+ }
93
+ });
94
+
95
+ console.log('');
96
+ }
97
+
98
+ /**
99
+ * Set menu options
100
+ */
101
+ setOptions(options) {
102
+ this.menuOptions = options;
103
+ this.selectedIndex = 0;
104
+ }
105
+
106
+ /**
107
+ * Handle keyboard navigation
108
+ * @param {boolean} clearScreen - Whether to clear screen on initial display (default: true)
109
+ * @param {string} versionInfo - Optional version info to display
110
+ */
111
+ async navigate(clearScreen = true, versionInfo = null) {
112
+ // Guard against empty menu to prevent NaN from modulo operations
113
+ if (!this.menuOptions || this.menuOptions.length === 0) {
114
+ console.log(colors.yellow + ' Warning: No menu options available' + colors.reset);
115
+ return -1; // Return cancel/exit code
116
+ }
117
+
118
+ this.versionInfo = versionInfo; // Store for redrawing
119
+
120
+ return new Promise((resolve, reject) => {
121
+ this.displayMenu(clearScreen, versionInfo);
122
+
123
+ if (process.stdin.isTTY) {
124
+ const scope = stdinManager.acquire('raw', {
125
+ id: 'menu_navigate',
126
+ allowNested: true
127
+ });
128
+
129
+ let resolved = false;
130
+ let cleanedUp = false;
131
+
132
+ const cleanup = () => {
133
+ if (cleanedUp) return;
134
+ cleanedUp = true;
135
+ try {
136
+ scope.removeListener('data', handleKeyPress);
137
+ scope.release();
138
+ } catch (error) {
139
+ // Ignore cleanup errors to prevent masking original error
140
+ }
141
+ };
142
+
143
+ const safeResolve = (value) => {
144
+ if (resolved) return;
145
+ resolved = true;
146
+ cleanup();
147
+ resolve(value);
148
+ };
149
+
150
+ const safeReject = (error) => {
151
+ if (resolved) return;
152
+ resolved = true;
153
+ cleanup();
154
+ reject(error);
155
+ };
156
+
157
+ const handleKeyPress = (key) => {
158
+ try {
159
+ // Handle Ctrl+C first
160
+ if (key === '\u0003') {
161
+ stdinManager.handleCtrlC();
162
+ return; // Don't process further
163
+ }
164
+
165
+ // If waiting for second Ctrl+C, any other key cancels it
166
+ if (stdinManager.isCtrlCPending()) {
167
+ stdinManager.cancelCtrlC();
168
+ // Continue to process this key normally
169
+ }
170
+
171
+ switch (key) {
172
+ case '\u001b[A': // Up arrow
173
+ this.selectedIndex = (this.selectedIndex - 1 + this.menuOptions.length) % this.menuOptions.length;
174
+ this.displayMenu(true, this.versionInfo);
175
+ break;
176
+
177
+ case '\u001b[B': // Down arrow
178
+ this.selectedIndex = (this.selectedIndex + 1) % this.menuOptions.length;
179
+ this.displayMenu(true, this.versionInfo);
180
+ break;
181
+
182
+ case '\r': // Enter
183
+ safeResolve(this.selectedIndex);
184
+ break;
185
+
186
+ case '\u001b': // Escape
187
+ case 'q':
188
+ case 'Q':
189
+ safeResolve(-1);
190
+ break;
191
+
192
+ default:
193
+ break;
194
+ }
195
+ } catch (error) {
196
+ safeReject(error);
197
+ }
198
+ };
199
+
200
+ scope.on('data', handleKeyPress);
201
+ } else {
202
+ // Non-TTY fallback: use StdinManager for line-based input
203
+ const lineScope = stdinManager.acquire('line', {
204
+ id: 'menu_navigate_nonTTY',
205
+ allowNested: false
206
+ });
207
+
208
+ const rl = lineScope.createReadline();
209
+
210
+ console.log(colors.yellow + ' ' + i18n.tSync('navigation.arrow_keys_not_available', this.menuOptions.length) + colors.reset);
211
+
212
+ rl.on('line', (input) => {
213
+ const choice = parseInt(input.trim());
214
+ if (choice >= 1 && choice <= this.menuOptions.length) {
215
+ rl.close();
216
+ lineScope.release();
217
+ resolve(choice - 1);
218
+ } else if (input.toLowerCase() === 'q' || input.toLowerCase() === 'exit') {
219
+ rl.close();
220
+ lineScope.release();
221
+ resolve(-1);
222
+ } else {
223
+ console.log(colors.red + ' Invalid selection. Please enter 1-' + this.menuOptions.length + '.' + colors.reset);
224
+ }
225
+ });
226
+ }
227
+ });
228
+ }
229
+
230
+ async selectFromList(title, items, activeIndex = -1) {
231
+ // Guard against empty items list to prevent NaN from modulo operations
232
+ if (!items || items.length === 0) {
233
+ console.log(colors.yellow + ' Warning: No items available to select' + colors.reset);
234
+ return null; // Return null to indicate no selection
235
+ }
236
+
237
+ forceCleanupBeforeMenu();
238
+
239
+ let selectedIndex = activeIndex >= 0 ? activeIndex : 0;
240
+
241
+ const displayList = () => {
242
+ console.clear();
243
+ console.log('');
244
+ console.log(colors.bright + colors.orange + `[*] ${title}` + colors.reset);
245
+ console.log('');
246
+ console.log(colors.gray + ' Use ↑↓ arrow keys to navigate, Enter to select, Esc to cancel' + colors.reset);
247
+ console.log('');
248
+
249
+ items.forEach((item, index) => {
250
+ const isActive = index === activeIndex;
251
+ const prefix = index === selectedIndex ? ' → ' : ' ';
252
+ const activeIndicator = isActive ? ' (ACTIVE)' : '';
253
+
254
+ if (index === selectedIndex) {
255
+ const itemText = `${item.name}${activeIndicator}`;
256
+ const paddedItem = padStringToWidth(itemText, Math.max(40, itemText.length + 2));
257
+ console.log(colors.orange + prefix + colors.black + colors.bgAmber + paddedItem + colors.reset);
258
+ if (item.details) {
259
+ item.details.forEach(detail => {
260
+ console.log(colors.gray + ' ' + detail + colors.reset);
261
+ });
262
+ }
263
+ } else {
264
+ console.log(colors.gray + prefix + `${item.name}${activeIndicator}` + colors.reset);
265
+ }
266
+ });
267
+
268
+ console.log('');
269
+ };
270
+
271
+ return new Promise((resolve, reject) => {
272
+ displayList();
273
+
274
+ if (process.stdin.isTTY) {
275
+ const scope = stdinManager.acquire('raw', {
276
+ id: 'menu_selectFromList',
277
+ allowNested: true
278
+ });
279
+
280
+ let resolved = false;
281
+ let cleanedUp = false;
282
+
283
+ const cleanup = () => {
284
+ if (cleanedUp) return;
285
+ cleanedUp = true;
286
+ try {
287
+ scope.removeListener('data', handleKeyPress);
288
+ scope.release();
289
+ } catch (error) {
290
+ // Ignore cleanup errors to prevent masking original error
291
+ }
292
+ };
293
+
294
+ const safeResolve = (value) => {
295
+ if (resolved) return;
296
+ resolved = true;
297
+ cleanup();
298
+ resolve(value);
299
+ };
300
+
301
+ const safeReject = (error) => {
302
+ if (resolved) return;
303
+ resolved = true;
304
+ cleanup();
305
+ reject(error);
306
+ };
307
+
308
+ const handleKeyPress = (key) => {
309
+ try {
310
+ // Handle Ctrl+C first
311
+ if (key === '\u0003') {
312
+ stdinManager.handleCtrlC();
313
+ return; // Don't process further
314
+ }
315
+
316
+ // If waiting for second Ctrl+C, any other key cancels it
317
+ if (stdinManager.isCtrlCPending()) {
318
+ stdinManager.cancelCtrlC();
319
+ // Continue to process this key normally
320
+ }
321
+
322
+ switch (key) {
323
+ case '\u001b[A': // Up arrow
324
+ selectedIndex = (selectedIndex - 1 + items.length) % items.length;
325
+ displayList();
326
+ break;
327
+
328
+ case '\u001b[B': // Down arrow
329
+ selectedIndex = (selectedIndex + 1) % items.length;
330
+ displayList();
331
+ break;
332
+
333
+ case '\r': // Enter
334
+ safeResolve(selectedIndex);
335
+ break;
336
+
337
+ case '\u001b': // Escape
338
+ case 'q':
339
+ case 'Q':
340
+ safeResolve(null);
341
+ break;
342
+
343
+ default:
344
+ break;
345
+ }
346
+ } catch (error) {
347
+ safeReject(error);
348
+ }
349
+ };
350
+
351
+ scope.on('data', handleKeyPress);
352
+ } else {
353
+ // Non-TTY fallback: use line-based input
354
+ const lineScope = stdinManager.acquire('line', {
355
+ id: 'menu_selectFromList_nonTTY',
356
+ allowNested: false
357
+ });
358
+
359
+ const rl = lineScope.createReadline();
360
+
361
+ console.log(colors.yellow + ' Arrow keys not available. Enter item number (1-' + items.length + ') or q to cancel:' + colors.reset);
362
+
363
+ rl.on('line', (input) => {
364
+ const choice = parseInt(input.trim());
365
+ if (choice >= 1 && choice <= items.length) {
366
+ rl.close();
367
+ lineScope.release();
368
+ resolve(choice - 1);
369
+ } else if (input.toLowerCase() === 'q') {
370
+ rl.close();
371
+ lineScope.release();
372
+ resolve(null);
373
+ } else {
374
+ console.log(colors.red + ' Invalid selection. Please enter 1-' + items.length + '.' + colors.reset);
375
+ }
376
+ });
377
+ }
378
+ });
379
+ }
380
+
381
+ }
382
+
383
+ module.exports = Menu;