@dotdrelle/wiki-manager 0.15.34 → 0.15.38

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.
Files changed (41) hide show
  1. package/.env.example +8 -5
  2. package/README.md +42 -0
  3. package/agents.docker-compose.yml +11 -16
  4. package/docker-compose.yml +9 -8
  5. package/package.json +2 -2
  6. package/src/agent/graph.js +20 -0
  7. package/src/cli/wiki-manager.js +20 -0
  8. package/src/commands/slash.js +85 -13
  9. package/src/commands/slash.test.js +85 -3
  10. package/src/core/agentsCompose.js +86 -1
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +24 -2
  13. package/src/core/dockerCompose.test.js +18 -0
  14. package/src/core/env.js +2 -1
  15. package/src/core/env.test.js +14 -0
  16. package/src/core/googleGrants.js +38 -0
  17. package/src/core/googleGrants.test.js +59 -0
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/mcp.test.js +1 -0
  20. package/src/core/mcpEndpoints.js +96 -0
  21. package/src/core/mcpEndpoints.test.js +126 -0
  22. package/src/core/modelFetch.js +241 -27
  23. package/src/core/modelFetch.test.js +213 -0
  24. package/src/core/profileServiceStatus.test.js +146 -0
  25. package/src/core/wikiSetup.js +11 -2
  26. package/src/core/wikiWorkspace.test.js +16 -3
  27. package/src/orchestrator/agentRegistry.js +13 -1
  28. package/src/orchestrator/agentRegistry.test.js +20 -0
  29. package/src/runtime/lifecycle.js +105 -3
  30. package/src/runtime/lifecycle.test.js +68 -0
  31. package/src/runtime/server.js +28 -0
  32. package/src/shell/LeftPane.tsx +55 -41
  33. package/src/shell/SetupWizard.tsx +416 -116
  34. package/src/shell/repl.js +61 -12
  35. package/src/shell/repl.test.js +48 -19
  36. package/src/shell/setupWizardDiscovery.test.js +48 -0
  37. package/src/shell/setupWizardPlaceholders.test.js +15 -2
  38. package/src/shell/setupWizardSuggestions.test.js +36 -2
  39. package/src/shell/wrapText.js +57 -0
  40. package/src/shell/wrapText.test.js +48 -0
  41. package/wiki-workspace +122 -32
@@ -4,13 +4,15 @@ import { join } from 'node:path';
4
4
  import { useKeyboard, usePaste } from '@opentui/solid';
5
5
  import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
6
6
  import {
7
+ DISCOVERY_TIMEOUT_MS,
7
8
  defaultBaseUrl,
8
9
  fallbackModels,
9
10
  fetchGatewayCatalog,
10
- fetchModels,
11
+ fetchServerCatalog,
11
12
  normalizeEngine,
12
13
  normalizeProvider,
13
14
  requiresBaseUrl,
15
+ transportSummary,
14
16
  } from '../core/modelFetch.js';
15
17
  import { checkInternetConnectivity } from '../core/startupCheck.js';
16
18
  import {
@@ -25,6 +27,7 @@ import {
25
27
  } from '../core/wikiSetup.js';
26
28
  import { listWorkspaces, workspacesDir } from '../core/workspaces.js';
27
29
  import { loadWikircProfile } from '../core/wikirc.js';
30
+ import { wrapText } from './wrapText.js';
28
31
 
29
32
  type Gap = { kind: 'agents' | 'network' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
30
33
  type Mode = 'startup' | 'setup';
@@ -46,6 +49,8 @@ type Step =
46
49
  * le modèle voulu n'y figure pas.
47
50
  */
48
51
  suggestions?: string[];
52
+ /** Valeur configurée écartée parce qu'absente du catalogue découvert. */
53
+ stale?: string | null;
49
54
  }
50
55
  | { kind: 'done' };
51
56
  type LogEntry = { icon: string; label: string; detail?: string };
@@ -148,6 +153,35 @@ function stepTitle(step: Step) {
148
153
  return step.kind === 'done' ? 'Setup complete' : step.title;
149
154
  }
150
155
 
156
+ /**
157
+ * Partie « champ » d'une étape modèle : préremplissage, indication, catalogue.
158
+ *
159
+ * Le préremplissage ne reprend que ce qui est *déjà configuré*. Choisir pour
160
+ * l'opérateur le premier modèle découvert le filtrait aussitôt sur lui-même —
161
+ * la liste n'affichait plus qu'une entrée sur dix — et proposait une réponse
162
+ * qui n'avait aucune raison d'être la bonne. Quand rien n'est configuré, le
163
+ * champ reste vide et c'est l'indication grisée qui montre la forme attendue.
164
+ */
165
+ function suggestionField(discovered: string[], configured?: string | null, example = '') {
166
+ // Un modèle absent du catalogue ne doit pas être préremplí : il filtrerait
167
+ // la liste sur zéro résultat, et l'écran afficherait « No match » devant dix
168
+ // modèles disponibles. C'est exactement le cas du `BAAI/bge-m3` du scaffold
169
+ // face à un serveur qui nomme le même modèle autrement.
170
+ const usable = configured && (discovered.length === 0 || discovered.includes(configured))
171
+ ? configured
172
+ : null;
173
+ return {
174
+ // Sans catalogue il n'y a pas de liste à masquer : l'exemple redevient un
175
+ // préremplissage utile plutôt qu'un choix imposé.
176
+ prefill: usable ?? (discovered.length === 0 ? example : ''),
177
+ placeholder: discovered.length > 0 ? '↑↓ to browse the list, or type a name' : example,
178
+ suggestions: discovered,
179
+ /** Rappel affiché quand la valeur configurée a été écartée. */
180
+ stale: configured && !usable ? configured : null,
181
+ };
182
+ }
183
+
184
+
151
185
  export function SetupWizard(props: {
152
186
  mode: Mode;
153
187
  session?: any;
@@ -166,6 +200,15 @@ export function SetupWizard(props: {
166
200
  const [stepIndex, setStepIndex] = createSignal(0);
167
201
  const [selected, setSelected] = createSignal(0);
168
202
  const [input, setInput] = createSignal('');
203
+ /**
204
+ * Entrée survolée dans la liste des modèles, `-1` quand on tape librement.
205
+ *
206
+ * La liste n'était qu'un rappel : impossible d'y naviguer, donc le wizard
207
+ * préremplissait le premier modèle découvert pour qu'il y ait au moins une
208
+ * réponse. Ce préremplissage filtrait la liste sur lui-même — on ne voyait
209
+ * plus qu'un modèle sur dix, et rarement le bon.
210
+ */
211
+ const [highlight, setHighlight] = createSignal(-1);
169
212
  const [busy, setBusy] = createSignal(false);
170
213
  const [error, setError] = createSignal<string | null>(null);
171
214
 
@@ -182,8 +225,14 @@ export function SetupWizard(props: {
182
225
  });
183
226
 
184
227
  const currentGap = () => startupGaps()[stepIndex()];
185
- const dialogWidth = () => Math.max(44, Math.min(72, Math.floor(props.width * 0.72)));
186
- const dialogHeight = () => Math.max(22, Math.min(30, Math.floor(props.height * 0.72)));
228
+ // Le dialogue occupe désormais l'essentiel du terminal. Les libellés, notes
229
+ // et causes d'erreur sont des phrases, pas des étiquettes : à 72 colonnes et
230
+ // 30 lignes elles débordaient, alors que la moitié basse du cadre restait
231
+ // vide.
232
+ const dialogWidth = () => Math.max(50, Math.min(110, Math.floor(props.width * 0.86)));
233
+ const dialogHeight = () => Math.max(24, Math.min(40, Math.floor(props.height * 0.86)));
234
+ /** Largeur utile : bordure (1) + padding (1) de chaque côté. */
235
+ const textWidth = () => Math.max(20, dialogWidth() - 4);
187
236
  const left = () => Math.max(1, Math.floor((props.width - dialogWidth()) / 2));
188
237
  const top = () => Math.max(1, Math.floor((props.height - dialogHeight()) / 2));
189
238
 
@@ -295,25 +344,38 @@ export function SetupWizard(props: {
295
344
  return {
296
345
  kind: 'text',
297
346
  title: 'LLM configuration',
298
- label: isGateway
299
- ? 'Gateway base URL (example: http://gateway:4000/v1)'
300
- : `Base URL${example ? ` (example: ${example})` : ''}`,
347
+ label: isGateway ? 'Gateway base URL' : 'Base URL',
348
+ note: isGateway
349
+ ? 'Example: http://gateway:4000/v1'
350
+ : example
351
+ ? `Example: ${example}`
352
+ : undefined,
301
353
  prefill: llm().baseUrl || '',
302
354
  };
303
355
  }
304
356
  if (currentRoute === 'llm-apikey') {
305
- return { kind: 'text', title: 'LLM configuration', label: 'API key (required)', secret: true };
357
+ return {
358
+ kind: 'text',
359
+ title: 'LLM configuration',
360
+ label: 'API key',
361
+ note: 'Required. The model catalog is read in the background right after.',
362
+ secret: true,
363
+ };
306
364
  }
307
365
  if (currentRoute === 'llm-model') {
308
366
  const discovered = catalog()?.chat ?? [];
309
- const example = discovered[0] || fallbackModels(llm().engine)[0] || 'provider-agentic-model';
310
367
  return {
311
368
  kind: 'text',
312
369
  title: 'LLM configuration',
313
- label: `Model (example: ${example})`,
314
- note: catalogNote('Required: an agentic model with tool/function calling support.'),
315
- prefill: llm().model || example,
316
- suggestions: discovered,
370
+ // L'exemple vit dans l'indication du champ et dans la liste découverte ;
371
+ // le répéter dans le libellé en faisait la ligne la plus longue de
372
+ // l'écran, pour une information déjà visible deux fois.
373
+ label: 'Chat model',
374
+ note: 'Required: an agentic model with tool/function calling support.',
375
+ // Préremplir avec le premier modèle découvert filtrait la liste sur
376
+ // lui-même : neuf modèles sur dix devenaient invisibles, et la réponse
377
+ // proposée était arbitraire. Seul un modèle déjà configuré est repris.
378
+ ...suggestionField(discovered, llm().model, fallbackModels(llm().engine)[0] ?? 'provider-agentic-model'),
317
379
  };
318
380
  }
319
381
  if (currentRoute === 'vector-confirm') {
@@ -339,18 +401,16 @@ export function SetupWizard(props: {
339
401
  }
340
402
  if (currentRoute === 'vector-model') {
341
403
  const discovered = catalog()?.embedding ?? [];
342
- const defaultEmbedding =
343
- vector().embeddingModel ||
344
- discovered[0] ||
345
- fallbackModels(llm().engine, 'embedding')[0] ||
346
- '';
347
404
  return {
348
405
  kind: 'text',
349
406
  title: 'Vector search',
350
407
  label: 'Embedding model',
351
- note: catalogNote('Embeddings endpoint model.'),
352
- prefill: defaultEmbedding,
353
- suggestions: discovered,
408
+ note: 'Model exposed by the embeddings endpoint.',
409
+ ...suggestionField(
410
+ discovered,
411
+ vector().embeddingModel,
412
+ fallbackModels(llm().engine, 'embedding')[0] ?? '',
413
+ ),
354
414
  };
355
415
  }
356
416
  if (currentRoute === 'vector-rerank') {
@@ -358,15 +418,12 @@ export function SetupWizard(props: {
358
418
  }
359
419
  if (currentRoute === 'vector-rerank-model') {
360
420
  const discovered = catalog()?.rerank ?? [];
361
- const defaultReranker =
362
- vector().rerankerModel || discovered[0] || 'BAAI/bge-reranker-v2-m3';
363
421
  return {
364
422
  kind: 'text',
365
423
  title: 'Vector search',
366
424
  label: 'Rerank model',
367
- note: catalogNote('Leave reranking disabled if no rerank model is available.'),
368
- prefill: defaultReranker,
369
- suggestions: discovered,
425
+ note: 'Leave reranking disabled if no rerank model is available.',
426
+ ...suggestionField(discovered, vector().rerankerModel, 'BAAI/bge-reranker-v2-m3'),
370
427
  };
371
428
  }
372
429
  if (currentRoute === 'unregister-confirm') {
@@ -394,6 +451,7 @@ export function SetupWizard(props: {
394
451
  const s = step();
395
452
  setError(null);
396
453
  setInput((s as any).prefill ?? '');
454
+ setHighlight(-1);
397
455
  const items = (s as any).items ?? (s as any).options?.map((label: string) => ({ label })) ?? [{ label: 'x' }];
398
456
  let preferred = -1;
399
457
  // La question de routage est reposée à chaque passage — c'est plus simple
@@ -420,59 +478,172 @@ export function SetupWizard(props: {
420
478
  */
421
479
  const [catalog, setCatalog] = createSignal<any>(null);
422
480
  const [catalogError, setCatalogError] = createSignal<string | null>(null);
481
+ /** URL interrogée pendant que la découverte est en vol, sinon `null`. */
482
+ const [discovering, setDiscovering] = createSignal<string | null>(null);
483
+ /**
484
+ * Numéro de la découverte en cours.
485
+ *
486
+ * La découverte ne bloque plus l'étape suivante : une réponse tardive peut
487
+ * donc revenir alors que l'opérateur a déjà corrigé l'URL ou la clé et
488
+ * relancé une découverte. Sans ce compteur, la vieille réponse écraserait la
489
+ * neuve.
490
+ */
491
+ let discoveryRun = 0;
423
492
 
424
- function catalogNote(base: string) {
425
- const error = catalogError();
426
- if (error) return `${base} (model discovery unavailable: ${error})`;
427
- const found = catalog();
428
- if (!found) return base;
429
- if (!found.typed) {
430
- return `${base} (gateway has no /model/info: the three lists are unfiltered)`;
431
- }
432
- return base;
433
- }
434
-
435
- async function discoverModels(target?: {
493
+ /**
494
+ * Lance la découverte **sans l'attendre**.
495
+ *
496
+ * C'était la vraie cause du gel : la saisie de la clé partait dans un
497
+ * `await` muet, et l'écran suivant n'apparaissait qu'une fois le réseau
498
+ * retombé. Un catalogue n'est pourtant que du préremplissage — le champ
499
+ * texte fait foi. L'étape s'affiche donc tout de suite, et la liste se
500
+ * remplit quand elle arrive.
501
+ */
502
+ function startDiscovery(target?: {
436
503
  provider?: string;
437
504
  engine?: string;
438
505
  baseUrl?: string;
439
506
  apiKey?: string;
440
507
  }) {
441
- setCatalog(null);
442
- setCatalogError(null);
443
508
  const source = target ?? llm();
444
509
  const { provider, engine, baseUrl, apiKey } = source as any;
510
+ setCatalog(null);
511
+ setCatalogError(null);
445
512
  if (!baseUrl) return;
446
- try {
447
- if (normalizeProvider(provider) === 'ai-gateway') {
448
- const found = await fetchGatewayCatalog(baseUrl, apiKey);
449
- if (!found.ok) {
450
- setCatalogError(found.error ?? 'gateway unreachable');
451
- return;
452
- }
453
- setCatalog(found);
454
- return;
455
- }
456
- const chat = await fetchModels(provider, baseUrl, apiKey, { engine });
457
- const embedding = await fetchModels(provider, baseUrl, apiKey, {
458
- engine,
459
- kind: 'embedding',
513
+ discoveryRun += 1;
514
+ const run = discoveryRun;
515
+ setDiscovering(baseUrl);
516
+
517
+ const isGateway = normalizeProvider(provider) === 'ai-gateway';
518
+ // Un serveur direct expose un seul catalogue, non typé : interroger chat
519
+ // puis embeddings tapait deux fois la même URL avec les mêmes en-têtes,
520
+ // pour la même réponse — et payait deux fois le délai.
521
+ const promise = isGateway
522
+ ? fetchGatewayCatalog(baseUrl, apiKey, {
523
+ // La liste plate arrive la première et suffit à choisir : elle est
524
+ // affichée tout de suite, puis remplacée par le catalogue typé
525
+ // pendant que l'opérateur lit ses options.
526
+ onPartial: (partial: any) => {
527
+ if (run === discoveryRun) setCatalog(partial);
528
+ },
529
+ })
530
+ : fetchServerCatalog(provider, baseUrl, apiKey, { engine });
531
+
532
+ promise
533
+ .then((found: any) => {
534
+ if (run !== discoveryRun) return;
535
+ if (!found.ok) setCatalogError(found.error ?? 'endpoint unreachable');
536
+ else setCatalog(found);
537
+ })
538
+ .catch((err) => {
539
+ if (run !== discoveryRun) return;
540
+ setCatalogError(err instanceof Error ? err.message : String(err));
541
+ })
542
+ .finally(() => {
543
+ if (run === discoveryRun) setDiscovering(null);
460
544
  });
461
- if (!chat.ok && !embedding.ok) {
462
- setCatalogError(chat.error ?? 'server unreachable');
463
- return;
545
+ }
546
+
547
+ function modelCount(found: any) {
548
+ return new Set([...(found?.chat ?? []), ...(found?.embedding ?? []), ...(found?.rerank ?? [])]).size;
549
+ }
550
+
551
+ /**
552
+ * Bloc d'état de la découverte, rendu sous le champ de saisie.
553
+ *
554
+ * Il occupe la place laissée libre au milieu du dialogue, là où il n'y avait
555
+ * rien : ce qui est tenté, contre quelle URL, avec quel transport, et ce que
556
+ * ça a donné.
557
+ */
558
+ const discoveryLines = createMemo<Array<{ text: string; fg: string }>>(() => {
559
+ const width = textWidth();
560
+ const rows: Array<{ text: string; fg: string }> = [];
561
+ // L'indentation est appliquée à chaque ligne : `wrapText` normalise les
562
+ // espaces, un préfixe passé dans le texte serait perdu sur la première.
563
+ const indent = ' ';
564
+ const push = (text: string, fg: string, maxLines = 3) => {
565
+ for (const line of wrapText(text, width - indent.length, maxLines)) {
566
+ rows.push({ text: `${indent}${line}`, fg });
464
567
  }
465
- // Un serveur direct ne type pas ses modèles : les mêmes entrées
466
- // alimentent les trois questions.
467
- setCatalog({
468
- typed: false,
469
- chat: chat.models,
470
- embedding: embedding.models,
471
- rerank: embedding.models,
472
- });
473
- } catch (err) {
474
- setCatalogError(err instanceof Error ? err.message : String(err));
568
+ };
569
+ const pending = discovering();
570
+ const partial = catalog();
571
+ if (pending && partial) {
572
+ // La liste plate est déjà utilisable ; seul le typage manque encore.
573
+ rows.push({ text: `✓ ${modelCount(partial)} model(s) available`, fg: '#8BD5CA' });
574
+ rows.push({ text: '⟳ Refining chat/embedding/rerank types…', fg: '#FBBF24' });
575
+ return rows;
576
+ }
577
+ if (pending) {
578
+ rows.push({ text: '⟳ Reading the model catalog…', fg: '#FBBF24' });
579
+ push(pending, '#7F8C8D', 2);
580
+ push(transportSummary(), '#7F8C8D', 2);
581
+ return rows;
582
+ }
583
+ const failure = catalogError();
584
+ if (failure) {
585
+ rows.push({ text: '⚠ Model catalog unavailable', fg: '#FBBF24' });
586
+ push(`Cause: ${failure}`, '#F87171', 3);
587
+ push(`Transport: ${transportSummary()}`, '#7F8C8D', 2);
588
+ push('Type the model name below; it is used as-is.', '#7F8C8D', 2);
589
+ return rows;
590
+ }
591
+ const found = partial;
592
+ if (found) {
593
+ rows.push({ text: `✓ ${modelCount(found)} model(s) available`, fg: '#8BD5CA' });
594
+ push(
595
+ found.typed
596
+ ? 'Typed catalog: chat, embedding and rerank lists are filtered.'
597
+ : 'Untyped catalog: the server does not say which model does what, so the three lists are identical.',
598
+ '#7F8C8D',
599
+ 3,
600
+ );
601
+ }
602
+ const stale = (step() as any).stale;
603
+ if (stale) {
604
+ push(`Configured "${stale}" is not in this catalog — pick one below.`, '#FBBF24', 2);
605
+ }
606
+ return rows;
607
+ });
608
+
609
+ /** Étapes de découverte : le bloc d'état n'a de sens que là. */
610
+ const DISCOVERY_ROUTES = new Set(['llm-apikey', 'llm-model', 'vector-model', 'vector-rerank-model']);
611
+ const showDiscoveryPanel = () => discovering() !== null || DISCOVERY_ROUTES.has(route());
612
+
613
+ /**
614
+ * Position dans la phase courante, affichée en haut à droite.
615
+ *
616
+ * La séquence est reconstruite depuis les choix déjà faits, parce qu'elle
617
+ * est réellement variable : une gateway saute la question du moteur, un
618
+ * moteur hébergé saute l'URL, Ollama saute la clé. Annoncer un total fixe
619
+ * serait faux.
620
+ */
621
+ function llmFlow() {
622
+ const provider = llm().provider;
623
+ const engine = llm().engine;
624
+ const steps = ['llm-provider'];
625
+ if (normalizeProvider(provider) !== 'ai-gateway') steps.push('llm-engine');
626
+ if (requiresBaseUrl(provider, engine)) steps.push('llm-baseurl');
627
+ if (normalizeProvider(provider) === 'ai-gateway' || normalizeEngine(engine) !== 'ollama') {
628
+ steps.push('llm-apikey');
475
629
  }
630
+ steps.push('llm-model');
631
+ return steps;
632
+ }
633
+
634
+ function vectorFlow() {
635
+ const steps = ['vector-confirm', 'vector-baseurl', 'vector-apikey', 'vector-model', 'vector-rerank'];
636
+ if (vector().rerankEnabled) steps.push('vector-rerank-model');
637
+ return steps;
638
+ }
639
+
640
+ function progressLabel() {
641
+ const current = route();
642
+ for (const flow of [llmFlow(), vectorFlow()]) {
643
+ const index = flow.indexOf(current);
644
+ if (index >= 0) return `Step ${index + 1} of ${flow.length}`;
645
+ }
646
+ return '';
476
647
  }
477
648
 
478
649
  /** Vrai quand l'URL vecteur ne pointe plus le même hôte que le LLM. */
@@ -513,9 +684,13 @@ export function SetupWizard(props: {
513
684
  // URL the operator just entered, instead of the scaffold's fake one.
514
685
  baseUrl: configuredValue(config.retrieval.vector.baseUrl),
515
686
  apiKey: configuredValue(config.retrieval.vector.apiKey),
516
- embeddingModel: config.retrieval.vector.embeddingModel,
687
+ // Les noms de modèles étaient les deux seuls champs à échapper au
688
+ // filtre : le `BAAI/bge-m3` du scaffold arrivait donc dans le wizard
689
+ // comme une réponse choisie, et servait de filtre sur un catalogue
690
+ // qui nomme le même modèle autrement.
691
+ embeddingModel: configuredValue(config.retrieval.vector.embeddingModel),
517
692
  rerankEnabled: config.retrieval.vector.rerankEnabled,
518
- rerankerModel: config.retrieval.vector.rerankerModel,
693
+ rerankerModel: configuredValue(config.retrieval.vector.rerankerModel),
519
694
  });
520
695
  }
521
696
  } catch { /* ignore — new workspace or unreadable profile */ }
@@ -768,6 +943,10 @@ export function SetupWizard(props: {
768
943
  if (lang.length < 2) return setError('Please enter a 2-character language code (e.g. fr, en).');
769
944
  const context = currentWorkspaceContext(props.session, currentGap()?.context ?? targetWorkspace());
770
945
  if (context?.workspacePath) writeLanguageConfig(context.workspacePath, context.profileName ?? 'default', lang);
946
+ // Sans cette ligne, le récapitulatif continuait d'afficher la langue lue
947
+ // dans le scaffold au moment de la création du workspace (`en`) au lieu
948
+ // de celle qui vient d'être saisie et écrite.
949
+ setLanguage(lang);
771
950
  navigate('llm-provider');
772
951
  return;
773
952
  }
@@ -799,7 +978,7 @@ export function SetupWizard(props: {
799
978
  setLlm((old: any) => ({ ...old, baseUrl: value }));
800
979
  // Ollama n'exige pas de clé : on peut découvrir tout de suite.
801
980
  if (llm().engine === 'ollama') {
802
- await discoverModels();
981
+ startDiscovery({ ...llm(), baseUrl: value });
803
982
  return navigate('llm-model');
804
983
  }
805
984
  return navigate('llm-apikey');
@@ -807,7 +986,9 @@ export function SetupWizard(props: {
807
986
  if (currentRoute === 'llm-apikey') {
808
987
  if (!value) return setError('API key is required.');
809
988
  setLlm((old: any) => ({ ...old, apiKey: value }));
810
- await discoverModels();
989
+ // Pas de `await` : l'étape modèle s'affiche immédiatement et la liste
990
+ // s'y remplit toute seule.
991
+ startDiscovery({ ...llm(), apiKey: value });
811
992
  return navigate('llm-model');
812
993
  }
813
994
  if (currentRoute === 'vector-baseurl') {
@@ -835,7 +1016,7 @@ export function SetupWizard(props: {
835
1016
  // que l'URL diverge, et proposer les modèles de chat de l'un pour les
836
1017
  // embeddings de l'autre n'a aucun sens.
837
1018
  if (vectorBaseUrlDiverged()) {
838
- await discoverModels({ ...vector(), apiKey });
1019
+ startDiscovery({ ...vector(), apiKey });
839
1020
  }
840
1021
  return navigate('vector-model');
841
1022
  }
@@ -878,6 +1059,8 @@ export function SetupWizard(props: {
878
1059
  });
879
1060
 
880
1061
  useKeyboard((key: any) => {
1062
+ // La découverte ne bloque volontairement rien : on peut taper le nom du
1063
+ // modèle et valider avant même que le catalogue n'arrive.
881
1064
  if (busy()) return;
882
1065
  const s = step();
883
1066
  const keyName = String(key.name ?? '').toLowerCase();
@@ -923,13 +1106,33 @@ export function SetupWizard(props: {
923
1106
  const closeIdx = pasted.indexOf('\x1b[201~');
924
1107
  if (closeIdx !== -1) pasted = pasted.slice(0, closeIdx);
925
1108
  pasted = pasted.split('\r').join('');
926
- if (pasted) setInput((value) => value + pasted);
1109
+ if (pasted) { setHighlight(-1); setInput((value) => value + pasted); }
927
1110
  return;
928
1111
  }
929
1112
  // Explicit clipboard paste (Ctrl+V or Cmd+V on macOS)
930
1113
  if (isPaste) {
931
1114
  const pasted = readClipboard();
932
- if (pasted) setInput((value) => value + pasted);
1115
+ if (pasted) { setHighlight(-1); setInput((value) => value + pasted); }
1116
+ return;
1117
+ }
1118
+
1119
+ // Navigation dans le catalogue. Le champ reste la vérité : les flèches
1120
+ // ne font que survoler, et il faut valider pour écrire dans le champ.
1121
+ const matches = filteredSuggestions().matches;
1122
+ if (matches.length > 0 && (keyName === 'down' || keyName === 'up')) {
1123
+ setHighlight((value) => {
1124
+ if (keyName === 'down') return value + 1 >= matches.length ? -1 : value + 1;
1125
+ return value <= -1 ? matches.length - 1 : value - 1;
1126
+ });
1127
+ return;
1128
+ }
1129
+ const hovered = highlight() >= 0 ? matches[highlight()] : null;
1130
+ // Deux temps voulus : le premier Enter dépose le modèle survolé dans le
1131
+ // champ, le second valide l'étape. On peut donc relire, corriger ou
1132
+ // compléter ce qu'on vient de choisir.
1133
+ if (hovered && (isEnter || keyName === 'tab')) {
1134
+ setInput(hovered);
1135
+ setHighlight(-1);
933
1136
  return;
934
1137
  }
935
1138
  if (isEnter) {
@@ -937,10 +1140,12 @@ export function SetupWizard(props: {
937
1140
  return;
938
1141
  }
939
1142
  if (keyName === 'backspace') {
1143
+ setHighlight(-1);
940
1144
  setInput((value) => value.slice(0, -1));
941
1145
  return;
942
1146
  }
943
1147
  if (sequence.length >= 1 && !sequence.startsWith('\x1b') && sequence >= ' ') {
1148
+ setHighlight(-1);
944
1149
  setInput((value) => value + sequence);
945
1150
  }
946
1151
  }
@@ -957,8 +1162,10 @@ export function SetupWizard(props: {
957
1162
  const displayValue = () => {
958
1163
  const s = step();
959
1164
  if (s.kind !== 'text') return '';
960
- const value = input();
961
- return value || (s.secret ? (s.placeholder ?? '') : '');
1165
+ // Le champ vide affiche son indication en grisé, quel que soit le type
1166
+ // d'étape : c'est ce qui permet de ne plus préremplir un modèle arbitraire
1167
+ // tout en montrant à quoi ressemble une réponse valable.
1168
+ return input() || (s.placeholder ?? '');
962
1169
  };
963
1170
  const inputHasValue = () => step().kind === 'text' && input().length > 0;
964
1171
 
@@ -971,17 +1178,30 @@ export function SetupWizard(props: {
971
1178
  * seule vérité et on n'affiche qu'un rappel filtré — ce qui règle d'un coup
972
1179
  * les trois cas : liste énorme, modèle absent, endpoint injoignable.
973
1180
  */
974
- const SUGGESTION_ROWS = 4;
1181
+ const SUGGESTION_ROWS = 6;
975
1182
  const filteredSuggestions = createMemo(() => {
976
1183
  const current = step() as any;
977
1184
  const all: string[] = current?.suggestions ?? [];
978
- if (all.length === 0) return { rows: [] as string[], total: 0, matched: 0 };
1185
+ if (all.length === 0) {
1186
+ return { matches: [] as string[], rows: [] as string[], offset: 0, total: 0, matched: 0 };
1187
+ }
979
1188
  const needle = input().trim().toLowerCase();
980
1189
  const matches = needle
981
1190
  ? all.filter((item) => item.toLowerCase().includes(needle))
982
1191
  : all;
1192
+
1193
+ // Fenêtre glissante autour de l'élément survolé : sans elle, seules les
1194
+ // premières entrées étaient atteignables et un catalogue de 200 modèles
1195
+ // restait invisible au delà de la sixième ligne.
1196
+ const cursor = highlight();
1197
+ const offset =
1198
+ cursor < SUGGESTION_ROWS
1199
+ ? 0
1200
+ : Math.min(cursor - SUGGESTION_ROWS + 1, Math.max(0, matches.length - SUGGESTION_ROWS));
983
1201
  return {
984
- rows: matches.slice(0, SUGGESTION_ROWS),
1202
+ matches,
1203
+ rows: matches.slice(offset, offset + SUGGESTION_ROWS),
1204
+ offset,
985
1205
  total: all.length,
986
1206
  matched: matches.length,
987
1207
  };
@@ -994,20 +1214,42 @@ export function SetupWizard(props: {
994
1214
  const showLine3 = () => displayValue().length > lineWidth() * 2;
995
1215
  const contextPath = () => targetWorkspace()?.workspacePath ?? (currentGap()?.context?.workspacePath ?? null);
996
1216
 
997
- const contextSummary = createMemo(() => {
998
- const parts: string[] = [];
999
- const wp = targetWorkspace()?.workspacePath ?? (currentGap()?.context?.workspacePath ?? null);
1000
- if (wp) parts.push(wp);
1001
- if (language()) parts.push(`lang:${language()}`);
1002
- const p = llm().provider;
1003
- if (p) parts.push(p);
1004
- if (llm().baseUrl) parts.push(llm().baseUrl);
1005
- if (llm().apiKey) parts.push('key:***');
1006
- const model = llm().model;
1007
- if (model) parts.push(model);
1008
- if (vector().baseUrl && vector().baseUrl !== llm().baseUrl) parts.push(`vec:${vector().baseUrl}`);
1009
- if (vector().embeddingModel) parts.push(vector().embeddingModel);
1010
- return parts.join(' ');
1217
+ /**
1218
+ * Récapitulatif de bas de cadre, en lignes `étiquette valeur`.
1219
+ *
1220
+ * Il était concaténé en une seule ligne, coupée net dès le deuxième champ :
1221
+ * le chemin du workspace mangeait la place, et l'URL — l'information qu'on
1222
+ * vient justement de saisir — n'apparaissait jamais en entier.
1223
+ */
1224
+ const CONTEXT_LABEL_WIDTH = 11;
1225
+ const contextLines = createMemo(() => {
1226
+ const rows: Array<[string, string]> = [];
1227
+ const workspacePath = targetWorkspace()?.workspacePath ?? currentGap()?.context?.workspacePath ?? null;
1228
+ if (workspacePath) {
1229
+ rows.push(['Workspace', language() ? `${workspacePath} · lang ${language()}` : workspacePath]);
1230
+ }
1231
+ // Trois lignes au plus, et seulement celles de la phase en cours : ce
1232
+ // récapitulatif ne doit jamais pousser le pied de cadre hors de la boîte.
1233
+ if (route().startsWith('vector-')) {
1234
+ const vectorUrl = vector().baseUrl || llm().baseUrl;
1235
+ if (vectorUrl) rows.push(['Vector', vectorUrl]);
1236
+ if (vector().embeddingModel) rows.push(['Embedding', vector().embeddingModel]);
1237
+ } else {
1238
+ const routing = [llm().provider, llm().engine].filter(Boolean).join(' · ');
1239
+ if (routing) rows.push(['Routing', routing]);
1240
+ if (llm().baseUrl) {
1241
+ rows.push(['Endpoint', llm().apiKey ? `${llm().baseUrl} · key set` : llm().baseUrl]);
1242
+ }
1243
+ }
1244
+
1245
+ const width = textWidth() - CONTEXT_LABEL_WIDTH;
1246
+ const lines: string[] = [];
1247
+ for (const [label, value] of rows.slice(0, 3)) {
1248
+ wrapText(value, width, 2).forEach((line, index) => {
1249
+ lines.push(`${(index === 0 ? label : '').padEnd(CONTEXT_LABEL_WIDTH)}${line}`);
1250
+ });
1251
+ }
1252
+ return lines;
1011
1253
  });
1012
1254
 
1013
1255
  return (
@@ -1026,17 +1268,34 @@ export function SetupWizard(props: {
1026
1268
  flexDirection="column"
1027
1269
  overflow="hidden"
1028
1270
  >
1029
- <For each={logs().slice(-4)}>
1030
- {(entry) => <text height={1} fg={entry.icon === '✓' ? '#8BD5CA' : '#9CA3AF'}>{entry.icon} {entry.label}{entry.detail ? ` - ${entry.detail}` : ''}</text>}
1031
- </For>
1032
- <text height={1} fg="#FBBF24">{busy() ? `${stepTitle(step())} - working...` : stepTitle(step())}</text>
1271
+ <Show when={logs().length > 0}>
1272
+ <For each={logs().slice(-3)}>
1273
+ {(entry) => <text height={1} fg={entry.icon === '✓' ? '#8BD5CA' : '#9CA3AF'}>{entry.icon} {entry.label}{entry.detail ? ` - ${entry.detail}` : ''}</text>}
1274
+ </For>
1275
+ <text height={1}>{''}</text>
1276
+ </Show>
1277
+
1278
+ {/* En-tête : phase à gauche, progression dans la phase à droite. */}
1279
+ <box height={1} flexDirection="row">
1280
+ <text fg="#8BD5CA">{stepTitle(step())}</text>
1281
+ <box flexGrow={1} />
1282
+ <text fg="#4B5563">{busy() ? 'working…' : progressLabel()}</text>
1283
+ </box>
1284
+ <text height={1} fg="#2A3441">{'─'.repeat(textWidth())}</text>
1033
1285
  <text height={1}>{''}</text>
1286
+
1034
1287
  <Show when={(step() as any).message || (step() as any).label}>
1035
- <text height={1} fg="#D6DEE8">{(step() as any).message ?? (step() as any).label}</text>
1288
+ <For each={wrapText((step() as any).message ?? (step() as any).label, textWidth(), 5)}>
1289
+ {(line) => <text height={1} fg="#D6DEE8">{line}</text>}
1290
+ </For>
1036
1291
  </Show>
1037
1292
  <Show when={(step() as any).note}>
1038
- <text height={1} fg="#9CA3AF">{(step() as any).note}</text>
1293
+ <For each={wrapText((step() as any).note, textWidth(), 4)}>
1294
+ {(line) => <text height={1} fg="#9CA3AF">{line}</text>}
1295
+ </For>
1039
1296
  </Show>
1297
+ <text height={1}>{''}</text>
1298
+
1040
1299
  <Show when={step().kind === 'text'}>
1041
1300
  <box
1042
1301
  height={5}
@@ -1072,21 +1331,37 @@ export function SetupWizard(props: {
1072
1331
  </box>
1073
1332
  </Show>
1074
1333
  <Show when={step().kind === 'text' && filteredSuggestions().total > 0}>
1075
- <text height={1} fg="#7F8C8D">
1076
- {filteredSuggestions().matched === 0
1077
- ? `no match among ${filteredSuggestions().total} discovered model(s) — the typed value is used as-is`
1078
- : `${filteredSuggestions().matched}/${filteredSuggestions().total} discovered model(s) — type to filter, the typed value wins`}
1079
- </text>
1080
- <For each={filteredSuggestions().rows}>
1081
- {(suggestion) => (
1082
- <text height={1} fg={suggestion === input().trim() ? '#8BD5CA' : '#9CA3AF'}>
1083
- {` ${suggestion === input().trim() ? '*' : '·'} ${suggestion}`}
1084
- </text>
1334
+ <text height={1}>{''}</text>
1335
+ <For
1336
+ each={wrapText(
1337
+ filteredSuggestions().matched === 0
1338
+ ? `No match among ${filteredSuggestions().total} model(s) — the typed value is used as-is.`
1339
+ : `${filteredSuggestions().matched} of ${filteredSuggestions().total} model(s) — ↑↓ to browse, Enter picks, the typed value wins.`,
1340
+ textWidth(),
1341
+ 2,
1085
1342
  )}
1343
+ >
1344
+ {(line) => <text height={1} fg="#7F8C8D">{line}</text>}
1345
+ </For>
1346
+ <For each={filteredSuggestions().rows}>
1347
+ {(suggestion, index) => {
1348
+ const position = () => filteredSuggestions().offset + index();
1349
+ const hovered = () => position() === highlight();
1350
+ const chosen = () => suggestion === input().trim();
1351
+ return (
1352
+ <text
1353
+ height={1}
1354
+ fg={hovered() ? '#111318' : chosen() ? '#8BD5CA' : '#9CA3AF'}
1355
+ bg={hovered() ? '#8BD5CA' : '#111318'}
1356
+ >
1357
+ {`${hovered() ? ' > ' : chosen() ? ' ● ' : ' · '}${suggestion}`.slice(0, textWidth())}
1358
+ </text>
1359
+ );
1360
+ }}
1086
1361
  </For>
1087
- <Show when={filteredSuggestions().matched > SUGGESTION_ROWS}>
1362
+ <Show when={filteredSuggestions().matched > filteredSuggestions().rows.length}>
1088
1363
  <text height={1} fg="#7F8C8D">
1089
- {` … ${filteredSuggestions().matched - SUGGESTION_ROWS} more`}
1364
+ {` … ${filteredSuggestions().matched - filteredSuggestions().rows.length} more (↑↓ to scroll)`}
1090
1365
  </text>
1091
1366
  </Show>
1092
1367
  </Show>
@@ -1098,25 +1373,50 @@ export function SetupWizard(props: {
1098
1373
  fg={(item as any).muted ? '#4B5563' : index() === selected() ? '#111318' : '#D6DEE8'}
1099
1374
  bg={index() === selected() && !(item as any).muted ? '#8BD5CA' : '#111318'}
1100
1375
  >
1101
- {(item as any).muted ? ' ---' : `${index() === selected() ? '> ' : ' '}${item.label}`}
1376
+ {(item as any).muted ? ' ───' : `${index() === selected() ? '> ' : ' '}${item.label}`}
1102
1377
  </text>
1103
1378
  )}
1104
1379
  </For>
1105
1380
  </Show>
1106
1381
 
1382
+ {/* État de la découverte : occupe l'espace laissé libre au milieu. */}
1383
+ <Show when={showDiscoveryPanel() && discoveryLines().length > 0}>
1384
+ <text height={1}>{''}</text>
1385
+ <For each={discoveryLines()}>
1386
+ {(row) => <text height={1} fg={row.fg}>{row.text}</text>}
1387
+ </For>
1388
+ </Show>
1389
+
1107
1390
  <Show when={error()}>
1108
- {(message) => <text height={6} fg="#F87171">{message()}</text>}
1391
+ {(message) => (
1392
+ <>
1393
+ <text height={1}>{''}</text>
1394
+ <For each={wrapText(message(), textWidth(), 5)}>
1395
+ {(line) => <text height={1} fg="#F87171">{line}</text>}
1396
+ </For>
1397
+ </>
1398
+ )}
1109
1399
  </Show>
1400
+
1110
1401
  <box flexGrow={1} />
1111
- <Show when={contextSummary()}>
1112
- <text height={1} fg="#374151">{contextSummary()}</text>
1402
+ <Show when={contextLines().length > 0}>
1403
+ <text height={1} fg="#2A3441">{'─'.repeat(textWidth())}</text>
1404
+ <For each={contextLines()}>
1405
+ {(line) => <text height={1} fg="#4B5563">{line}</text>}
1406
+ </For>
1113
1407
  </Show>
1114
1408
  <text height={1}>{''}</text>
1115
1409
  <box height={1} flexDirection="row">
1116
- <text fg="#7F8C8D">{step().kind === 'text' ? 'Enter Confirm Esc Skip' : 'Up/Down Enter Select Esc Skip'}</text>
1410
+ <text fg="#7F8C8D">
1411
+ {step().kind !== 'text'
1412
+ ? '↑↓ Move Enter Select Esc Skip'
1413
+ : filteredSuggestions().matched > 0
1414
+ ? '↑↓ Browse Enter Pick / Confirm Esc Skip'
1415
+ : 'Enter Confirm Esc Skip'}
1416
+ </text>
1117
1417
  <box flexGrow={1} />
1118
1418
  <Show when={routeHistory().length > 0}>
1119
- <text fg="#7F8C8D">Ctrl+Z ←</text>
1419
+ <text fg="#7F8C8D">Ctrl+Z Back</text>
1120
1420
  </Show>
1121
1421
  </box>
1122
1422
  </box>