@parallel-cli/parallel 0.4.0 → 0.4.1

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 CHANGED
@@ -34,7 +34,7 @@ The philosophy is simple: agents can move in parallel, but the human keeps the c
34
34
  - Avoid silent overwrites with adaptive merge-on-write for file edits.
35
35
  - Pause, resume, stop, focus, restore, and steer agents while they are running.
36
36
  - Review live diffs, notes, costs, sessions, skills, and specialists from built-in views.
37
- - Use any OpenAI-compatible provider, including DeepSeek, OpenRouter, Ollama, vLLM, LM Studio, and local servers.
37
+ - Use any OpenAI-compatible provider 18 pre-configured cloud providers (DeepSeek, xAI/Grok, Perplexity, Cohere, DeepInfra, Fireworks, Cerebras, Novita, Hyperbolic, SambaNova, and more), plus Ollama for local models, and any custom OpenAI-compatible endpoint.
38
38
  - Choose shell approval behavior: `ask`, `auto-safe`, or `yolo`.
39
39
  - Track token usage and estimated cost per agent and per session.
40
40
 
@@ -281,7 +281,9 @@ When there is exactly one agent, commands such as `/undo`, `/focus`, `/pause`, `
281
281
 
282
282
  ## Providers
283
283
 
284
- Parallel uses OpenAI-compatible chat completions with tool calling. The built-in DeepSeek preset works out of the box once an API key is configured.
284
+ Parallel ships with **18 pre-configured cloud providers** with verified endpoints and curated model lists, plus **Ollama** for local models with automatic model detection. All providers use OpenAI-compatible chat completions with tool calling. You can also add any custom OpenAI-compatible endpoint.
285
+
286
+ The built-in DeepSeek preset works out of the box once an API key is configured. Additional providers like xAI/Grok, Perplexity, Cohere, DeepInfra, Fireworks, Cerebras, Novita, Hyperbolic, and SambaNova are available for selection during setup or from the Providers settings submenu.
285
287
 
286
288
  Environment variables:
287
289
 
@@ -346,6 +348,15 @@ The runtime is intentionally small:
346
348
 
347
349
  ## Changelog
348
350
 
351
+ ### 0.4.1
352
+
353
+ - **18 pre-configured cloud providers** with verified endpoints and curated model lists (up from 8). Added: xAI/Grok, Perplexity, Cohere, DeepInfra, Fireworks, Cerebras, Novita, Hyperbolic, SambaNova.
354
+ - **Ollama (local models)** as a first-class preset with automatic connectivity check and model detection.
355
+ - **Wizard redesign** — provider selection now grouped by category (Configured, Cloud, Local, Custom) instead of a flat list.
356
+ - **Settings reorganization** — all provider actions (keys, models, pricing, add/remove) consolidated under a "Providers" submenu; settings root reduced from 13 to 8 items.
357
+ - **Provider removal** — can now remove configured providers from settings.
358
+ - Custom provider option always available for any OpenAI-compatible endpoint.
359
+
349
360
  ### 0.4.0
350
361
 
351
362
  - **Removed `/spawn`** — use `/task` instead. The alias was redundant and its removal simplified the command registry.
package/dist/config.js CHANGED
@@ -69,6 +69,76 @@ export const PROVIDER_PRESETS = [
69
69
  models: ['openai/gpt-oss-120b', 'openai/gpt-oss-20b'],
70
70
  defaultModel: 'openai/gpt-oss-120b',
71
71
  },
72
+ {
73
+ name: 'xAI',
74
+ baseUrl: 'https://api.x.ai/v1',
75
+ apiKey: '',
76
+ models: ['grok-4', 'grok-3-beta', 'grok-3-mini'],
77
+ defaultModel: 'grok-3-beta',
78
+ },
79
+ {
80
+ name: 'Perplexity',
81
+ baseUrl: 'https://api.perplexity.ai',
82
+ apiKey: '',
83
+ models: ['sonar-pro', 'sonar', 'sonar-reasoning'],
84
+ defaultModel: 'sonar-pro',
85
+ },
86
+ {
87
+ name: 'Cohere',
88
+ baseUrl: 'https://api.cohere.com/v2',
89
+ apiKey: '',
90
+ models: ['command-a', 'command-r-plus', 'command-r'],
91
+ defaultModel: 'command-a',
92
+ },
93
+ {
94
+ name: 'DeepInfra',
95
+ baseUrl: 'https://api.deepinfra.com/v1/openai',
96
+ apiKey: '',
97
+ models: ['meta-llama/llama-4-maverick', 'deepseek-ai/deepseek-chat', 'microsoft/wizardlm-2-8x22b'],
98
+ defaultModel: 'meta-llama/llama-4-maverick',
99
+ },
100
+ {
101
+ name: 'Fireworks',
102
+ baseUrl: 'https://api.fireworks.ai/inference/v1',
103
+ apiKey: '',
104
+ models: ['llama-4-maverick', 'llama-4-scout', 'mixtral-8x22b'],
105
+ defaultModel: 'llama-4-maverick',
106
+ },
107
+ {
108
+ name: 'Cerebras',
109
+ baseUrl: 'https://api.cerebras.ai/v1',
110
+ apiKey: '',
111
+ models: ['llama-3.3-70b', 'llama-3.1-8b'],
112
+ defaultModel: 'llama-3.3-70b',
113
+ },
114
+ {
115
+ name: 'Novita',
116
+ baseUrl: 'https://api.novita.ai/v3/openai',
117
+ apiKey: '',
118
+ models: ['deepseek-r1', 'deepseek-v3', 'llama-3.1-70b'],
119
+ defaultModel: 'deepseek-v3',
120
+ },
121
+ {
122
+ name: 'Hyperbolic',
123
+ baseUrl: 'https://api.hyperbolic.xyz/v1',
124
+ apiKey: '',
125
+ models: ['deepseek-v3', 'llama-4-maverick', 'qwen3-235b'],
126
+ defaultModel: 'deepseek-v3',
127
+ },
128
+ {
129
+ name: 'SambaNova',
130
+ baseUrl: 'https://api.sambanova.ai/v1',
131
+ apiKey: '',
132
+ models: ['llama-4-maverick', 'llama-4-scout', 'deepseek-r1'],
133
+ defaultModel: 'llama-4-maverick',
134
+ },
135
+ {
136
+ name: 'Ollama',
137
+ baseUrl: 'http://localhost:11434/v1',
138
+ apiKey: '',
139
+ models: ['llama3', 'codellama', 'mistral'],
140
+ defaultModel: 'llama3',
141
+ },
72
142
  ];
73
143
  export const DEFAULTS = {
74
144
  providers: [],
@@ -718,6 +718,26 @@ export class Controller extends EventEmitter {
718
718
  this.emit('update');
719
719
  return true;
720
720
  }
721
+ /** Remove a provider by name. Clears the default if it was the removed one. */
722
+ removeProvider(name) {
723
+ const idx = this.config.providers.findIndex(p => p.name.toLowerCase() === name.toLowerCase());
724
+ if (idx < 0)
725
+ return false;
726
+ this.config.providers.splice(idx, 1);
727
+ if (this.config.defaultProvider.toLowerCase() === name.toLowerCase()) {
728
+ this.config.defaultProvider = this.config.providers[0]?.name ?? '';
729
+ }
730
+ // If the session was using the removed provider, reset it
731
+ if (this.session.providerName.toLowerCase() === name.toLowerCase()) {
732
+ const fallback = this.config.providers[0];
733
+ this.session.providerName = fallback?.name ?? '';
734
+ this.session.model = fallback?.defaultModel ?? '';
735
+ }
736
+ saveConfig(this.config);
737
+ this.llmCache.clear();
738
+ this.emit('update');
739
+ return true;
740
+ }
721
741
  setGlobalApprovalMode(mode) {
722
742
  this.config.approvalMode = mode;
723
743
  saveConfig(this.config);
package/dist/i18n.js CHANGED
@@ -276,6 +276,8 @@ const en = {
276
276
  'sset.model': 'Session model: {pm}',
277
277
  'sset.approvals': 'Shell approvals (session): {mode}',
278
278
  'sset.sound': 'Sound (session): {state}',
279
+ 'sset.providers.title': 'Change provider & model',
280
+ 'sset.providers.back': 'Back',
279
281
  'set.esc': 'Esc: close',
280
282
  // agent questions (auto-run)
281
283
  'q.title': '❓ AGENT QUESTION',
@@ -327,6 +329,40 @@ const en = {
327
329
  'set.priceBad': 'Invalid format — expected: input, output (e.g. 0.27, 1.10)',
328
330
  'set.newSkillName': 'Skill name (a .md template will be created in ~/.parallel/skills)',
329
331
  'set.newSpecialistName': 'Specialist name (a .md template will be created in ~/.parallel/specialists)',
332
+ // provider-pick section headers
333
+ 'wiz.provider.section.configured': 'Configured',
334
+ 'wiz.provider.section.cloud': 'Cloud Providers',
335
+ 'wiz.provider.section.local': 'Local',
336
+ 'wiz.provider.customDetail': 'Configure manually',
337
+ 'wiz.provider.ollamaDetail': 'Local — no API key needed',
338
+ 'wiz.provider.ollama.checking': 'Checking Ollama at localhost:11434...',
339
+ 'wiz.provider.ollama.found': 'Found {n} models',
340
+ 'wiz.provider.ollama.notFound': 'Could not reach Ollama at localhost:11434. Is it running?',
341
+ 'wiz.provider.ollama.continueDefaults': 'Continue with defaults',
342
+ 'wiz.provider.ollama.goBack': 'Go back',
343
+ // provider settings (future use)
344
+ 'set.providers': 'Providers',
345
+ 'set.providers.title': 'Providers',
346
+ 'set.providers.add': 'Add Provider...',
347
+ 'set.providers.back': 'Back',
348
+ 'set.providerDetail.title': '{name}',
349
+ 'set.providerDetail.key': 'Set API Key',
350
+ 'set.providerDetail.models': 'Manage Models',
351
+ 'set.providerDetail.pricing': 'Pricing',
352
+ 'set.providerDetail.setDefault': 'Set as Default',
353
+ 'set.providerDetail.remove': 'Remove Provider',
354
+ 'set.providerDetail.back': 'Back',
355
+ 'set.defaultProvider': 'Default Provider',
356
+ 'set.defaultProvider.title': 'Default Provider',
357
+ 'set.removeProvider.title': 'Remove {name}?',
358
+ 'set.removeProvider.confirm': 'This cannot be undone.',
359
+ 'set.removeProvider.yes': 'Yes, remove',
360
+ 'set.removeProvider.no': 'Cancel',
361
+ 'set.status.configured': 'configured',
362
+ 'set.status.noKey': 'no key',
363
+ 'set.status.local': 'local',
364
+ 'set.status.default': 'default',
365
+ 'set.key.masked': '{masked}',
330
366
  };
331
367
  const fr = {
332
368
  tagline: 'Des agents de code en parallèle, en temps réel — sans verrous, sans attente.',
@@ -574,6 +610,8 @@ const fr = {
574
610
  'sset.model': 'Modèle de session : {pm}',
575
611
  'sset.approvals': 'Approbations shell (session) : {mode}',
576
612
  'sset.sound': 'Son (session) : {state}',
613
+ 'sset.providers.title': 'Changer de fournisseur et de modèle',
614
+ 'sset.providers.back': 'Retour',
577
615
  'set.esc': 'Esc : fermer',
578
616
  'q.title': '❓ QUESTION D’UN AGENT',
579
617
  'q.pending': ' ({n} en attente)',
@@ -619,6 +657,40 @@ const fr = {
619
657
  'set.priceBad': 'Format invalide — attendu : input, output (ex : 0.27, 1.10)',
620
658
  'set.newSkillName': 'Nom du skill (un modèle .md sera créé dans ~/.parallel/skills)',
621
659
  'set.newSpecialistName': 'Nom du spécialiste (un modèle .md sera créé dans ~/.parallel/specialists)',
660
+ // provider-pick section headers
661
+ 'wiz.provider.section.configured': 'Configurés',
662
+ 'wiz.provider.section.cloud': 'Fournisseurs cloud',
663
+ 'wiz.provider.section.local': 'Local',
664
+ 'wiz.provider.customDetail': 'Configurer manuellement',
665
+ 'wiz.provider.ollamaDetail': 'Local — pas de clef API requise',
666
+ 'wiz.provider.ollama.checking': 'Vérification de Ollama sur localhost:11434…',
667
+ 'wiz.provider.ollama.found': '{n} modèles trouvés',
668
+ 'wiz.provider.ollama.notFound': 'Ollama est injoignable sur localhost:11434. Est-il lancé ?',
669
+ 'wiz.provider.ollama.continueDefaults': 'Continuer avec les valeurs par défaut',
670
+ 'wiz.provider.ollama.goBack': 'Retour',
671
+ // provider settings (future use)
672
+ 'set.providers': 'Fournisseurs',
673
+ 'set.providers.title': 'Fournisseurs',
674
+ 'set.providers.add': 'Ajouter un fournisseur…',
675
+ 'set.providers.back': 'Retour',
676
+ 'set.providerDetail.title': '{name}',
677
+ 'set.providerDetail.key': 'Définir la clef API',
678
+ 'set.providerDetail.models': 'Gérer les modèles',
679
+ 'set.providerDetail.pricing': 'Tarifs',
680
+ 'set.providerDetail.setDefault': 'Définir par défaut',
681
+ 'set.providerDetail.remove': 'Supprimer le fournisseur',
682
+ 'set.providerDetail.back': 'Retour',
683
+ 'set.defaultProvider': 'Fournisseur par défaut',
684
+ 'set.defaultProvider.title': 'Fournisseur par défaut',
685
+ 'set.removeProvider.title': 'Supprimer {name} ?',
686
+ 'set.removeProvider.confirm': 'Cette action est irréversible.',
687
+ 'set.removeProvider.yes': 'Oui, supprimer',
688
+ 'set.removeProvider.no': 'Annuler',
689
+ 'set.status.configured': 'configuré',
690
+ 'set.status.noKey': 'pas de clef',
691
+ 'set.status.local': 'local',
692
+ 'set.status.default': 'défaut',
693
+ 'set.key.masked': '{masked}',
622
694
  };
623
695
  const es = {
624
696
  tagline: 'Agentes de código en paralelo, en tiempo real — sin bloqueos, sin esperas.',
@@ -866,6 +938,8 @@ const es = {
866
938
  'sset.model': 'Modelo de sesión: {pm}',
867
939
  'sset.approvals': 'Aprobaciones shell (sesión): {mode}',
868
940
  'sset.sound': 'Sonido (sesión): {state}',
941
+ 'sset.providers.title': 'Cambiar proveedor y modelo',
942
+ 'sset.providers.back': 'Volver',
869
943
  'set.esc': 'Esc: cerrar',
870
944
  'q.title': '❓ PREGUNTA DE UN AGENTE',
871
945
  'q.pending': ' ({n} pendientes)',
@@ -911,6 +985,40 @@ const es = {
911
985
  'set.priceBad': 'Formato inválido — esperado: input, output (ej.: 0.27, 1.10)',
912
986
  'set.newSkillName': 'Nombre del skill (se creará una plantilla .md en ~/.parallel/skills)',
913
987
  'set.newSpecialistName': 'Nombre del especialista (se creará una plantilla .md en ~/.parallel/specialists)',
988
+ // provider-pick section headers
989
+ 'wiz.provider.section.configured': 'Configurados',
990
+ 'wiz.provider.section.cloud': 'Proveedores en la nube',
991
+ 'wiz.provider.section.local': 'Local',
992
+ 'wiz.provider.customDetail': 'Configurar manualmente',
993
+ 'wiz.provider.ollamaDetail': 'Local — no requiere clave API',
994
+ 'wiz.provider.ollama.checking': 'Comprobando Ollama en localhost:11434…',
995
+ 'wiz.provider.ollama.found': '{n} modelos encontrados',
996
+ 'wiz.provider.ollama.notFound': 'No se pudo contactar con Ollama en localhost:11434. ¿Está en ejecución?',
997
+ 'wiz.provider.ollama.continueDefaults': 'Continuar con valores predeterminados',
998
+ 'wiz.provider.ollama.goBack': 'Volver',
999
+ // provider settings (future use)
1000
+ 'set.providers': 'Proveedores',
1001
+ 'set.providers.title': 'Proveedores',
1002
+ 'set.providers.add': 'Añadir proveedor…',
1003
+ 'set.providers.back': 'Volver',
1004
+ 'set.providerDetail.title': '{name}',
1005
+ 'set.providerDetail.key': 'Establecer clave API',
1006
+ 'set.providerDetail.models': 'Gestionar modelos',
1007
+ 'set.providerDetail.pricing': 'Precios',
1008
+ 'set.providerDetail.setDefault': 'Establecer como predeterminado',
1009
+ 'set.providerDetail.remove': 'Eliminar proveedor',
1010
+ 'set.providerDetail.back': 'Volver',
1011
+ 'set.defaultProvider': 'Proveedor predeterminado',
1012
+ 'set.defaultProvider.title': 'Proveedor predeterminado',
1013
+ 'set.removeProvider.title': '¿Eliminar {name}?',
1014
+ 'set.removeProvider.confirm': 'Esta acción no se puede deshacer.',
1015
+ 'set.removeProvider.yes': 'Sí, eliminar',
1016
+ 'set.removeProvider.no': 'Cancelar',
1017
+ 'set.status.configured': 'configurado',
1018
+ 'set.status.noKey': 'sin clave',
1019
+ 'set.status.local': 'local',
1020
+ 'set.status.default': 'predeterminado',
1021
+ 'set.key.masked': '{masked}',
914
1022
  };
915
1023
  const zh = {
916
1024
  tagline: '并行实时代码智能体 — 无锁定,无等待。',
@@ -1158,6 +1266,8 @@ const zh = {
1158
1266
  'sset.model': '会话模型:{pm}',
1159
1267
  'sset.approvals': 'Shell 批准(会话):{mode}',
1160
1268
  'sset.sound': '声音(会话):{state}',
1269
+ 'sset.providers.title': '更改提供商和模型',
1270
+ 'sset.providers.back': '返回',
1161
1271
  'set.esc': 'Esc:关闭',
1162
1272
  'q.title': '❓ 智能体提问',
1163
1273
  'q.pending': '({n} 个待处理)',
@@ -1203,5 +1313,39 @@ const zh = {
1203
1313
  'set.priceBad': '格式无效 — 应为:input, output(例:0.27, 1.10)',
1204
1314
  'set.newSkillName': '技能名称(将在 ~/.parallel/skills 创建 .md 模板)',
1205
1315
  'set.newSpecialistName': '专家名称(将在 ~/.parallel/specialists 创建 .md 模板)',
1316
+ // provider-pick section headers
1317
+ 'wiz.provider.section.configured': '已配置',
1318
+ 'wiz.provider.section.cloud': '云提供商',
1319
+ 'wiz.provider.section.local': '本地',
1320
+ 'wiz.provider.customDetail': '手动配置',
1321
+ 'wiz.provider.ollamaDetail': '本地 — 无需 API 密钥',
1322
+ 'wiz.provider.ollama.checking': '正在检查 Ollama(localhost:11434)…',
1323
+ 'wiz.provider.ollama.found': '找到 {n} 个模型',
1324
+ 'wiz.provider.ollama.notFound': '无法连接到 localhost:11434 的 Ollama。它在运行吗?',
1325
+ 'wiz.provider.ollama.continueDefaults': '继续使用默认值',
1326
+ 'wiz.provider.ollama.goBack': '返回',
1327
+ // provider settings (future use)
1328
+ 'set.providers': '提供商',
1329
+ 'set.providers.title': '提供商',
1330
+ 'set.providers.add': '添加提供商…',
1331
+ 'set.providers.back': '返回',
1332
+ 'set.providerDetail.title': '{name}',
1333
+ 'set.providerDetail.key': '设置 API 密钥',
1334
+ 'set.providerDetail.models': '管理模型',
1335
+ 'set.providerDetail.pricing': '定价',
1336
+ 'set.providerDetail.setDefault': '设为默认',
1337
+ 'set.providerDetail.remove': '移除提供商',
1338
+ 'set.providerDetail.back': '返回',
1339
+ 'set.defaultProvider': '默认提供商',
1340
+ 'set.defaultProvider.title': '默认提供商',
1341
+ 'set.removeProvider.title': '移除 {name}?',
1342
+ 'set.removeProvider.confirm': '此操作无法撤销。',
1343
+ 'set.removeProvider.yes': '是,移除',
1344
+ 'set.removeProvider.no': '取消',
1345
+ 'set.status.configured': '已配置',
1346
+ 'set.status.noKey': '无密钥',
1347
+ 'set.status.local': '本地',
1348
+ 'set.status.default': '默认',
1349
+ 'set.key.masked': '{masked}',
1206
1350
  };
1207
1351
  const STRINGS = { en, fr, es, zh };
package/dist/pricing.js CHANGED
@@ -30,6 +30,33 @@ const BUILTIN = {
30
30
  // Alibaba
31
31
  'qwen2.5-coder': { input: 0.09, output: 0.09 },
32
32
  'qwen-max': { input: 1.6, output: 6.4 },
33
+ // xAI (Grok)
34
+ 'grok-4': { input: 4.0, output: 16.0 },
35
+ 'grok-3-beta': { input: 3.0, output: 12.0 },
36
+ 'grok-3-mini': { input: 0.55, output: 2.2 },
37
+ // Perplexity
38
+ 'sonar-pro': { input: 3.0, output: 15.0 },
39
+ 'sonar': { input: 1.0, output: 1.0 },
40
+ 'sonar-reasoning': { input: 2.0, output: 16.0 },
41
+ // Cohere
42
+ 'command-a': { input: 2.5, output: 10.0 },
43
+ 'command-r-plus': { input: 2.5, output: 10.0 },
44
+ 'command-r': { input: 0.5, output: 1.5 },
45
+ // DeepInfra
46
+ 'llama-4-maverick': { input: 0.2, output: 0.6 }, // approximate
47
+ 'wizardlm-2-8x22b': { input: 0.5, output: 0.5 }, // approximate
48
+ // Fireworks
49
+ 'llama-4-scout': { input: 0.1, output: 0.3 }, // approximate
50
+ 'mixtral-8x22b': { input: 0.9, output: 0.9 }, // approximate
51
+ // Cerebras
52
+ 'llama-3.3-70b @cerebras': { input: 0.5, output: 1.5 }, // approximate
53
+ 'llama-3.1-8b': { input: 0.05, output: 0.1 }, // approximate
54
+ // Novita
55
+ 'deepseek-r1': { input: 2.0, output: 8.0 },
56
+ 'deepseek-v3': { input: 1.25, output: 5.0 },
57
+ 'llama-3.1-70b': { input: 0.35, output: 0.4 }, // approximate
58
+ // Hyperbolic
59
+ 'qwen3-235b': { input: 0.5, output: 1.5 }, // approximate
33
60
  // Local endpoints are free
34
61
  'ollama': { input: 0, output: 0 },
35
62
  };
package/dist/ui/App.js CHANGED
@@ -274,37 +274,106 @@ export function App({ config, initialFolder }) {
274
274
  .join(', ')
275
275
  .slice(0, 60)})`,
276
276
  })),
277
- ], onBack: wizardBack, onSelect: chooseSession }) })), phase === 'provider' && providerStep.id === 'pick' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.title'), children: _jsx(SelectList, { items: [
278
- ...config.providers.map((p) => ({
277
+ ], onBack: wizardBack, onSelect: chooseSession }) })), phase === 'provider' && providerStep.id === 'pick' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.title'), children: _jsx(SelectList, { items: (() => {
278
+ const items = [];
279
+ // Section: Configured
280
+ const configured = config.providers.map((p) => ({
279
281
  label: p.name,
280
- value: `existing:${p.name}`,
281
- hint: `(${p.baseUrl}${p.apiKey ? '' : ' — ' + t('wiz.provider.needsKey')})`,
282
- })),
283
- ...PROVIDER_PRESETS.filter((preset) => !config.providers.some((p) => p.name.toLowerCase() === preset.name.toLowerCase())).map((preset) => ({
284
- label: preset.name,
285
- value: `preset:${preset.name}`,
286
- hint: t('wiz.provider.presetHint', { url: preset.baseUrl, model: preset.defaultModel }),
287
- })),
288
- { label: t('wiz.provider.custom'), value: '__custom__', hint: t('wiz.provider.customHint') },
289
- ], onBack: wizardBack, onSelect: (v) => {
282
+ value: p.name,
283
+ detail: p.apiKey ? undefined : t('wiz.provider.needsKey'),
284
+ }));
285
+ if (configured.length > 0) {
286
+ items.push({ label: t('wiz.provider.section.configured'), value: '', section: true });
287
+ items.push(...configured);
288
+ }
289
+ // Section: Cloud Providers (presets except Ollama, not already configured)
290
+ const cloudPresets = PROVIDER_PRESETS.filter((preset) => preset.name !== 'Ollama' &&
291
+ !config.providers.some((p) => p.name.toLowerCase() === preset.name.toLowerCase()));
292
+ if (cloudPresets.length > 0) {
293
+ items.push({ label: t('wiz.provider.section.cloud'), value: '', section: true });
294
+ items.push(...cloudPresets.map((preset) => ({
295
+ label: preset.name,
296
+ value: preset.name,
297
+ detail: preset.defaultModel,
298
+ })));
299
+ }
300
+ // Section: Local (Ollama only, if not already configured)
301
+ const ollamaPreset = PROVIDER_PRESETS.find((p) => p.name === 'Ollama');
302
+ const ollamaConfigured = config.providers.some((p) => p.name.toLowerCase() === 'ollama');
303
+ if (ollamaPreset && !ollamaConfigured) {
304
+ items.push({ label: t('wiz.provider.section.local'), value: '', section: true });
305
+ items.push({
306
+ label: ollamaPreset.name,
307
+ value: ollamaPreset.name,
308
+ detail: t('wiz.provider.ollamaDetail'),
309
+ });
310
+ }
311
+ // Custom always last
312
+ items.push({
313
+ label: t('wiz.provider.custom'),
314
+ value: '__custom__',
315
+ detail: t('wiz.provider.customDetail'),
316
+ });
317
+ return items;
318
+ })(), onBack: wizardBack, onSelect: async (v) => {
290
319
  if (v === '__custom__')
291
320
  return setProviderStep({ id: 'name' });
292
- if (v.startsWith('preset:')) {
293
- const preset = PROVIDER_PRESETS.find((p) => p.name === v.slice('preset:'.length));
294
- if (preset)
295
- return setProviderStep({ id: 'key', preset: { ...preset, models: [...preset.models] } });
296
- }
297
- const p = config.providers.find((x) => x.name === v.slice('existing:'.length));
298
- if (!p)
321
+ // Already-configured provider?
322
+ const existing = config.providers.find((x) => x.name === v);
323
+ if (existing) {
324
+ if (existing.apiKey) {
325
+ ctlRef.current?.setDefaultProvider(v);
326
+ ctlRef.current?.setSessionProvider(v);
327
+ enterMain();
328
+ }
329
+ else {
330
+ setProviderStep({ id: 'key', preset: existing });
331
+ }
299
332
  return;
300
- if (p.apiKey) {
301
- ctlRef.current?.setDefaultProvider(p.name);
302
- ctlRef.current?.setSessionProvider(p.name);
303
- enterMain();
304
333
  }
305
- else {
306
- setProviderStep({ id: 'key', preset: p });
334
+ // Must be a preset
335
+ const preset = PROVIDER_PRESETS.find((p) => p.name === v);
336
+ if (!preset)
337
+ return;
338
+ // Ollama: connectivity check with 2s timeout
339
+ if (preset.name.toLowerCase() === 'ollama') {
340
+ setSystemLines((ls) => [
341
+ ...ls.slice(-5),
342
+ { text: t('wiz.provider.ollama.checking', { url: preset.baseUrl }), level: 'info' },
343
+ ]);
344
+ let models = [...preset.models];
345
+ let defaultModel = preset.defaultModel;
346
+ try {
347
+ const controller = new AbortController();
348
+ const timeout = setTimeout(() => controller.abort(), 2000);
349
+ const resp = await fetch(preset.baseUrl + '/models', { signal: controller.signal });
350
+ clearTimeout(timeout);
351
+ if (resp.ok) {
352
+ const data = (await resp.json());
353
+ const detected = data?.data?.map((m) => m.id).filter(Boolean) ?? [];
354
+ if (detected.length > 0) {
355
+ models = detected;
356
+ defaultModel = detected[0];
357
+ setSystemLines((ls) => [
358
+ ...ls.slice(-5),
359
+ { text: t('wiz.provider.ollama.found', { n: detected.length }), level: 'ok' },
360
+ ]);
361
+ }
362
+ }
363
+ }
364
+ catch {
365
+ setSystemLines((ls) => [
366
+ ...ls.slice(-5),
367
+ { text: t('wiz.provider.ollama.notFound', { url: preset.baseUrl }), level: 'warn' },
368
+ ]);
369
+ }
370
+ const ollamaProvider = { ...preset, apiKey: 'ollama-local', models, defaultModel };
371
+ ctlRef.current?.saveProvider(ollamaProvider);
372
+ ctlRef.current?.setSessionProvider(ollamaProvider.name);
373
+ setPhase('model');
374
+ return;
307
375
  }
376
+ setProviderStep({ id: 'key', preset: { ...preset, models: [...preset.models] } });
308
377
  } }) })), phase === 'provider' && providerStep.id === 'key' && (_jsxs(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.preset.name }), footer: t('wiz.provider.key.footer'), children: [_jsx(Text, { color: "gray", children: providerStep.preset.baseUrl }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (k) => finishProvider({ ...providerStep.preset, apiKey: k.trim() }) })] })), phase === 'provider' && providerStep.id === 'name' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.name.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.name.ph'), onBack: wizardBack, onInput: (name) => setProviderStep({ id: 'url', name }) }) })), phase === 'provider' && providerStep.id === 'url' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.url.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onBack: wizardBack, onInput: (url) => setProviderStep({ id: 'model', name: providerStep.name, url }) }) })), phase === 'provider' && providerStep.id === 'model' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.model.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onInput: (model) => setProviderStep({ id: 'newKey', name: providerStep.name, url: providerStep.url, model }) }) })), phase === 'provider' && providerStep.id === 'newKey' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.name }), footer: t('wiz.provider.key.footer'), children: _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (key) => finishProvider({
309
378
  name: providerStep.name,
310
379
  baseUrl: providerStep.url,
@@ -17,66 +17,66 @@ function nextApprovalMode(mode) {
17
17
  return 'yolo';
18
18
  return 'ask';
19
19
  }
20
+ /** Derive a status badge string for a provider in the submenu list. */
21
+ function providerStatus(p, defaultName) {
22
+ const isLocal = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.baseUrl);
23
+ if (p.name.toLowerCase() === defaultName.toLowerCase())
24
+ return t('set.status.default');
25
+ if (isLocal)
26
+ return t('set.status.local');
27
+ if (p.apiKey)
28
+ return masked(p.apiKey);
29
+ return t('set.status.noKey');
30
+ }
20
31
  /**
21
32
  * /settings → scope 'global' : persisted in ~/.parallel/config.json
22
33
  * /settings-session → scope 'session' : this session only, never persisted
23
34
  */
24
35
  export function SettingsPanel({ ctl, scope, onClose, }) {
25
36
  const [step, setStep] = useState({ id: 'root' });
37
+ const [returnStep, setReturnStep] = useState(null);
26
38
  const [flash, setFlash] = useState('');
27
39
  const saved = () => setFlash(t('set.saved'));
28
40
  const cfg = ctl.config;
41
+ // ---- root menu items ----
29
42
  const rootItems = scope === 'global'
30
43
  ? [
31
44
  { label: t('set.language', { lang: LANGS.find((l) => l.code === getLang())?.label ?? getLang() }), value: 'lang' },
32
45
  {
33
46
  label: t('set.defaultPM', {
34
- pm: cfg.defaultProvider ? `${cfg.defaultProvider}:${cfg.providers.find((p) => p.name === cfg.defaultProvider)?.defaultModel ?? '?'}` : '—',
47
+ pm: cfg.defaultProvider
48
+ ? `${cfg.defaultProvider}:${cfg.providers.find((p) => p.name === cfg.defaultProvider)?.defaultModel ?? '?'}`
49
+ : '—',
35
50
  }),
36
51
  value: 'defaultPM',
37
52
  },
38
- ...cfg.providers.map((p) => ({
39
- label: t('set.key', { name: p.name, masked: masked(p.apiKey) }),
40
- value: `key:${p.name}`,
41
- })),
42
- { label: t('set.addProvider'), value: 'add' },
43
- { label: t('set.models'), value: 'models' },
44
- { label: t('set.prices'), value: 'prices' },
45
- { label: t('set.newSkill'), value: 'newSkill' },
46
- { label: t('set.newSpecialist'), value: 'newSpecialist' },
53
+ { label: t('set.providers'), value: 'providers' },
47
54
  { label: t('set.approvals', { mode: cfg.approvalMode }), value: 'approvals' },
48
55
  { label: t('set.sound', { state: cfg.soundEnabled ? 'on' : 'off' }), value: 'sound' },
56
+ { label: t('set.newSkill'), value: 'newSkill' },
57
+ { label: t('set.newSpecialist'), value: 'newSpecialist' },
49
58
  { label: t('set.back'), value: 'back' },
50
59
  ]
51
60
  : [
52
61
  {
53
62
  label: t('sset.model', { pm: `${ctl.session.providerName || '—'}:${ctl.session.model || '—'}` }),
54
- value: 'model',
63
+ value: 'providers',
55
64
  },
56
65
  { label: t('sset.approvals', { mode: ctl.session.approvalMode }), value: 'approvals' },
57
66
  { label: t('sset.sound', { state: ctl.session.soundEnabled ? 'on' : 'off' }), value: 'sound' },
58
67
  { label: t('set.back'), value: 'back' },
59
68
  ];
69
+ // ---- root menu handler ----
60
70
  const chooseRoot = (v) => {
61
71
  setFlash('');
62
72
  if (v === 'back')
63
73
  return onClose();
64
74
  if (v === 'lang')
65
75
  return setStep({ id: 'lang' });
66
- if (v === 'defaultPM' || v === 'model')
76
+ if (v === 'defaultPM')
67
77
  return setStep({ id: 'pickProvider', next: 'model' });
68
- if (v.startsWith('key:')) {
69
- const p = cfg.providers.find((x) => x.name === v.slice(4));
70
- if (p)
71
- setStep({ id: 'key', provider: p });
72
- return;
73
- }
74
- if (v === 'add')
75
- return setStep({ id: 'newName' });
76
- if (v === 'models')
77
- return setStep({ id: 'pickProvider', next: 'models' });
78
- if (v === 'prices')
79
- return setStep({ id: 'pickProvider', next: 'prices' });
78
+ if (v === 'providers')
79
+ return setStep({ id: 'providers', scope });
80
80
  if (v === 'newSkill')
81
81
  return setStep({ id: 'newSkill' });
82
82
  if (v === 'newSpecialist')
@@ -100,6 +100,7 @@ export function SettingsPanel({ ctl, scope, onClose, }) {
100
100
  return;
101
101
  }
102
102
  };
103
+ // ---- shared helpers ----
103
104
  const pickModel = (provider, model) => {
104
105
  if (scope === 'global') {
105
106
  provider.defaultModel = model;
@@ -112,13 +113,21 @@ export function SettingsPanel({ ctl, scope, onClose, }) {
112
113
  else {
113
114
  ctl.setSessionModel(`${provider.name}:${model}`);
114
115
  }
115
- setStep({ id: 'root' });
116
+ setStep(returnStep ?? { id: 'root' });
117
+ setReturnStep(null);
116
118
  };
117
119
  const finishNewProvider = (name, url, model, key) => {
118
120
  ctl.saveProvider({ name, baseUrl: url, apiKey: key, models: [model], defaultModel: model });
119
121
  saved();
120
- setStep({ id: 'root' });
122
+ setStep(returnStep ?? { id: 'root' });
123
+ setReturnStep(null);
124
+ };
125
+ // ---- navigate into a sub-step, remembering where to return ----
126
+ const goSub = (next) => {
127
+ setReturnStep(step);
128
+ setStep(next);
121
129
  };
130
+ // ---- render ----
122
131
  return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: scope === 'global' ? t('set.title') : t('sset.title') }), flash ? _jsx(Text, { color: "green", children: flash }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [step.id === 'root' && _jsx(SelectList, { items: rootItems, onSelect: chooseRoot }), step.id === 'lang' && (_jsx(SelectList, { items: LANGS.map((l) => ({ label: l.label, value: l.code })), onSelect: (code) => {
123
132
  setLang(code);
124
133
  ctl.setLanguage(code);
@@ -138,7 +147,21 @@ export function SettingsPanel({ ctl, scope, onClose, }) {
138
147
  if (step.next === 'models')
139
148
  return setStep({ id: 'modelList', provider: p });
140
149
  setStep({ id: 'model', provider: p });
141
- } })] })), step.id === 'modelList' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.modelsFor', { name: step.provider.name }) }), _jsx(SelectList, { items: [
150
+ } })] })), step.id === 'model' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.chooseModel', { name: step.provider.name }) }), _jsx(SelectList, { items: [
151
+ ...step.provider.models.map((m) => ({
152
+ label: m,
153
+ value: m,
154
+ hint: m === step.provider.defaultModel ? t('wiz.model.default') : undefined,
155
+ })),
156
+ { label: t('set.back'), value: '__back__' },
157
+ ], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onSelect: (v) => {
158
+ if (v === '__back__') {
159
+ setStep(returnStep ?? { id: 'root' });
160
+ setReturnStep(null);
161
+ return;
162
+ }
163
+ pickModel(step.provider, v);
164
+ }, onInput: (m) => pickModel(step.provider, m) })] })), step.id === 'modelList' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.modelsFor', { name: step.provider.name }) }), _jsx(SelectList, { items: [
142
165
  ...step.provider.models.map((m) => ({
143
166
  label: m,
144
167
  value: m,
@@ -146,13 +169,17 @@ export function SettingsPanel({ ctl, scope, onClose, }) {
146
169
  })),
147
170
  { label: t('set.back'), value: '__back__' },
148
171
  ], allowInput: true, inputPlaceholder: t('set.addModelName'), onSelect: (v) => {
149
- if (v === '__back__')
150
- return setStep({ id: 'root' });
172
+ if (v === '__back__') {
173
+ setStep(returnStep ?? { id: 'root' });
174
+ setReturnStep(null);
175
+ return;
176
+ }
151
177
  step.provider.defaultModel = v;
152
178
  ctl.saveProvider(step.provider);
153
179
  ctl.setDefaultProvider(step.provider.name);
154
180
  saved();
155
- setStep({ id: 'root' });
181
+ setStep(returnStep ?? { id: 'root' });
182
+ setReturnStep(null);
156
183
  }, onInput: (m) => {
157
184
  const model = m.trim();
158
185
  if (!model)
@@ -163,30 +190,46 @@ export function SettingsPanel({ ctl, scope, onClose, }) {
163
190
  ctl.saveProvider(step.provider);
164
191
  ctl.setDefaultProvider(step.provider.name);
165
192
  saved();
166
- setStep({ id: 'root' });
193
+ setStep(returnStep ?? { id: 'root' });
194
+ setReturnStep(null);
167
195
  } })] })), step.id === 'priceModel' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.priceModel', { name: step.provider.name }) }), _jsx(SelectList, { items: [
168
196
  ...step.provider.models.map((m) => {
169
197
  const pr = priceFor(step.provider, m);
170
198
  return {
171
199
  label: m,
172
200
  value: m,
173
- hint: pr ? `($${pr.input}/M in · $${pr.output}/M out${step.provider.prices?.[m] ? ' — override' : ''})` : `(${t('set.priceUnknown')})`,
201
+ hint: pr
202
+ ? `($${pr.input}/M in · $${pr.output}/M out${step.provider.prices?.[m] ? ' — override' : ''})`
203
+ : `(${t('set.priceUnknown')})`,
174
204
  };
175
205
  }),
176
206
  { label: t('set.back'), value: '__back__' },
177
207
  ], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onSelect: (v) => {
178
- if (v === '__back__')
179
- return setStep({ id: 'root' });
180
- setStep({ id: 'priceValue', provider: step.provider, model: v });
181
- }, onInput: (m) => setStep({ id: 'priceValue', provider: step.provider, model: m.trim() }) })] })), step.id === 'priceValue' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.priceValue', { model: step.model }) }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: "0.27, 1.10", onInput: (v) => {
208
+ if (v === '__back__') {
209
+ setStep(returnStep ?? { id: 'root' });
210
+ setReturnStep(null);
211
+ return;
212
+ }
213
+ goSub({ id: 'priceValue', provider: step.provider, model: v });
214
+ }, onInput: (m) => goSub({ id: 'priceValue', provider: step.provider, model: m.trim() }) })] })), step.id === 'priceValue' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.priceValue', { model: step.model }) }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: "0.27, 1.10", onInput: (v) => {
182
215
  const m = v.match(/^\s*([\d.]+)\s*[,;\s]\s*([\d.]+)\s*$/);
183
216
  if (!m)
184
217
  return setFlash(t('set.priceBad'));
185
- step.provider.prices = { ...step.provider.prices, [step.model]: { input: parseFloat(m[1]), output: parseFloat(m[2]) } };
218
+ step.provider.prices = {
219
+ ...step.provider.prices,
220
+ [step.model]: { input: parseFloat(m[1]), output: parseFloat(m[2]) },
221
+ };
186
222
  ctl.saveProvider(step.provider);
187
223
  saved();
188
- setStep({ id: 'root' });
189
- } })] })), step.id === 'newSkill' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.newSkillName') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: "review, deploy, tests\u2026", onInput: (name) => {
224
+ setStep(returnStep ?? { id: 'root' });
225
+ setReturnStep(null);
226
+ } })] })), step.id === 'key' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.key.title', { name: step.provider.name }) }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onInput: (k) => {
227
+ step.provider.apiKey = k.trim();
228
+ ctl.saveProvider(step.provider);
229
+ saved();
230
+ setStep(returnStep ?? { id: 'root' });
231
+ setReturnStep(null);
232
+ } })] })), step.id === 'newName' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.name.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.name.ph'), onInput: (name) => setStep({ id: 'newUrl', name }) })] })), step.id === 'newUrl' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.url.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onInput: (url) => setStep({ id: 'newModel', name: step.name, url }) })] })), step.id === 'newModel' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.model.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onInput: (model) => setStep({ id: 'newKey', name: step.name, url: step.url, model }) })] })), step.id === 'newKey' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.key.title', { name: step.name }) }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onInput: (key) => finishNewProvider(step.name, step.url, step.model, key.trim()) })] })), step.id === 'newSkill' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.newSkillName') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: "review, deploy, tests\u2026", onInput: (name) => {
190
233
  try {
191
234
  const file = createSkillTemplate(name.trim(), '', 'global', ctl.projectRoot);
192
235
  setFlash(t('m.skillCreated', { file }));
@@ -204,21 +247,86 @@ export function SettingsPanel({ ctl, scope, onClose, }) {
204
247
  setFlash(t('m.alreadyExists', { msg: e?.message ?? '' }));
205
248
  }
206
249
  setStep({ id: 'root' });
207
- } })] })), step.id === 'model' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.chooseModel', { name: step.provider.name }) }), _jsx(SelectList, { items: [
208
- ...step.provider.models.map((m) => ({
209
- label: m,
210
- value: m,
211
- hint: m === step.provider.defaultModel ? t('wiz.model.default') : undefined,
250
+ } })] })), step.id === 'providers' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: step.scope === 'global' ? t('set.providers.title') : t('sset.providers.title') }), _jsx(SelectList, { items: [
251
+ ...cfg.providers.map((p) => ({
252
+ label: p.name,
253
+ value: p.name,
254
+ hint: providerStatus(p, cfg.defaultProvider),
212
255
  })),
213
- { label: t('set.back'), value: '__back__' },
214
- ], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onSelect: (v) => {
256
+ { label: t('set.providers.add'), value: '__add__' },
257
+ { label: step.scope === 'global' ? t('set.providers.back') : t('sset.providers.back'), value: '__back__' },
258
+ ], onSelect: (v) => {
215
259
  if (v === '__back__')
216
260
  return setStep({ id: 'root' });
217
- pickModel(step.provider, v);
218
- }, onInput: (m) => pickModel(step.provider, m) })] })), step.id === 'key' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.key.title', { name: step.provider.name }) }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onInput: (k) => {
219
- step.provider.apiKey = k.trim();
220
- ctl.saveProvider(step.provider);
221
- saved();
222
- setStep({ id: 'root' });
223
- } })] })), step.id === 'newName' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.name.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.name.ph'), onInput: (name) => setStep({ id: 'newUrl', name }) })] })), step.id === 'newUrl' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.url.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onInput: (url) => setStep({ id: 'newModel', name: step.name, url }) })] })), step.id === 'newModel' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.model.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onInput: (model) => setStep({ id: 'newKey', name: step.name, url: step.url, model }) })] })), step.id === 'newKey' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.key.title', { name: step.name }) }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onInput: (key) => finishNewProvider(step.name, step.url, step.model, key.trim()) })] }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: t('set.esc') }) })] }));
261
+ if (v === '__add__') {
262
+ setReturnStep({ id: 'providers', scope: step.scope });
263
+ return setStep({ id: 'newName' });
264
+ }
265
+ const p = cfg.providers.find((x) => x.name === v);
266
+ if (!p)
267
+ return;
268
+ if (step.scope === 'session') {
269
+ // Session scope: pick a model for this session
270
+ setReturnStep({ id: 'root' });
271
+ setStep({ id: 'model', provider: p });
272
+ }
273
+ else {
274
+ // Global scope: go to provider detail
275
+ setStep({ id: 'providerDetail', provider: p, scope: 'global' });
276
+ }
277
+ } })] })), step.id === 'providerDetail' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.providerDetail.title', { name: step.provider.name }) }), _jsx(SelectList, { items: [
278
+ {
279
+ label: t('set.providerDetail.key'),
280
+ value: 'key',
281
+ hint: masked(step.provider.apiKey),
282
+ },
283
+ {
284
+ label: t('set.providerDetail.models'),
285
+ value: 'models',
286
+ hint: `(${step.provider.models.length})`,
287
+ },
288
+ { label: t('set.providerDetail.pricing'), value: 'pricing' },
289
+ {
290
+ label: t('set.providerDetail.setDefault'),
291
+ value: 'setDefault',
292
+ hint: step.provider.name.toLowerCase() === cfg.defaultProvider.toLowerCase()
293
+ ? `(${t('set.status.default')})`
294
+ : undefined,
295
+ },
296
+ { label: t('set.providerDetail.remove'), value: 'remove' },
297
+ { label: t('set.providerDetail.back'), value: '__back__' },
298
+ ], onSelect: (v) => {
299
+ if (v === '__back__')
300
+ return setStep({ id: 'providers', scope: step.scope });
301
+ if (v === 'key') {
302
+ setReturnStep({ id: 'providerDetail', provider: step.provider, scope: step.scope });
303
+ return setStep({ id: 'key', provider: step.provider });
304
+ }
305
+ if (v === 'models') {
306
+ setReturnStep({ id: 'providerDetail', provider: step.provider, scope: step.scope });
307
+ return setStep({ id: 'modelList', provider: step.provider });
308
+ }
309
+ if (v === 'pricing') {
310
+ setReturnStep({ id: 'providerDetail', provider: step.provider, scope: step.scope });
311
+ return setStep({ id: 'priceModel', provider: step.provider });
312
+ }
313
+ if (v === 'setDefault') {
314
+ ctl.setDefaultProvider(step.provider.name);
315
+ saved();
316
+ return setStep({ id: 'providers', scope: step.scope });
317
+ }
318
+ if (v === 'remove')
319
+ return setStep({ id: 'removeProvider', provider: step.provider, scope: step.scope });
320
+ } })] })), step.id === 'removeProvider' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.removeProvider.title', { name: step.provider.name }) }), _jsx(Text, { color: "yellow", children: t('set.removeProvider.confirm') }), _jsx(SelectList, { items: [
321
+ { label: t('set.removeProvider.yes'), value: 'yes' },
322
+ { label: t('set.removeProvider.no'), value: 'no' },
323
+ ], onSelect: (v) => {
324
+ if (v === 'no')
325
+ return setStep({ id: 'providerDetail', provider: step.provider, scope: step.scope });
326
+ if (v === 'yes') {
327
+ ctl.removeProvider(step.provider.name);
328
+ saved();
329
+ setStep({ id: 'providers', scope: step.scope });
330
+ }
331
+ } })] }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: t('set.esc') }) })] }));
224
332
  }
package/dist/ui/Wizard.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { t } from '../i18n.js';
@@ -12,6 +12,8 @@ export function SelectList({ items, allowInput, inputPlaceholder, mask, onBack,
12
12
  const [typed, setTyped] = useState('');
13
13
  const typing = allowInput && typed.length > 0;
14
14
  useInput((input, key) => {
15
+ // Build selectable index list each render (cheap — items is small).
16
+ const selectable = items.map((it, i) => (it.section ? -1 : i)).filter((i) => i >= 0);
15
17
  if (key.escape) {
16
18
  if (typed)
17
19
  setTyped('');
@@ -26,8 +28,10 @@ export function SelectList({ items, allowInput, inputPlaceholder, mask, onBack,
26
28
  if (v)
27
29
  onInput?.(v);
28
30
  }
29
- else if (items[idx]) {
30
- onSelect?.(items[idx].value);
31
+ else {
32
+ const realIdx = selectable[idx];
33
+ if (realIdx !== undefined && items[realIdx])
34
+ onSelect?.(items[realIdx].value);
31
35
  }
32
36
  return;
33
37
  }
@@ -37,12 +41,12 @@ export function SelectList({ items, allowInput, inputPlaceholder, mask, onBack,
37
41
  }
38
42
  if (key.upArrow) {
39
43
  if (!typing)
40
- setIdx((i) => (i - 1 + items.length) % Math.max(1, items.length));
44
+ setIdx((i) => (i - 1 + selectable.length) % Math.max(1, selectable.length));
41
45
  return;
42
46
  }
43
47
  if (key.downArrow) {
44
48
  if (!typing)
45
- setIdx((i) => (i + 1) % Math.max(1, items.length));
49
+ setIdx((i) => (i + 1) % Math.max(1, selectable.length));
46
50
  return;
47
51
  }
48
52
  if (key.tab || key.ctrl || key.meta)
@@ -59,7 +63,10 @@ export function SelectList({ items, allowInput, inputPlaceholder, mask, onBack,
59
63
  }
60
64
  setTyped((v) => v + input);
61
65
  });
62
- return (_jsxs(Box, { flexDirection: "column", children: [items.map((it, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: !typing && i === idx ? 'cyanBright' : 'gray', bold: !typing && i === idx, children: [!typing && i === idx ? '❯ ' : ' ', it.label] }), it.hint ? _jsxs(Text, { color: "gray", children: [" ", it.hint] }) : null] }, it.value + i))), allowInput && (_jsx(Box, { marginTop: items.length > 0 ? 1 : 0, children: _jsxs(Text, { color: typing ? 'cyanBright' : 'gray', children: ["\u270E", ' ', typing ? (_jsx(Text, { color: "white", children: mask ? '•'.repeat(typed.length) : typed })) : (_jsx(Text, { color: "gray", children: inputPlaceholder ?? '…' })), typing ? _jsx(Text, { color: "cyanBright", children: "\u2588" }) : null] }) }))] }));
66
+ // Build a separate index map so up/down skip section headers.
67
+ const selectable = items.map((it, i) => (it.section ? -1 : i)).filter((i) => i >= 0);
68
+ const safeIdx = selectable.length > 0 ? selectable[Math.min(idx, selectable.length - 1)] : -1;
69
+ return (_jsxs(Box, { flexDirection: "column", children: [items.map((it, i) => it.section ? (_jsx(Box, { marginTop: i > 0 ? 1 : 0, children: _jsx(Text, { bold: true, color: "white", children: it.label }) }, it.label)) : (_jsxs(Text, { children: [_jsxs(Text, { color: !typing && i === safeIdx ? 'cyanBright' : 'gray', bold: !typing && i === safeIdx, children: [!typing && i === safeIdx ? '❯ ' : ' ', it.label] }), it.hint ? _jsxs(Text, { color: "gray", children: [" ", it.hint] }) : null, it.detail ? _jsxs(Text, { color: "gray", children: [" \u2014 ", it.detail] }) : null] }, it.value + i))), allowInput && (_jsx(Box, { marginTop: items.length > 0 ? 1 : 0, children: _jsxs(Text, { color: typing ? 'cyanBright' : 'gray', children: ["\u270E", ' ', typing ? (_jsx(Text, { color: "white", children: mask ? '•'.repeat(typed.length) : typed })) : (_jsx(Text, { color: "gray", children: inputPlaceholder ?? '…' })), typing ? _jsx(Text, { color: "cyanBright", children: "\u2588" }) : null] }) }))] }));
63
70
  }
64
71
  export function WizardStep({ step, total, title, children, footer, }) {
65
72
  return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["[", step, "/", total, "] ", title] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: children }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: footer ?? t('wiz.footer.select') }) })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallel-cli/parallel",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Real-time multi-agent coding CLI with shared context, adaptive co-editing, dedicated agent terminals, and headless CI runs.",
5
5
  "keywords": [
6
6
  "cli",