@kikkimo/claude-launcher 3.0.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.
package/claude-launcher CHANGED
@@ -36,12 +36,14 @@ const Menu = require('./lib/ui/menu');
36
36
  const colors = require('./lib/ui/colors');
37
37
  const { checkForUpdates, forceCheckForUpdates } = require('./lib/utils/version-checker');
38
38
  const {
39
+ simpleInput,
39
40
  waitForKey,
40
41
  promptForThirdPartyApi,
41
42
  confirmAction,
42
43
  showSuccess,
43
44
  showError,
44
- showInfo
45
+ showInfo,
46
+ selectProvider,
45
47
  } = require('./lib/ui/prompts');
46
48
  const {
47
49
  launchClaudeDefault,
@@ -53,7 +55,8 @@ const { getPasswordInput } = require('./lib/auth/password-input');
53
55
  const { passwordGuard, verifyCurrentPassword, setupNewPassword, changePassword: changePasswordModule } = require('./lib/auth/password-validator');
54
56
  const { maskApiToken } = require('./lib/validators');
55
57
  const { showApiSelectionTable, confirmDeletion } = require('./lib/ui/interactive-table');
56
- const { editApi } = require('./lib/ui/api-editor');
58
+ const { editApi, editApiEnvVarsById, resolveProviderName } = require('./lib/ui/api-editor');
59
+ const { i18nLabel } = require('./lib/ui/i18n-labels');
57
60
  const i18n = require('./lib/i18n');
58
61
  const screen = require('./lib/ui/screen');
59
62
  const fs = require('fs');
@@ -221,52 +224,631 @@ function validateImportFile(filePath) {
221
224
  // Main menu options - will be populated dynamically with i18n
222
225
  let menuOptions = [];
223
226
 
227
+ // --- Add API flow helpers ---
228
+
229
+ /**
230
+ * Input helper for add-api steps. Supports Esc / "back" to go back,
231
+ * "exit"/"quit" to cancel, empty input uses defaultValue if provided.
232
+ * @param {string} promptText
233
+ * @param {Object} opts
234
+ * @param {string} opts.defaultValue - fallback when input is empty
235
+ * @param {boolean} opts.allowEmpty - if true, empty input returns ""
236
+ * @param {string} opts.stepLabel - step header e.g. "Step 2/6"
237
+ * @param {string[]} opts.hintLines - i18n keys for hint lines (default: exit + empty_restore)
238
+ * @param {Function} opts.validate - (value) => { valid, error? }
239
+ * @returns {string|null} - input value, or null to go back
240
+ */
241
+ async function inputStepValue(promptText, { defaultValue, allowEmpty, stepLabel, hintLines, validate } = {}) {
242
+ const lines = [''];
243
+ if (stepLabel) {
244
+ lines.push(colors.bright + stepLabel + colors.reset);
245
+ }
246
+ lines.push(colors.cyan + promptText + colors.reset);
247
+ lines.push('');
248
+ const hints = hintLines || ['prompt.exit_to_cancel', 'prompt.empty_to_restore'];
249
+ for (const h of hints) {
250
+ lines.push(colors.gray + i18n.tSync(h) + colors.reset);
251
+ }
252
+ lines.push('');
253
+ screen.render(lines);
254
+
255
+ while (true) {
256
+ const input = await simpleInput(colors.green + '> ' + colors.reset);
257
+ if (input.toLowerCase() === 'exit' || input.toLowerCase() === 'quit') {
258
+ const cancelledMsg = await i18n.t('errors.general.cancelled_by_user');
259
+ throw new Error(cancelledMsg);
260
+ }
261
+ if (input.toLowerCase() === 'back') {
262
+ return null;
263
+ }
264
+ if (input === '') {
265
+ if (allowEmpty) return '';
266
+ if (defaultValue !== undefined) return defaultValue;
267
+ }
268
+ if (validate) {
269
+ const result = validate(input);
270
+ if (!result.valid) {
271
+ screen.write(colors.red + (result.error || 'Invalid input') + colors.reset + '\n');
272
+ continue;
273
+ }
274
+ }
275
+ return input;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Rebuild draft after user goes back and re-enters connection info.
281
+ * Preserves manual overrides from oldDraft for model/runtime/custom sections.
282
+ */
283
+ function rebuildDraftPreservingOverrides(oldDraft, providerId, baseUrl, authToken, model, name) {
284
+ const draft = ApiManager.buildApiDraft(providerId, baseUrl, authToken, model, name);
285
+ if (!oldDraft) return draft;
286
+
287
+ // Preserve model overrides: if user set a value different from auto, keep it
288
+ const oldAuto = oldDraft._autoModelEnvVars || {};
289
+ for (const k of Object.keys(oldDraft.modelEnvVars || {})) {
290
+ if (oldDraft.modelEnvVars[k] !== oldAuto[k]) {
291
+ draft.modelEnvVars[k] = oldDraft.modelEnvVars[k];
292
+ }
293
+ }
294
+ // Preserve runtime manual overrides
295
+ const oldRtSources = oldDraft._runtimeEnvSources || {};
296
+ for (const k of Object.keys(oldRtSources)) {
297
+ if (oldRtSources[k] === 'manual') {
298
+ draft.runtimeEnvVars[k] = oldDraft.runtimeEnvVars[k];
299
+ draft._runtimeEnvSources[k] = 'manual';
300
+ }
301
+ }
302
+ // Preserve custom vars
303
+ draft.customEnvVars = { ...(oldDraft.customEnvVars || {}) };
304
+ return draft;
305
+ }
306
+
224
307
  /**
225
- * Add new third-party API
308
+ * Show duplicate API branch: enter existing config or go back.
309
+ * @returns {'enter_existing'|'go_back'}
310
+ */
311
+ async function showDuplicateBranch(existingApi) {
312
+ const menu = new Menu();
313
+ screen.render([
314
+ '', colors.yellow + i18n.tSync('add_api.duplicate_title') + colors.reset, '',
315
+ i18n.tSync('api.edit.field_name') + ': ' + existingApi.name,
316
+ i18n.tSync('api.details.provider') + ': ' + resolveProviderName(existingApi.provider),
317
+ i18n.tSync('api.details.url') + ': ' + existingApi.baseUrl,
318
+ i18n.tSync('api.details.model') + ': ' + existingApi.model,
319
+ '', colors.gray + i18n.tSync('add_api.duplicate_draft_discarded') + colors.reset, '',
320
+ i18n.tSync('action.please_choose'), '',
321
+ ]);
322
+ menu.setOptions([
323
+ i18n.tSync('add_api.duplicate_enter_config'),
324
+ i18n.tSync('add_api.duplicate_back'),
325
+ ]);
326
+ const choice = await menu.navigate();
327
+ if (choice === 0) return 'enter_existing';
328
+ return 'go_back';
329
+ }
330
+
331
+ // --- Draft editing sub-pages (no persistence, operate on draft object) ---
332
+
333
+ async function editDraftModelConfig(draft) {
334
+ const menu = new Menu();
335
+ const { PREDEFINED_MODEL_ENV_KEYS } = require('./lib/validators');
336
+ const { MODEL_CONFIG_LABELS } = require('./lib/api-manager');
337
+ const getLabel = (key) => i18nLabel("model", key, MODEL_CONFIG_LABELS);
338
+
339
+ while (true) {
340
+ const rows = PREDEFINED_MODEL_ENV_KEYS.map((key) => {
341
+ const currentVal = draft.modelEnvVars[key] || '';
342
+ const autoVal = draft._autoModelEnvVars[key] || '';
343
+ const displayVal = currentVal || autoVal || i18n.tSync('status.not_set');
344
+ const isOverridden = autoVal !== '' && currentVal !== autoVal;
345
+ const mark = isOverridden ? ' ' + colors.cyan + i18n.tSync('status.overridden') + colors.reset : '';
346
+ return { label: ' ' + getLabel(key), displayVal, mark };
347
+ });
348
+ const { getStringWidth, padStringToWidth } = require('./lib/utils/string-width');
349
+ const maxLabelW = Math.max(...rows.map(r => getStringWidth(r.label)));
350
+ const maxValueW = Math.max(...rows.map(r => getStringWidth(r.displayVal)));
351
+ const options = rows.map(r => {
352
+ return colors.reset + padStringToWidth(r.label, maxLabelW + 2) + ' ' + padStringToWidth(r.displayVal, maxValueW) + r.mark;
353
+ });
354
+ options.push(i18n.tSync('api.edit.back'));
355
+ menu.setOptions(options);
356
+ const modelHintFn = (idx) => {
357
+ if (idx >= PREDEFINED_MODEL_ENV_KEYS.length) return null;
358
+ const k = PREDEFINED_MODEL_ENV_KEYS[idx];
359
+ const dk = {
360
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'hints.model.sonnet_detail',
361
+ ANTHROPIC_DEFAULT_OPUS_MODEL: 'hints.model.opus_detail',
362
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'hints.model.haiku_detail',
363
+ CLAUDE_CODE_SUBAGENT_MODEL: 'hints.model.subagent_detail',
364
+ ANTHROPIC_CUSTOM_MODEL_OPTION: 'hints.model.custom_option_detail',
365
+ ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: 'hints.model.custom_name_detail',
366
+ };
367
+ return dk[k] ? i18n.tSync(dk[k]) : null;
368
+ };
369
+ const choice = await menu.navigate(null, modelHintFn, 'navigation.enter_to_edit');
370
+
371
+ if (choice === -1 || choice === PREDEFINED_MODEL_ENV_KEYS.length) return;
372
+
373
+ const key = PREDEFINED_MODEL_ENV_KEYS[choice];
374
+ const autoVal = draft._autoModelEnvVars[key] || '';
375
+ const label = getLabel(key);
376
+ const subMenu = new Menu();
377
+ let inSub = true;
378
+ while (inSub) {
379
+ const currentVal = draft.modelEnvVars[key] || '';
380
+ screen.render([
381
+ '', colors.cyan + label + colors.reset, '',
382
+ i18n.tSync('status.current_value') + ': ' + (currentVal || i18n.tSync('status.not_set')),
383
+ i18n.tSync('status.recommended_value') + ': ' + (autoVal || i18n.tSync('status.not_set')),
384
+ '', i18n.tSync('action.please_choose'), '',
385
+ ]);
386
+ subMenu.setOptions([
387
+ i18n.tSync('action.follow_recommended'),
388
+ i18n.tSync('action.custom_input'),
389
+ i18n.tSync('api.edit.back'),
390
+ ]);
391
+ const subChoice = await subMenu.navigate(null, null);
392
+ if (subChoice === -1 || subChoice === 2) { inSub = false; break; }
393
+ if (subChoice === 0) {
394
+ draft.modelEnvVars[key] = autoVal;
395
+ inSub = false;
396
+ } else if (subChoice === 1) {
397
+ const input = await inputStepValue(
398
+ i18n.tSync('api.edit.current_value', currentVal),
399
+ { allowEmpty: true }
400
+ );
401
+ if (input === null) continue; // "back"
402
+ draft.modelEnvVars[key] = (input === '') ? autoVal : input;
403
+ inSub = false;
404
+ }
405
+ }
406
+ }
407
+ }
408
+
409
+ async function editDraftRuntimeConfig(draft) {
410
+ const menu = new Menu();
411
+ const { PREDEFINED_RUNTIME_KEYS, TYPE_A_FIELDS, TYPE_B_FIELDS } = require('./lib/validators');
412
+ const { RUNTIME_CONFIG_LABELS } = require('./lib/api-manager');
413
+ const { getProvider } = require('./lib/presets/providers');
414
+ const getLabel = (key) => i18nLabel("runtime", key, RUNTIME_CONFIG_LABELS);
415
+ const providerConfig = getProvider(draft.provider);
416
+ const providerDefaults = providerConfig ? providerConfig.envVars || {} : {};
417
+
418
+ while (true) {
419
+ const runtimeKeys = [...PREDEFINED_RUNTIME_KEYS];
420
+ const rows = runtimeKeys.map(key => {
421
+ const val = draft.runtimeEnvVars[key] || '';
422
+ const providerVal = providerDefaults[key];
423
+ let display;
424
+ if (val === '') {
425
+ if (providerVal !== undefined) {
426
+ if (TYPE_A_FIELDS.includes(key) && providerVal === '1') {
427
+ display = i18n.tSync('status.enabled');
428
+ } else {
429
+ display = providerVal;
430
+ }
431
+ } else {
432
+ display = i18n.tSync('status.auto');
433
+ }
434
+ } else if (TYPE_A_FIELDS.includes(key) && val === 'off') {
435
+ display = i18n.tSync('status.disabled');
436
+ } else if (TYPE_A_FIELDS.includes(key) && val === '1') {
437
+ display = i18n.tSync('status.enabled');
438
+ } else {
439
+ display = val;
440
+ }
441
+ const isManual = (draft._runtimeEnvSources || {})[key] === 'manual';
442
+ const mark = isManual ? ' ' + colors.cyan + i18n.tSync('status.overridden') + colors.reset : '';
443
+ return { label: ' ' + getLabel(key), display, mark };
444
+ });
445
+ const { getStringWidth, padStringToWidth } = require('./lib/utils/string-width');
446
+ const maxLabelW = Math.max(...rows.map(r => getStringWidth(r.label)));
447
+ const maxValueW = Math.max(...rows.map(r => getStringWidth(r.display)));
448
+ const options = rows.map(r => {
449
+ return colors.reset + padStringToWidth(r.label, maxLabelW + 2) + ' ' + padStringToWidth(r.display, maxValueW) + r.mark;
450
+ });
451
+ options.push(i18n.tSync('api.edit.back'));
452
+ menu.setOptions(options);
453
+ const runtimeHintFn = (idx) => {
454
+ if (idx >= runtimeKeys.length) return null;
455
+ const k = runtimeKeys[idx];
456
+ const dk = {
457
+ API_TIMEOUT_MS: 'hints.runtime.timeout_detail',
458
+ CLAUDE_CODE_ATTRIBUTION_HEADER: 'hints.runtime.attribution_detail',
459
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 'hints.runtime.nonessential_detail',
460
+ CLAUDE_CODE_EFFORT_LEVEL: 'hints.runtime.effort_detail',
461
+ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: 'hints.runtime.experimental_detail',
462
+ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: 'hints.runtime.nonstreaming_detail',
463
+ };
464
+ if (!dk[k]) return null;
465
+ const val = draft.runtimeEnvVars[k] || '';
466
+ const isManual = (draft._runtimeEnvSources || {})[k] === 'manual';
467
+ let sourceText;
468
+ if (isManual && val !== '') {
469
+ sourceText = i18n.tSync('hints.runtime.source_manual');
470
+ } else if (providerDefaults[k] !== undefined) {
471
+ sourceText = i18n.tSync('hints.runtime.source_provider');
472
+ } else {
473
+ sourceText = i18n.tSync('hints.runtime.source_default');
474
+ }
475
+ return i18n.tSync(dk[k]) + '\n' + sourceText;
476
+ };
477
+ const choice = await menu.navigate(null, runtimeHintFn, 'navigation.enter_to_edit');
478
+
479
+ if (choice === -1 || choice === runtimeKeys.length) return;
480
+
481
+ const key = runtimeKeys[choice];
482
+ if (TYPE_A_FIELDS.includes(key) || TYPE_B_FIELDS.includes(key)) {
483
+ const values = TYPE_A_FIELDS.includes(key) ? ['', '1', 'off'] : ['', '1', '0'];
484
+ try {
485
+ const v = draft.runtimeEnvVars[key] || '';
486
+ const idx = values.indexOf(v);
487
+ const next = values[(idx + 1) % values.length];
488
+ ApiManager.applyDraftEnvChange(draft, 'runtime', key, next);
489
+ } catch (e) {
490
+ screen.write(colors.red + e.message + colors.reset + '\n');
491
+ await waitForKey(i18n.tSync('ui.general.press_any_key_continue'));
492
+ }
493
+ } else {
494
+ const isEffortLevel = (key === 'CLAUDE_CODE_EFFORT_LEVEL');
495
+ const extraHints = isEffortLevel ? ['hints.runtime.effort_values'] : [];
496
+ const draftVal = draft.runtimeEnvVars[key] || '';
497
+ const draftProvVal = providerDefaults[key];
498
+ const draftDisplayVal = draftVal || draftProvVal || i18n.tSync('status.auto');
499
+ const input = await inputStepValue(
500
+ i18n.tSync('api.edit.current_value', draftDisplayVal),
501
+ { allowEmpty: true, hintLines: ['prompt.exit_to_cancel', ...extraHints] }
502
+ );
503
+ if (input === null) continue; // "back"
504
+ try {
505
+ ApiManager.applyDraftEnvChange(draft, 'runtime', key, input === '' ? '' : input);
506
+ } catch (e) {
507
+ screen.write(colors.red + e.message + colors.reset + '\n');
508
+ await waitForKey(i18n.tSync('ui.general.press_any_key_continue'));
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ async function editDraftCustomVars(draft) {
515
+ const menu = new Menu();
516
+ while (true) {
517
+ const keys = Object.keys(draft.customEnvVars || {});
518
+ const options = keys.length > 0
519
+ ? keys.map(k => ' ' + k + ' = ' + draft.customEnvVars[k])
520
+ : [colors.gray + i18n.tSync('api.edit.no_custom_vars') + colors.reset];
521
+ options.push(i18n.tSync('action.add_variable'));
522
+ options.push(i18n.tSync('api.edit.back'));
523
+ menu.setOptions(options);
524
+ const choice = await menu.navigate(null, null, 'navigation.enter_to_edit');
525
+
526
+ if (choice === -1 || choice === options.length - 1) return;
527
+ if (choice === options.length - 2) {
528
+ const keyInput = await inputStepValue(i18n.tSync('api.edit.enter_custom_key'), { allowEmpty: false });
529
+ if (!keyInput || keyInput === null) continue;
530
+ const valInput = await inputStepValue(i18n.tSync('api.edit.enter_custom_value'), { allowEmpty: true });
531
+ if (valInput === null) continue;
532
+ try {
533
+ ApiManager.applyDraftEnvChange(draft, 'custom', keyInput, valInput || '');
534
+ } catch (e) {
535
+ screen.write(colors.red + e.message + colors.reset + '\n');
536
+ await waitForKey(i18n.tSync('ui.general.press_any_key_continue'));
537
+ }
538
+ } else if (choice < keys.length) {
539
+ const key = keys[choice];
540
+ screen.render(['', colors.yellow + i18n.tSync('confirm.delete_variable') + colors.reset]);
541
+ const confirm = await simpleInput('> ');
542
+ if (confirm.toLowerCase() === 'y') {
543
+ ApiManager.deleteDraftCustomEnvVar(draft, key);
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ async function editDraftSection(draft, section) {
550
+ if (section === 'modify_model') await editDraftModelConfig(draft);
551
+ else if (section === 'modify_runtime') await editDraftRuntimeConfig(draft);
552
+ else if (section === 'modify_custom') await editDraftCustomVars(draft);
553
+ }
554
+
555
+ /**
556
+ * Show config confirmation page (step 6).
557
+ * @returns {'finish'|'back'|'modify_model'|'modify_runtime'|'modify_custom'}
558
+ */
559
+ async function showConfigConfirmation(draft) {
560
+ const menu = new Menu();
561
+ const { PREDEFINED_MODEL_ENV_KEYS, PREDEFINED_RUNTIME_KEYS } = require('./lib/validators');
562
+ const modelOverridden = PREDEFINED_MODEL_ENV_KEYS.filter(k => {
563
+ const v = draft.modelEnvVars[k] || '';
564
+ const a = draft._autoModelEnvVars[k];
565
+ return a !== undefined && v !== a;
566
+ }).length;
567
+ const runtimeOverridden = PREDEFINED_RUNTIME_KEYS.filter(k => {
568
+ const val = (draft.runtimeEnvVars || {})[k] || '';
569
+ return val !== '';
570
+ }).length;
571
+ const customCount = Object.keys(draft.customEnvVars).length;
572
+
573
+ const { getStringWidth, padStringToWidth } = require('./lib/utils/string-width');
574
+ const fieldLabels = [
575
+ i18n.tSync('api.edit.field_name'), i18n.tSync('api.details.provider'),
576
+ i18n.tSync('api.details.url'), i18n.tSync('api.details.model'),
577
+ ];
578
+ const labelW = Math.max(...fieldLabels.map(l => getStringWidth(l)));
579
+ const fieldValues = [
580
+ draft.name || i18n.tSync('status.not_set'), resolveProviderName(draft.provider),
581
+ draft.baseUrl, draft.model,
582
+ ];
583
+ function fieldLine(label, value) {
584
+ return ' ' + colors.gray + padStringToWidth(label, labelW) + colors.reset + ' ' + colors.cyan + value + colors.reset + '\n';
585
+ }
586
+ const overriddenMark = ' ' + colors.cyan + i18n.tSync('status.overridden') + colors.reset;
587
+ const versionInfo = colors.bright + colors.cyan + i18n.tSync('add_api.step_n_of_m', '6', '6') + ' · ' + i18n.tSync('add_api.confirm_config') + colors.reset + '\n'
588
+ + '\n'
589
+ + fieldLine(fieldLabels[0], fieldValues[0])
590
+ + fieldLine(fieldLabels[1], fieldValues[1])
591
+ + fieldLine(fieldLabels[2], fieldValues[2])
592
+ + fieldLine(fieldLabels[3], fieldValues[3])
593
+ + '\n'
594
+ + ' ' + colors.gray + i18n.tSync('add_api.confirm_page_prompt') + colors.reset + '\n'
595
+ + '\n'
596
+ + ' ' + i18n.tSync('page.model_config') + ' ' + colors.cyan + modelOverridden + '/' + PREDEFINED_MODEL_ENV_KEYS.length + colors.reset + ' ' + overriddenMark + '\n'
597
+ + ' ' + i18n.tSync('page.runtime_config') + ' ' + colors.cyan + runtimeOverridden + '/' + PREDEFINED_RUNTIME_KEYS.length + colors.reset + ' ' + overriddenMark + '\n'
598
+ + ' ' + i18n.tSync('page.custom_vars') + ' ' + colors.cyan + i18n.tSync('summary.x_items', String(customCount)) + colors.reset;
599
+
600
+ menu.setOptions([
601
+ i18n.tSync('action.finish_create'),
602
+ i18n.tSync('page.model_config'),
603
+ i18n.tSync('page.runtime_config'),
604
+ i18n.tSync('page.custom_vars'),
605
+ i18n.tSync('action.cancel_config'),
606
+ ]);
607
+ const hintFn = (idx) => {
608
+ if (idx === 0) return i18n.tSync('add_api.finish_hint');
609
+ if (idx === 1) return i18n.tSync('hints.model.desc');
610
+ if (idx === 2) return i18n.tSync('hints.runtime.desc');
611
+ if (idx === 3) return i18n.tSync('hints.custom.desc');
612
+ return null;
613
+ };
614
+ const choice = await menu.navigate(versionInfo, hintFn);
615
+ if (choice === -1 || choice === 4) return 'cancel';
616
+ if (choice === 0) return 'finish';
617
+ if (choice === 1) return 'modify_model';
618
+ if (choice === 2) return 'modify_runtime';
619
+ if (choice === 3) return 'modify_custom';
620
+ }
621
+
622
+ /**
623
+ * Replay draft edits onto a newly created API.
624
+ * @returns {{ ok: string[], failed: string[] }}
625
+ */
626
+ function replayDraftToApi(apiManager, draft, newApi) {
627
+ const ok = [];
628
+ const failed = [];
629
+
630
+ // Model env vars: only write overridden values
631
+ for (const k of Object.keys(draft.modelEnvVars || {})) {
632
+ const v = draft.modelEnvVars[k];
633
+ const autoV = draft._autoModelEnvVars[k] || '';
634
+ if (v !== autoV) {
635
+ try {
636
+ apiManager.updateModelEnvVar(newApi.id, k, v);
637
+ ok.push('model/' + k);
638
+ } catch (e) {
639
+ failed.push('model/' + k + ': ' + e.message);
640
+ }
641
+ }
642
+ }
643
+ // Runtime env vars: only write manual values
644
+ for (const k of Object.keys(draft.runtimeEnvVars || {})) {
645
+ if ((draft._runtimeEnvSources || {})[k] === 'manual') {
646
+ try {
647
+ apiManager.updateRuntimeEnvVar(newApi.id, k, draft.runtimeEnvVars[k]);
648
+ ok.push('runtime/' + k);
649
+ } catch (e) {
650
+ failed.push('runtime/' + k + ': ' + e.message);
651
+ }
652
+ }
653
+ }
654
+ // Custom env vars
655
+ for (const [k, v] of Object.entries(draft.customEnvVars || {})) {
656
+ try {
657
+ apiManager.setCustomEnvVar(newApi.id, k, v);
658
+ ok.push('custom/' + k);
659
+ } catch (e) {
660
+ failed.push('custom/' + k + ': ' + e.message);
661
+ }
662
+ }
663
+ return { ok, failed };
664
+ }
665
+
666
+ /**
667
+ * Add new third-party API — 6-step wizard with state machine.
668
+ * Step 1: Provider → 2: Base URL → 3: Auth Token → 4: Model → 5: Name → 6: Confirm
226
669
  */
227
670
  async function addNewThirdPartyApi() {
228
- try {
229
- const apiData = await promptForThirdPartyApi();
230
-
231
- // Check if this is the first API
232
- const isFirstApi = apiManager.getApis().length === 0;
233
- const hasExportPassword = apiManager.hasExportPassword();
234
-
235
- const newApi = apiManager.addApi(
236
- apiData.baseUrl,
237
- apiData.authToken,
238
- apiData.model,
239
- apiData.name,
240
- apiData.provider
241
- );
671
+ const { getProvider, getSuggestedModels } = require('./lib/presets/providers');
672
+ let step = 1;
673
+ let providerId = null, providerMeta = null;
674
+ let baseUrl = null, authToken = null, model = null, name = '';
675
+ let draft = null;
242
676
 
243
- showSuccess(await i18n.t('messages.success.api_added'), [
244
- `Name: ${newApi.name}`,
245
- `${await i18n.t('api.details.provider')}: ${newApi.provider}`,
246
- `${await i18n.t('api.details.url')}: ${newApi.baseUrl}`,
247
- `${await i18n.t('api.details.model')}: ${newApi.model}`
248
- ]);
677
+ try {
678
+ while (step >= 1 && step <= 6) {
679
+ switch (step) {
680
+ case 1: { // Provider
681
+ const result = await selectProvider({
682
+ title: i18n.tSync('add_api.step_n_of_m', '1', '6'),
683
+ showNote: true,
684
+ });
685
+ if (!result) { step = 0; break; } // Esc = cancel
686
+ providerId = result.id;
687
+ providerMeta = getProvider(providerId);
688
+ baseUrl = null; authToken = null; model = null; name = '';
689
+ step = 2;
690
+ break;
691
+ }
692
+ case 2: { // Base URL
693
+ const defaultUrl = providerMeta ? providerMeta.baseUrl || '' : '';
694
+ const input = await inputStepValue(
695
+ i18n.tSync('api.edit.field_base_url') + (defaultUrl ? ' [' + defaultUrl + ']' : ''),
696
+ { defaultValue: defaultUrl, stepLabel: i18n.tSync('add_api.step_n_of_m', '2', '6') }
697
+ );
698
+ if (input === null) { step = 1; break; }
699
+ baseUrl = input;
700
+ step = 3;
701
+ break;
702
+ }
703
+ case 3: { // Auth Token
704
+ const input = await inputStepValue(
705
+ i18n.tSync('api.details.token'),
706
+ {
707
+ stepLabel: i18n.tSync('add_api.step_n_of_m', '3', '6'),
708
+ hintLines: ['prompt.exit_to_cancel'],
709
+ validate: (v) => {
710
+ if (!v || v.trim().length < 10) {
711
+ return { valid: false, error: i18n.tSync('errors.api.invalid_token', 'at least 10 characters required') };
712
+ }
713
+ return { valid: true };
714
+ },
715
+ }
716
+ );
717
+ if (input === null) { step = 2; break; }
718
+ authToken = input;
719
+ step = 4;
720
+ break;
721
+ }
722
+ case 4: { // Model
723
+ const suggested = getSuggestedModels(providerId);
724
+ const hintLines = suggested.length > 0
725
+ ? ['', colors.gray + ' ' + i18n.tSync('add_api.recommended_models') + ': ' + suggested.join(', ') + colors.reset, '']
726
+ : ['', ''];
727
+ const fullPrompt = i18n.tSync('api.details.model');
728
+ const lines = [''];
729
+ lines.push(colors.bright + i18n.tSync('add_api.step_n_of_m', '4', '6') + colors.reset);
730
+ lines.push(colors.cyan + fullPrompt + colors.reset);
731
+ lines.push(...hintLines);
732
+ lines.push(colors.gray + i18n.tSync('prompt.exit_to_cancel') + colors.reset);
733
+ lines.push(colors.gray + i18n.tSync('prompt.empty_to_restore') + colors.reset);
734
+ lines.push('');
735
+ screen.render(lines);
736
+ const input = await simpleInput(colors.green + '> ' + colors.reset);
737
+ if (input.toLowerCase() === 'exit' || input.toLowerCase() === 'quit') {
738
+ throw new Error(await i18n.t('errors.general.cancelled_by_user'));
739
+ }
740
+ if (input.toLowerCase() === 'back') { step = 3; break; }
741
+ if (input === '' && suggested.length > 0) {
742
+ model = suggested[0];
743
+ } else {
744
+ model = input;
745
+ }
249
746
 
250
- if (isFirstApi) {
251
- showInfo(await i18n.t('messages.info.first_time_usage'));
747
+ // Duplicate check
748
+ const dup = apiManager.checkDuplicate(baseUrl, authToken, model);
749
+ if (dup.isDuplicate) {
750
+ const action = await showDuplicateBranch(dup.existing);
751
+ if (action === 'enter_existing') {
752
+ await editApiEnvVarsById(apiManager, { apiId: dup.existing.id });
753
+ return; // done
754
+ }
755
+ step = 2; break; // go back to modify connection info
756
+ }
757
+ step = 5;
758
+ break;
759
+ }
760
+ case 5: { // Name
761
+ const fullName = resolveProviderName(providerId);
762
+ const providerShortName = fullName.split(' (')[0].split(' -')[0];
763
+ // Count APIs sharing the same short-name prefix (handles moonshot / kimi_for_coding)
764
+ const sameNameCount = apiManager.config.apis.filter(a => {
765
+ const aFull = resolveProviderName(a.provider);
766
+ return aFull.split(' (')[0].split(' -')[0] === providerShortName;
767
+ }).length;
768
+ const autoName = providerShortName + ' #' + (sameNameCount + 1);
769
+ const input = await inputStepValue(
770
+ i18n.tSync('api.edit.field_name'),
771
+ { defaultValue: autoName, stepLabel: i18n.tSync('add_api.step_n_of_m', '5', '6') }
772
+ );
773
+ if (input === null) { step = 4; break; }
774
+ name = input;
775
+ // Build/rebuild draft preserving manual edits if returning from step 6
776
+ draft = rebuildDraftPreservingOverrides(draft, providerId, baseUrl, authToken, model, name);
777
+ step = 6;
778
+ break;
779
+ }
780
+ case 6: { // Config confirmation
781
+ const action = await showConfigConfirmation(draft);
782
+ if (action === 'finish') {
783
+ try {
784
+ const newApi = apiManager.addApi(baseUrl, authToken, model, name, providerId);
785
+ const replayResult = replayDraftToApi(apiManager, draft, newApi);
786
+ if (replayResult.failed.length > 0) {
787
+ screen.write(colors.yellow + i18n.tSync('add_api.partial_failure') + colors.reset + '\n');
788
+ await waitForKey(i18n.tSync('ui.general.press_any_key_continue'));
789
+ await editApiEnvVarsById(apiManager, { apiId: newApi.id });
790
+ } else {
791
+ const isFirstApi = apiManager.getApis().length === 1;
792
+ showSuccess(await i18n.t('messages.success.api_added'), [
793
+ `Name: ${newApi.name}`,
794
+ `${await i18n.t('api.details.provider')}: ${resolveProviderName(newApi.provider)}`,
795
+ `${await i18n.t('api.details.url')}: ${newApi.baseUrl}`,
796
+ `${await i18n.t('api.details.model')}: ${newApi.model}`
797
+ ]);
798
+ if (isFirstApi) {
799
+ showInfo(await i18n.t('messages.info.first_time_usage'));
800
+ }
801
+ }
802
+ } catch (error) {
803
+ forceStdinCleanup();
804
+ if (error.code === 'DUPLICATE_API') {
805
+ const existing = apiManager.config.apis.find(a => a.id === error.existingApiId);
806
+ if (existing) {
807
+ screen.write(colors.yellow + i18n.tSync('add_api.duplicate_race_lost') + colors.reset + '\n');
808
+ const action = await showDuplicateBranch(existing);
809
+ if (action === 'enter_existing') {
810
+ await editApiEnvVarsById(apiManager, { apiId: existing.id });
811
+ }
812
+ return;
813
+ }
814
+ }
815
+ const cancelledMessage = await i18n.t('errors.general.cancelled_by_user');
816
+ if (error.message === cancelledMessage) {
817
+ screen.write(colors.yellow + await i18n.t('messages.info.operation_cancelled') + colors.reset + '\n');
818
+ } else {
819
+ showError(await i18n.t('errors.api.failed_add', error.message));
820
+ }
821
+ }
822
+ return; // Exit loop on all paths
823
+ } else if (action === 'cancel') {
824
+ return; // back to main menu
825
+ } else {
826
+ // modify_model, modify_runtime, modify_custom
827
+ await editDraftSection(draft, action);
828
+ // Loop back to step 6 (confirmation re-renders)
829
+ }
830
+ break;
831
+ }
252
832
  }
833
+ }
253
834
 
835
+ if (step === 0) {
836
+ screen.write(colors.yellow + i18n.tSync('messages.info.operation_cancelled') + colors.reset + '\n');
837
+ }
254
838
  } catch (error) {
255
- // Force cleanup stdin state to prevent navigation issues
256
839
  forceStdinCleanup();
257
-
258
- // Check if user cancelled the operation
259
- const cancelledMessage = await i18n.t('errors.general.cancelled_by_user');
260
- if (error.message === cancelledMessage) {
261
- // User cancelled - show neutral message instead of error
262
- screen.write(colors.yellow + await i18n.t('messages.info.operation_cancelled') + colors.reset + '\n');
840
+ const cancelledMsg = i18n.tSync('errors.general.cancelled_by_user');
841
+ if (error.message === cancelledMsg) {
842
+ screen.write(colors.yellow + i18n.tSync('messages.info.operation_cancelled') + colors.reset + '\n');
843
+ } else if (error.code === 'DUPLICATE_API') {
844
+ const existing = apiManager.config.apis.find(a => a.id === error.existingApiId);
845
+ if (existing) {
846
+ screen.write(colors.yellow + i18n.tSync('add_api.duplicate_race_lost') + colors.reset + '\n');
847
+ }
263
848
  } else {
264
- // Actual error occurred
265
- showError(await i18n.t('errors.api.failed_add', error.message));
849
+ screen.write(colors.red + error.message + colors.reset + '\n');
266
850
  }
267
851
  }
268
-
269
- await waitForKey(await i18n.t('messages.prompts.press_any_key'));
270
852
  }
271
853
 
272
854
  /**
@@ -1090,6 +1672,9 @@ async function showConfigManagement(restoreIndex = 0) {
1090
1672
  const launchModeVal = config.apiLaunchMode === 'select'
1091
1673
  ? i18n.tSync('config.values.select_mode')
1092
1674
  : i18n.tSync('config.values.direct_mode');
1675
+ const noFlickerVal = config.noFlicker !== false
1676
+ ? i18n.tSync('config.values.recommended_on')
1677
+ : i18n.tSync('config.values.off');
1093
1678
 
1094
1679
  const menuOptions = [
1095
1680
  padStringToWidth(i18n.tSync('menu.config.language'), labelWidth) + langName,
@@ -1097,6 +1682,7 @@ async function showConfigManagement(restoreIndex = 0) {
1097
1682
  padStringToWidth(i18n.tSync('menu.config.model_upgrade_notification'), labelWidth) + upgradeNotifVal,
1098
1683
  padStringToWidth(i18n.tSync('menu.config.telemetry'), labelWidth) + telemetryVal,
1099
1684
  padStringToWidth(i18n.tSync('menu.config.api_launch_mode'), labelWidth) + launchModeVal,
1685
+ padStringToWidth(i18n.tSync('menu.config.no_flicker'), labelWidth) + noFlickerVal,
1100
1686
  i18n.tSync('menu.config.back')
1101
1687
  ];
1102
1688
 
@@ -1108,6 +1694,7 @@ async function showConfigManagement(restoreIndex = 0) {
1108
1694
  case 2: return i18n.tSync('hints.config.upgrade_notification');
1109
1695
  case 3: return i18n.tSync('hints.config.telemetry');
1110
1696
  case 4: return i18n.tSync('hints.config.launch_mode');
1697
+ case 5: return i18n.tSync('hints.config.no_flicker');
1111
1698
  default: return null;
1112
1699
  }
1113
1700
  };
@@ -1155,7 +1742,13 @@ async function showConfigManagement(restoreIndex = 0) {
1155
1742
  return showConfigManagement(4);
1156
1743
  }
1157
1744
 
1158
- case 5: // Back
1745
+ case 5: { // noFlicker toggle
1746
+ config.noFlicker = !config.noFlicker;
1747
+ await versionChecker.saveConfig(config);
1748
+ return showConfigManagement(5);
1749
+ }
1750
+
1751
+ case 6: // Back
1159
1752
  case -1:
1160
1753
  default:
1161
1754
  return showMenu();
@@ -1334,7 +1927,10 @@ async function showMenu() {
1334
1927
  const cfgTelemetry = hintConfig.disableTelemetry !== false
1335
1928
  ? i18n.tSync('config.values.off')
1336
1929
  : i18n.tSync('config.values.on');
1337
- const hintConfigSummary = i18n.tSync('hints.config_summary', cfgLangName, cfgLaunchMode, cfgTelemetry);
1930
+ const cfgNoFlicker = hintConfig.noFlicker !== false
1931
+ ? i18n.tSync('config.values.on')
1932
+ : i18n.tSync('config.values.off');
1933
+ const hintConfigSummary = i18n.tSync('hints.config_summary', cfgLangName, cfgLaunchMode, cfgTelemetry, cfgNoFlicker);
1338
1934
 
1339
1935
  // Synchronous hint callback — must not use await
1340
1936
  const hintCallback = (selectedIndex) => {