@kikkimo/claude-launcher 2.5.0 → 3.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.
@@ -3,70 +3,156 @@
3
3
  */
4
4
 
5
5
  const colors = require('./colors');
6
+ const screen = require('./screen');
6
7
  const { maskApiToken } = require('../validators');
7
8
  const { decrypt } = require('../crypto');
8
9
  const i18n = require('../i18n');
9
10
  const { padStringToWidth } = require('../utils/string-width');
10
11
  const stdinManager = require('../utils/stdin-manager');
11
12
 
13
+ /**
14
+ * Calculate pagination parameters from terminal dimensions and API count.
15
+ * Pure function — no side effects, no I/O.
16
+ */
17
+ function calculatePagination(apiCount, terminalRows, isSwitchWithActive, isLegacyOverflow = false) {
18
+ const warningLine = isLegacyOverflow ? 1 : 0;
19
+ const fixedOverhead = (isSwitchWithActive ? 16 : 10) + warningLine;
20
+ const linesPerItem = 7;
21
+ const itemsPerPage = Math.max(1, Math.floor((terminalRows - fixedOverhead) / linesPerItem));
22
+ const totalPages = Math.ceil(apiCount / itemsPerPage);
23
+ return { itemsPerPage, totalPages };
24
+ }
25
+
26
+ /**
27
+ * Initialize pagination state: current page and per-page selection memory.
28
+ * For switch mode, starts on the page containing activeIndex.
29
+ */
30
+ function initPaginationState(itemsPerPage, totalPages, activeIndex, actionType, apiCount) {
31
+ const pageSelections = new Array(totalPages).fill(0);
32
+ let currentPage = 0;
33
+
34
+ if (actionType === 'switch' && activeIndex >= 0 && activeIndex < apiCount) {
35
+ currentPage = Math.floor(activeIndex / itemsPerPage);
36
+ pageSelections[currentPage] = activeIndex - currentPage * itemsPerPage;
37
+ }
38
+
39
+ return { currentPage, pageSelections };
40
+ }
41
+
42
+ /**
43
+ * Pure state transition for page key presses.
44
+ * Returns updated state for navigation keys, or action object for enter/escape.
45
+ */
46
+ function handlePageKeyPress(key, state) {
47
+ const { currentPage, pageSelections, itemsPerPage, totalPages, apiCount } = state;
48
+ const pageItemCount = Math.min(itemsPerPage, apiCount - currentPage * itemsPerPage);
49
+ const newSelections = [...pageSelections];
50
+
51
+ switch (key) {
52
+ case 'right':
53
+ if (totalPages <= 1) return state;
54
+ return { ...state, currentPage: (currentPage + 1) % totalPages, pageSelections: newSelections };
55
+ case 'left':
56
+ if (totalPages <= 1) return state;
57
+ return { ...state, currentPage: (currentPage - 1 + totalPages) % totalPages, pageSelections: newSelections };
58
+ case 'up':
59
+ newSelections[currentPage] = (newSelections[currentPage] - 1 + pageItemCount) % pageItemCount;
60
+ return { ...state, pageSelections: newSelections };
61
+ case 'down':
62
+ newSelections[currentPage] = (newSelections[currentPage] + 1) % pageItemCount;
63
+ return { ...state, pageSelections: newSelections };
64
+ case 'enter':
65
+ return { action: 'select', globalIndex: currentPage * itemsPerPage + newSelections[currentPage] };
66
+ case 'escape':
67
+ return { action: 'cancel' };
68
+ default:
69
+ return state;
70
+ }
71
+ }
72
+
12
73
  /**
13
74
  * Display simple interactive table for API selection
14
75
  */
15
76
  async function showApiSelectionTable(apis, title, actionType = 'select', activeIndex = -1, apiManager = null) {
16
77
  if (apis.length === 0) {
17
- console.clear();
18
- console.log('');
19
- console.log(colors.yellow + 'ℹ️ ' + i18n.tSync('messages.info.no_apis_info_title') + colors.reset);
20
- console.log(colors.gray + ' ' + i18n.tSync('messages.info.apis_removed_or_none') + colors.reset);
21
- console.log('');
22
- console.log(colors.gray + i18n.tSync('messages.info.press_return_menu') + colors.reset);
78
+ screen.render([
79
+ '',
80
+ colors.yellow + 'ℹ️ ' + i18n.tSync('messages.info.no_apis_info_title') + colors.reset,
81
+ colors.gray + ' ' + i18n.tSync('messages.info.apis_removed_or_none') + colors.reset,
82
+ '',
83
+ colors.gray + i18n.tSync('ui.general.press_any_key_continue') + colors.reset,
84
+ ]);
23
85
 
24
86
  await waitForKeyPress();
25
87
  return null;
26
88
  }
27
89
 
28
- let selectedIndex = 0;
29
- if (actionType === 'switch' && activeIndex >= 0 && activeIndex < apis.length) {
30
- selectedIndex = activeIndex;
31
- }
90
+ // Legacy >99 guard: truncate display set, keep original refs
91
+ const isLegacyOverflow = apis.length > 99;
92
+ const displayApis = isLegacyOverflow ? apis.slice(0, 99) : apis;
93
+
94
+ // Recalculate activeIndex against displayApis
95
+ let effectiveActiveIndex = activeIndex;
96
+ if (isLegacyOverflow && activeIndex >= 99) effectiveActiveIndex = -1;
97
+
98
+ // Snapshot terminal height once — no resize handling during interaction
99
+ const rows = process.stdout.rows || 30;
100
+ const isSwitchWithActive = actionType === 'switch' && effectiveActiveIndex >= 0 && effectiveActiveIndex < displayApis.length;
101
+ const { itemsPerPage, totalPages } = calculatePagination(displayApis.length, rows, isSwitchWithActive, isLegacyOverflow);
102
+ let paginationState = initPaginationState(itemsPerPage, totalPages, effectiveActiveIndex, actionType, displayApis.length);
103
+ let currentPage = paginationState.currentPage;
104
+ let pageSelections = paginationState.pageSelections;
32
105
 
33
106
  function displaySimpleTable() {
107
+ const lines = [];
108
+
34
109
  // Header info
35
- console.log('');
36
- console.log(colors.cyan + title + colors.reset);
37
- console.log('');
110
+ lines.push('');
111
+ lines.push(colors.cyan + title + colors.reset);
112
+ lines.push('');
113
+
114
+ // Legacy overflow warning
115
+ if (isLegacyOverflow) {
116
+ lines.push(colors.yellow + ` ⚠ Showing first 99 of ${apis.length} APIs` + colors.reset);
117
+ }
38
118
 
39
119
  // Show current active API for switch mode
40
- if (actionType === 'switch' && activeIndex >= 0 && activeIndex < apis.length) {
41
- const activeApi = apis[activeIndex];
42
- console.log(colors.gray + i18n.tSync('ui.general.currently_active_api') + colors.reset);
43
- console.log(colors.gray + ` Name: ${activeApi.name}` + colors.reset);
44
- console.log(colors.gray + ` Provider: ${activeApi.provider}` + colors.reset);
45
- console.log(colors.gray + ` Usage Count: ${activeApi.usageCount || 0}` + colors.reset);
46
- console.log('');
120
+ if (isSwitchWithActive) {
121
+ const activeApi = displayApis[effectiveActiveIndex];
122
+ lines.push(colors.gray + i18n.tSync('ui.general.currently_active_api') + colors.reset);
123
+ lines.push(colors.gray + ` Name: ${activeApi.name}` + colors.reset);
124
+ lines.push(colors.gray + ` Provider: ${activeApi.provider}` + colors.reset);
125
+ lines.push(colors.gray + ` Usage Count: ${activeApi.usageCount || 0}` + colors.reset);
126
+ lines.push('');
47
127
  }
48
128
 
129
+ // Current page slice
130
+ const startIdx = currentPage * itemsPerPage;
131
+ const endIdx = Math.min(startIdx + itemsPerPage, displayApis.length);
132
+ const pageApis = displayApis.slice(startIdx, endIdx);
133
+
49
134
  // Table header with 3-column layout
50
- console.log(colors.bright + colors.orange +
135
+ lines.push(colors.bright + colors.orange +
51
136
  '┌────┬─────────────────────────┬────────────────────────────────────────────────────────────────────────┐' + colors.reset);
52
- console.log(colors.bright + colors.orange +
137
+ lines.push(colors.bright + colors.orange +
53
138
  '│ No.│ Name │ Detail │' + colors.reset);
54
- console.log(colors.bright + colors.orange +
139
+ lines.push(colors.bright + colors.orange +
55
140
  '├────┼─────────────────────────┼────────────────────────────────────────────────────────────────────────┤' + colors.reset);
56
141
 
57
- // Testing with multi-row display loop
58
- apis.forEach((api, index) => {
59
- const num = (index + 1).toString().padStart(2, ' ');
142
+ // Multi-row display loop — iterate page slice
143
+ pageApis.forEach((api, localIndex) => {
144
+ const globalIndex = startIdx + localIndex;
145
+ const num = (globalIndex + 1).toString().padStart(2, ' ');
60
146
 
61
- // Check if this is the currently active API
62
- const isActiveApi = activeIndex === index;
147
+ // Active marker uses global index
148
+ const isActiveApi = effectiveActiveIndex === globalIndex;
63
149
  const activeMarker = isActiveApi ? '●' : ' ';
64
150
 
65
151
  // Format name with active marker
66
152
  const nameWithMarker = `${activeMarker} ${api.name}`;
67
153
  const displayName = nameWithMarker.padEnd(23, ' ');
68
154
 
69
- // Test decrypt and maskApiToken functions
155
+ // Decrypt and mask token for display
70
156
  const decryptedToken = decrypt(api.authToken);
71
157
  const displayToken = decryptedToken.success ? maskApiToken(decryptedToken.value) : '***ERROR***';
72
158
 
@@ -83,48 +169,55 @@ async function showApiSelectionTable(apis, title, actionType = 'select', activeI
83
169
  // Pad each detail line to exactly 70 characters
84
170
  const paddedDetails = details.map(detail => padStringToWidth(detail, 70));
85
171
 
86
- // Color selection based on active state and selection
87
- const nameColor = isActiveApi ? colors.green : (index === selectedIndex ? colors.white : colors.gray);
88
- const detailColor = isActiveApi ? colors.green : (index === selectedIndex ? colors.white : colors.gray);
89
- const bgColor = index === selectedIndex ? colors.bgAmber : '';
90
- const textBg = index === selectedIndex ? colors.black : '';
172
+ // Selection highlight uses local index
173
+ const isSelected = localIndex === pageSelections[currentPage];
174
+ const nameColor = isActiveApi ? colors.green : (isSelected ? colors.white : colors.gray);
175
+ const detailColor = isActiveApi ? colors.green : (isSelected ? colors.white : colors.gray);
176
+ const bgColor = isSelected ? colors.bgAmber : '';
177
+ const textBg = isSelected ? colors.black : '';
91
178
 
92
179
  // Display 6 rows for each API, with No. and Name centered on row 3 (index 2)
93
180
  for (let i = 0; i < paddedDetails.length; i++) {
94
181
  if (i === 2) {
95
182
  // Middle row (3rd row) - show No. and Name for vertical centering
96
- console.log(colors.orange + '│' + textBg + bgColor + nameColor +
183
+ lines.push(colors.orange + '│' + textBg + bgColor + nameColor +
97
184
  ` ${num} ` + colors.reset + colors.orange + '│' + textBg + bgColor + nameColor +
98
185
  ` ${displayName} ` + colors.reset + colors.orange + '│' + textBg + bgColor + detailColor +
99
186
  ` ${paddedDetails[i]} ` + colors.reset + colors.orange + '│' + colors.reset);
100
187
  } else {
101
188
  // Other rows - empty No. and Name columns
102
- console.log(colors.orange + '│' + textBg + bgColor + colors.gray +
189
+ lines.push(colors.orange + '│' + textBg + bgColor + colors.gray +
103
190
  ' ' + colors.reset + colors.orange + '│' + textBg + bgColor + colors.gray +
104
191
  ' ' + colors.reset + colors.orange + '│' + textBg + bgColor + detailColor +
105
192
  ' ' + paddedDetails[i] + ' ' + colors.reset + colors.orange + '│' + colors.reset);
106
193
  }
107
194
  }
108
195
 
109
- // Add separator line after each API except the last one
110
- if (index < apis.length - 1) {
111
- console.log(colors.bright + colors.orange +
196
+ // Add separator line after each API except the last one on this page
197
+ if (localIndex < pageApis.length - 1) {
198
+ lines.push(colors.bright + colors.orange +
112
199
  '├────┼─────────────────────────┼────────────────────────────────────────────────────────────────────────┤' + colors.reset);
113
200
  }
114
201
  });
115
202
 
116
- console.log(colors.bright + colors.orange +
203
+ lines.push(colors.bright + colors.orange +
117
204
  '└────┴─────────────────────────┴────────────────────────────────────────────────────────────────────────┘' + colors.reset);
118
- console.log('');
205
+ lines.push('');
119
206
 
120
- if (actionType === 'switch' && activeIndex >= 0) {
121
- console.log(colors.green + ' ● = ' + i18n.tSync('ui.general.currently_active_api') + colors.reset);
207
+ if (isSwitchWithActive) {
208
+ lines.push(colors.green + ' ● = ' + i18n.tSync('ui.general.currently_active_api') + colors.reset);
122
209
  }
123
210
 
124
- // Different action prompts for different functionality
125
- const actionText = actionType === 'remove' ? 'remove' : (actionType === 'switch' ? 'switch' : 'select');
126
- console.log(colors.amber + ' ' + i18n.tSync('navigation.use_arrows_esc', actionText) + colors.reset);
127
- console.log('');
211
+ // Navigation hint pagination-aware
212
+ const actionText = i18n.tSync(`navigation.action.${actionType}`);
213
+ if (totalPages > 1) {
214
+ lines.push(colors.amber + ' ' + i18n.tSync('navigation.use_arrows_page_esc', (currentPage + 1).toString(), totalPages.toString(), actionText) + colors.reset);
215
+ } else {
216
+ lines.push(colors.amber + ' ' + i18n.tSync('navigation.use_arrows_esc', actionText) + colors.reset);
217
+ }
218
+ lines.push('');
219
+
220
+ screen.render(lines);
128
221
  }
129
222
 
130
223
  function handleKeyPress(key) {
@@ -140,33 +233,48 @@ async function showApiSelectionTable(apis, title, actionType = 'select', activeI
140
233
  // Continue to process this key normally
141
234
  }
142
235
 
236
+ // Map raw key codes to logical key names
237
+ let keyName;
143
238
  switch (key) {
144
- case '\u001b[A': // Up arrow
145
- selectedIndex = (selectedIndex - 1 + apis.length) % apis.length;
146
- console.clear(); // Force clear screen
147
- displaySimpleTable();
148
- break;
149
-
150
- case '\u001b[B': // Down arrow
151
- selectedIndex = (selectedIndex + 1) % apis.length;
152
- console.clear(); // Force clear screen
153
- displaySimpleTable();
154
- break;
155
-
156
- case '\r': // Enter
157
- return apis[selectedIndex];
158
-
159
- case '\u001b': // Escape
239
+ case '\u001b[C': keyName = 'right'; break;
240
+ case '\u001b[D': keyName = 'left'; break;
241
+ case '\u001b[A': keyName = 'up'; break;
242
+ case '\u001b[B': keyName = 'down'; break;
243
+ case '\r': keyName = 'enter'; break;
244
+ case '\u001b':
160
245
  case 'q':
161
246
  case 'Q':
162
- return null;
247
+ keyName = 'escape'; break;
248
+ default:
249
+ return undefined;
163
250
  }
251
+
252
+ const state = {
253
+ currentPage,
254
+ pageSelections,
255
+ itemsPerPage,
256
+ totalPages,
257
+ apiCount: displayApis.length,
258
+ };
259
+
260
+ const result = handlePageKeyPress(keyName, state);
261
+
262
+ if (result.action === 'select') {
263
+ return displayApis[result.globalIndex];
264
+ }
265
+ if (result.action === 'cancel') {
266
+ return null;
267
+ }
268
+
269
+ // State update — navigation key
270
+ currentPage = result.currentPage;
271
+ pageSelections = result.pageSelections;
272
+ displaySimpleTable();
164
273
  return undefined;
165
274
  }
166
275
 
167
276
  return new Promise((resolve) => {
168
277
  // Initial display
169
- console.clear();
170
278
  displaySimpleTable();
171
279
 
172
280
  if (process.stdin.isTTY) {
@@ -187,23 +295,23 @@ async function showApiSelectionTable(apis, title, actionType = 'select', activeI
187
295
  const selectedIndex = apis.findIndex(api => api.id === result.id);
188
296
  const switchedApi = apiManager.setActiveApi(selectedIndex);
189
297
 
190
- console.clear();
191
- console.log('');
192
- console.log(colors.bright + colors.green + `✓ ${i18n.tSync('messages.success.api_switched')}` + colors.reset);
193
- console.log(colors.gray + ` ${i18n.tSync('api.actions.switch_success', switchedApi.name)}` + colors.reset);
194
- console.log(colors.gray + ` ${i18n.tSync('api.details.provider')}: ${switchedApi.provider}` + colors.reset);
195
- console.log(colors.gray + ` ${i18n.tSync('api.details.url')}: ${switchedApi.baseUrl}` + colors.reset);
196
- console.log(colors.gray + ` ${i18n.tSync('api.details.model')}: ${switchedApi.model}` + colors.reset);
197
- console.log('');
198
-
199
- // Wait for user key press
200
- console.log(colors.gray + i18n.tSync('messages.prompts.press_any_key') + colors.reset);
298
+ screen.render([
299
+ '',
300
+ colors.bright + colors.green + `✓ ${i18n.tSync('messages.success.api_switched')}` + colors.reset,
301
+ colors.gray + ` ${i18n.tSync('api.actions.switch_success', switchedApi.name)}` + colors.reset,
302
+ colors.gray + ` ${i18n.tSync('api.details.provider')}: ${switchedApi.provider}` + colors.reset,
303
+ colors.gray + ` ${i18n.tSync('api.details.url')}: ${switchedApi.baseUrl}` + colors.reset,
304
+ colors.gray + ` ${i18n.tSync('api.details.model')}: ${switchedApi.model}` + colors.reset,
305
+ '',
306
+ colors.gray + i18n.tSync('messages.prompts.press_any_key') + colors.reset,
307
+ ]);
201
308
  await waitForKeyPress();
202
- } else {
203
- console.clear();
204
- console.log('');
205
- console.log(colors.green + '✓ Selection completed: ' + (result ? result.name : 'Cancelled') + colors.reset);
206
- console.log('');
309
+ } else if (actionType !== 'edit') {
310
+ screen.render([
311
+ '',
312
+ colors.green + '✓ Selection completed: ' + (result ? result.name : 'Cancelled') + colors.reset,
313
+ '',
314
+ ]);
207
315
  }
208
316
 
209
317
  resolve(result);
@@ -250,22 +358,24 @@ function waitForKeyPress() {
250
358
  }
251
359
 
252
360
  async function confirmDeletion(api) {
253
- console.clear();
254
- console.log('');
255
- console.log(colors.red + colors.bright + '[!] ' + i18n.tSync('messages.prompts.confirm_deletion') + colors.reset);
256
- console.log('');
257
- console.log(colors.yellow + i18n.tSync('ui.general.confirm_delete_api') + colors.reset);
258
- console.log('');
259
- console.log(colors.gray + `Name: ${api.name}` + colors.reset);
260
- console.log(colors.gray + `Provider: ${api.provider}` + colors.reset);
261
- console.log(colors.gray + `Base URL: ${api.baseUrl}` + colors.reset);
262
- console.log(colors.gray + `Model: ${api.model}` + colors.reset);
263
361
  const decryptedToken = decrypt(api.authToken);
264
362
  const displayToken = decryptedToken.success ? maskApiToken(decryptedToken.value) : '***ERROR***';
265
- console.log(colors.gray + `Token: ${displayToken}` + colors.reset);
266
- console.log('');
267
- console.log(colors.red + i18n.tSync('ui.general.action_cannot_undone') + colors.reset);
268
- console.log('');
363
+
364
+ screen.render([
365
+ '',
366
+ colors.red + colors.bright + '[!] ' + i18n.tSync('messages.prompts.confirm_deletion') + colors.reset,
367
+ '',
368
+ colors.yellow + i18n.tSync('ui.general.confirm_delete_api') + colors.reset,
369
+ '',
370
+ colors.gray + `Name: ${api.name}` + colors.reset,
371
+ colors.gray + `Provider: ${api.provider}` + colors.reset,
372
+ colors.gray + `Base URL: ${api.baseUrl}` + colors.reset,
373
+ colors.gray + `Model: ${api.model}` + colors.reset,
374
+ colors.gray + `Token: ${displayToken}` + colors.reset,
375
+ '',
376
+ colors.red + i18n.tSync('ui.general.action_cannot_undone') + colors.reset,
377
+ '',
378
+ ]);
269
379
 
270
380
  // Use StdinManager for proper state management
271
381
  const scope = stdinManager.acquire('line', {
@@ -277,7 +387,7 @@ async function confirmDeletion(api) {
277
387
  try {
278
388
  rl = scope.createReadline();
279
389
  } catch (error) {
280
- console.error('[ERROR] Failed to create readline interface:', error.message);
390
+ screen.debug('[ERROR] Failed to create readline interface: ' + error.message);
281
391
  scope.release();
282
392
  return false; // Default to not deleting if we can't get user confirmation
283
393
  }
@@ -314,18 +424,22 @@ async function confirmDeletion(api) {
314
424
 
315
425
  // Set a timeout to prevent infinite waiting
316
426
  timeoutId = setTimeout(() => {
317
- console.log('\n' + colors.yellow + '[!] Confirmation timeout - operation cancelled' + colors.reset);
427
+ screen.write('\n' + colors.yellow + '[!] Confirmation timeout - operation cancelled' + colors.reset + '\n');
318
428
  cleanup(false); // Timeout means no deletion
319
429
  }, 60000); // 60 second timeout
320
430
 
431
+ screen.showCursor();
432
+ screen.setReadlineActive(true);
321
433
  rl.question(colors.red + i18n.tSync('ui.general.confirm_deletion_prompt') + colors.reset, (answer) => {
434
+ screen.setReadlineActive(false);
435
+ screen.hideCursor();
322
436
  const confirmed = answer.trim().toLowerCase() === 'y';
323
437
  cleanup(confirmed);
324
438
  });
325
439
 
326
440
  // Handle readline errors
327
441
  rl.on('error', (error) => {
328
- console.error('[ERROR] Readline error:', error.message);
442
+ screen.debug('[ERROR] Readline error: ' + error.message);
329
443
  cleanup(false); // Error means no deletion
330
444
  });
331
445
  });
@@ -334,5 +448,8 @@ async function confirmDeletion(api) {
334
448
  module.exports = {
335
449
  showApiSelectionTable,
336
450
  waitForKeyPress,
337
- confirmDeletion
451
+ confirmDeletion,
452
+ calculatePagination,
453
+ initPaginationState,
454
+ handlePageKeyPress,
338
455
  };