@dotdrelle/wiki-manager 0.15.32 → 0.15.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,17 @@ import { execFileSync } from 'node:child_process';
3
3
  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
- import { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
6
+ import {
7
+ DISCOVERY_TIMEOUT_MS,
8
+ defaultBaseUrl,
9
+ fallbackModels,
10
+ fetchGatewayCatalog,
11
+ fetchServerCatalog,
12
+ normalizeEngine,
13
+ normalizeProvider,
14
+ requiresBaseUrl,
15
+ transportSummary,
16
+ } from '../core/modelFetch.js';
7
17
  import { checkInternetConnectivity } from '../core/startupCheck.js';
8
18
  import {
9
19
  createNewWorkspace,
@@ -17,6 +27,7 @@ import {
17
27
  } from '../core/wikiSetup.js';
18
28
  import { listWorkspaces, workspacesDir } from '../core/workspaces.js';
19
29
  import { loadWikircProfile } from '../core/wikirc.js';
30
+ import { wrapText } from './wrapText.js';
20
31
 
21
32
  type Gap = { kind: 'agents' | 'network' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
22
33
  type Mode = 'startup' | 'setup';
@@ -24,11 +35,42 @@ type Step =
24
35
  | { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
25
36
  | { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
26
37
  | { kind: 'select'; title: string; label: string; options: string[]; note?: string }
27
- | { kind: 'text'; title: string; label: string; note?: string; placeholder?: string; prefill?: string; secret?: boolean }
38
+ | {
39
+ kind: 'text';
40
+ title: string;
41
+ label: string;
42
+ note?: string;
43
+ placeholder?: string;
44
+ prefill?: string;
45
+ secret?: boolean;
46
+ /**
47
+ * Catalogue découvert. Purement indicatif : le champ texte fait foi, ce
48
+ * qui garde l'étape utilisable quand l'endpoint est injoignable ou quand
49
+ * le modèle voulu n'y figure pas.
50
+ */
51
+ suggestions?: string[];
52
+ /** Valeur configurée écartée parce qu'absente du catalogue découvert. */
53
+ stale?: string | null;
54
+ }
28
55
  | { kind: 'done' };
29
56
  type LogEntry = { icon: string; label: string; detail?: string };
30
57
 
31
- const PROVIDERS = ['OpenAI', 'Anthropic', 'Ollama (local)', 'Other (OpenAI-compatible)'];
58
+ // Deux axes, deux questions. `provider` dit où l'on tape, `engine` dit
59
+ // comment se comporte le serveur en face. Les fusionner était précisément ce
60
+ // qui empêchait de décrire une gateway.
61
+ const PROVIDERS = [
62
+ 'Direct server (OpenAI-compatible)',
63
+ 'AI gateway (LiteLLM, Bifrost, Portkey…)',
64
+ ];
65
+ const ENGINE_OPTIONS = [
66
+ 'OpenAI',
67
+ 'Anthropic',
68
+ 'Ollama (local)',
69
+ 'vLLM (local)',
70
+ 'MLX (local)',
71
+ 'Albert',
72
+ 'Other (generic OpenAI-compatible)',
73
+ ];
32
74
  // The scaffolded .wikirc.yaml ships fake endpoints and secrets so the file
33
75
  // documents its own shape (`https://mon-provider.example.com/v1`,
34
76
  // `http://infinity.local:7997/v1`, `YOUR_LLM_API_KEY`…). Preloading them into
@@ -43,12 +85,10 @@ function configuredValue(value: unknown) {
43
85
  }
44
86
  const MAIN_MENU = ['Agents', 'Workspaces', 'LLM configuration', 'Vector search', '---', 'Close'];
45
87
 
46
- function defaultBaseUrl(provider: string) {
47
- if (provider === 'ollama') return 'http://localhost:11434';
48
- if (provider === 'anthropic') return 'https://api.anthropic.com';
49
- if (provider === 'openai') return 'https://api.openai.com';
50
- if (provider === 'openai-compatible') return 'http://localhost:8000';
51
- return '';
88
+ // Les défauts et la question « faut-il demander une baseUrl ? » vivent dans
89
+ // core/modelFetch.js, source unique partagée avec la découverte.
90
+ function exampleBaseUrl(provider: string, engine: string) {
91
+ return defaultBaseUrl(provider, engine);
52
92
  }
53
93
 
54
94
  function currentWorkspaceContext(session: any, fallback?: any) {
@@ -113,6 +153,35 @@ function stepTitle(step: Step) {
113
153
  return step.kind === 'done' ? 'Setup complete' : step.title;
114
154
  }
115
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
+
116
185
  export function SetupWizard(props: {
117
186
  mode: Mode;
118
187
  session?: any;
@@ -131,6 +200,15 @@ export function SetupWizard(props: {
131
200
  const [stepIndex, setStepIndex] = createSignal(0);
132
201
  const [selected, setSelected] = createSignal(0);
133
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);
134
212
  const [busy, setBusy] = createSignal(false);
135
213
  const [error, setError] = createSignal<string | null>(null);
136
214
 
@@ -147,8 +225,14 @@ export function SetupWizard(props: {
147
225
  });
148
226
 
149
227
  const currentGap = () => startupGaps()[stepIndex()];
150
- const dialogWidth = () => Math.max(44, Math.min(72, Math.floor(props.width * 0.72)));
151
- 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);
152
236
  const left = () => Math.max(1, Math.floor((props.width - dialogWidth()) / 2));
153
237
  const top = () => Math.max(1, Math.floor((props.height - dialogHeight()) / 2));
154
238
 
@@ -239,31 +323,59 @@ export function SetupWizard(props: {
239
323
  kind: 'select',
240
324
  title: 'LLM configuration',
241
325
  label: context?.configError
242
- ? `${context.configError} Select a provider after creating or fixing the config:`
243
- : `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}. Select a provider:`,
326
+ ? `${context.configError} Select how requests are routed after creating or fixing the config:`
327
+ : `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}. How are requests routed?`,
244
328
  options: PROVIDERS,
329
+ note: 'A gateway is external infrastructure you deploy yourself; llm-wiki only reads its model catalog.',
330
+ };
331
+ }
332
+ if (currentRoute === 'llm-engine') {
333
+ return {
334
+ kind: 'select',
335
+ title: 'LLM configuration',
336
+ label: 'Which server is answering?',
337
+ options: ENGINE_OPTIONS,
338
+ note: 'Drives request-shaping workarounds and the `wiki doctor` calibration.',
245
339
  };
246
340
  }
247
341
  if (currentRoute === 'llm-baseurl') {
248
- const example = defaultBaseUrl(llm().provider);
342
+ const isGateway = llm().provider === 'ai-gateway';
343
+ const example = exampleBaseUrl(llm().provider, llm().engine);
249
344
  return {
250
345
  kind: 'text',
251
346
  title: 'LLM configuration',
252
- label: `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,
253
353
  prefill: llm().baseUrl || '',
254
354
  };
255
355
  }
256
356
  if (currentRoute === 'llm-apikey') {
257
- 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
+ };
258
364
  }
259
365
  if (currentRoute === 'llm-model') {
260
- const example = fallbackModels(llm().provider)[0] || 'provider-agentic-model';
366
+ const discovered = catalog()?.chat ?? [];
261
367
  return {
262
368
  kind: 'text',
263
369
  title: 'LLM configuration',
264
- label: `Model (example: ${example})`,
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',
265
374
  note: 'Required: an agentic model with tool/function calling support.',
266
- prefill: llm().model || '',
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'),
267
379
  };
268
380
  }
269
381
  if (currentRoute === 'vector-confirm') {
@@ -274,19 +386,45 @@ export function SetupWizard(props: {
274
386
  return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
275
387
  }
276
388
  if (currentRoute === 'vector-apikey') {
277
- const hint = llm().apiKey ? '(leave empty to reuse LLM key)' : undefined;
278
- return { kind: 'text', title: 'Vector search', label: 'Vector API key', placeholder: hint, secret: true };
389
+ // L'héritage n'est proposé que tant que l'URL n'a pas divergé : sinon la
390
+ // clé du LLM — celle de la gateway, qui ouvre tous les providers —
391
+ // partirait vers un autre hôte.
392
+ const diverged = vectorBaseUrlDiverged();
393
+ const hint = !diverged && llm().apiKey ? '(leave empty to reuse LLM key)' : undefined;
394
+ return {
395
+ kind: 'text',
396
+ title: 'Vector search',
397
+ label: diverged ? 'Vector API key (required: different host)' : 'Vector API key',
398
+ placeholder: hint,
399
+ secret: true,
400
+ };
279
401
  }
280
402
  if (currentRoute === 'vector-model') {
281
- const defaultEmbedding = vector().embeddingModel || fallbackModels(vector().provider || llm().provider, 'embedding')[0] || '';
282
- return { kind: 'text', title: 'Vector search', label: 'Embedding model', prefill: defaultEmbedding };
403
+ const discovered = catalog()?.embedding ?? [];
404
+ return {
405
+ kind: 'text',
406
+ title: 'Vector search',
407
+ label: 'Embedding model',
408
+ note: 'Model exposed by the embeddings endpoint.',
409
+ ...suggestionField(
410
+ discovered,
411
+ vector().embeddingModel,
412
+ fallbackModels(llm().engine, 'embedding')[0] ?? '',
413
+ ),
414
+ };
283
415
  }
284
416
  if (currentRoute === 'vector-rerank') {
285
417
  return { kind: 'confirm', title: 'Vector search', message: 'Enable reranking?', yesLabel: 'Enable', noLabel: 'Skip' };
286
418
  }
287
419
  if (currentRoute === 'vector-rerank-model') {
288
- const defaultReranker = vector().rerankerModel || 'BAAI/bge-reranker-v2-m3';
289
- return { kind: 'text', title: 'Vector search', label: 'Rerank model', prefill: defaultReranker };
420
+ const discovered = catalog()?.rerank ?? [];
421
+ return {
422
+ kind: 'text',
423
+ title: 'Vector search',
424
+ label: 'Rerank model',
425
+ note: 'Leave reranking disabled if no rerank model is available.',
426
+ ...suggestionField(discovered, vector().rerankerModel, 'BAAI/bge-reranker-v2-m3'),
427
+ };
290
428
  }
291
429
  if (currentRoute === 'unregister-confirm') {
292
430
  const workspace = targetWorkspace();
@@ -313,14 +451,209 @@ export function SetupWizard(props: {
313
451
  const s = step();
314
452
  setError(null);
315
453
  setInput((s as any).prefill ?? '');
454
+ setHighlight(-1);
316
455
  const items = (s as any).items ?? (s as any).options?.map((label: string) => ({ label })) ?? [{ label: 'x' }];
317
456
  let preferred = -1;
457
+ // La question de routage est reposée à chaque passage — c'est plus simple
458
+ // et plus honnête que de la mémoriser dans un champ dédié. On se contente
459
+ // de présélectionner ce que le wikirc déclare déjà.
318
460
  if (route() === 'llm-provider' && llm().provider) {
319
- preferred = PROVIDERS.findIndex((p) => normalizeProvider(p) === normalizeProvider(llm().provider));
461
+ preferred = PROVIDERS.findIndex(
462
+ (p) => normalizeProvider(p) === normalizeProvider(llm().provider),
463
+ );
464
+ }
465
+ if (route() === 'llm-engine' && llm().engine) {
466
+ preferred = ENGINE_OPTIONS.findIndex(
467
+ (e) => normalizeEngine(e) === normalizeEngine(llm().engine),
468
+ );
320
469
  }
321
470
  setSelected(preferred >= 0 ? preferred : firstSelectableIndex(items));
322
471
  });
323
472
 
473
+ /**
474
+ * Catalogue découvert auprès du serveur ou de la gateway. Il ne sert qu'à
475
+ * préremplir : le champ texte reste la vérité, ce qui garde le wizard
476
+ * utilisable quand l'endpoint est injoignable ou quand le modèle voulu n'y
477
+ * figure pas.
478
+ */
479
+ const [catalog, setCatalog] = createSignal<any>(null);
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;
492
+
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?: {
503
+ provider?: string;
504
+ engine?: string;
505
+ baseUrl?: string;
506
+ apiKey?: string;
507
+ }) {
508
+ const source = target ?? llm();
509
+ const { provider, engine, baseUrl, apiKey } = source as any;
510
+ setCatalog(null);
511
+ setCatalogError(null);
512
+ if (!baseUrl) return;
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);
544
+ });
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 });
567
+ }
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');
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 '';
647
+ }
648
+
649
+ /** Vrai quand l'URL vecteur ne pointe plus le même hôte que le LLM. */
650
+ function vectorBaseUrlDiverged() {
651
+ const vectorUrl = vector().baseUrl;
652
+ const llmUrl = llm().baseUrl;
653
+ if (!vectorUrl || !llmUrl) return false;
654
+ return vectorUrl.replace(/\/+$/, '') !== llmUrl.replace(/\/+$/, '');
655
+ }
656
+
324
657
  function preloadWikirc(context: any) {
325
658
  const workspacePath = context?.workspacePath;
326
659
  if (!workspacePath) return;
@@ -330,6 +663,15 @@ export function SetupWizard(props: {
330
663
  if (config?.llm?.provider) {
331
664
  setLlm({
332
665
  provider: normalizeProvider(config.llm.provider),
666
+ // Un wikirc pré-0.16 porte le moteur dans `provider`
667
+ // (`ollama`, `anthropic`, `openai`). Sans cette déduction, l'étape
668
+ // moteur ne présélectionne rien et propose OpenAI en tête — au
669
+ // risque d'écraser une configuration qui marchait.
670
+ engine: config.llm.engine
671
+ ? normalizeEngine(config.llm.engine)
672
+ : normalizeProvider(config.llm.provider) === 'ai-gateway'
673
+ ? null
674
+ : normalizeEngine(config.llm.provider),
333
675
  baseUrl: configuredValue(config.llm.baseUrl),
334
676
  apiKey: configuredValue(config.llm.apiKey),
335
677
  model: configuredValue(config.llm.model),
@@ -342,9 +684,13 @@ export function SetupWizard(props: {
342
684
  // URL the operator just entered, instead of the scaffold's fake one.
343
685
  baseUrl: configuredValue(config.retrieval.vector.baseUrl),
344
686
  apiKey: configuredValue(config.retrieval.vector.apiKey),
345
- 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),
346
692
  rerankEnabled: config.retrieval.vector.rerankEnabled,
347
- rerankerModel: config.retrieval.vector.rerankerModel,
693
+ rerankerModel: configuredValue(config.retrieval.vector.rerankerModel),
348
694
  });
349
695
  }
350
696
  } catch { /* ignore — new workspace or unreadable profile */ }
@@ -500,14 +846,28 @@ export function SetupWizard(props: {
500
846
  }
501
847
  if (currentRoute === 'llm-provider') {
502
848
  const provider = normalizeProvider(value);
849
+ setLlm((old: any) => ({
850
+ ...old,
851
+ provider,
852
+ // Derrière une gateway il n'y a pas de moteur : l'endpoint est opaque
853
+ // et chaque modèle peut en avoir un différent.
854
+ engine: provider === 'ai-gateway' ? null : old.engine,
855
+ baseUrl: old.provider === provider ? old.baseUrl : '',
856
+ }));
857
+ // La gateway exige toujours une baseUrl, et n'a pas de moteur à choisir.
858
+ if (provider === 'ai-gateway') return navigate('llm-baseurl');
859
+ return navigate('llm-engine');
860
+ }
861
+ if (currentRoute === 'llm-engine') {
862
+ const engine = normalizeEngine(value);
503
863
  setLlm((old: any) => {
504
- const asksForBaseUrl = provider === 'ollama' || provider === 'openai-compatible';
505
- const baseUrl = (old.provider === provider && old.baseUrl)
864
+ const asksForBaseUrl = requiresBaseUrl(old.provider, engine);
865
+ const baseUrl = (old.engine === engine && old.baseUrl)
506
866
  ? old.baseUrl
507
- : asksForBaseUrl ? '' : defaultBaseUrl(provider);
508
- return { ...old, provider, baseUrl, ...(provider === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
867
+ : asksForBaseUrl ? '' : defaultBaseUrl(old.provider, engine);
868
+ return { ...old, engine, baseUrl, ...(engine === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
509
869
  });
510
- if (provider === 'ollama' || provider === 'openai-compatible') return navigate('llm-baseurl');
870
+ if (requiresBaseUrl(llm().provider, engine)) return navigate('llm-baseurl');
511
871
  return navigate('llm-apikey');
512
872
  }
513
873
  if (currentRoute === 'unregister-confirm') {
@@ -569,7 +929,7 @@ export function SetupWizard(props: {
569
929
  setVector((old: any) => ({
570
930
  ...old,
571
931
  provider: old.provider || llm().provider,
572
- baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider),
932
+ baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider, llm().engine),
573
933
  }));
574
934
  return navigate('vector-baseurl');
575
935
  }
@@ -583,6 +943,10 @@ export function SetupWizard(props: {
583
943
  if (lang.length < 2) return setError('Please enter a 2-character language code (e.g. fr, en).');
584
944
  const context = currentWorkspaceContext(props.session, currentGap()?.context ?? targetWorkspace());
585
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);
586
950
  navigate('llm-provider');
587
951
  return;
588
952
  }
@@ -612,24 +976,48 @@ export function SetupWizard(props: {
612
976
  if (currentRoute === 'llm-baseurl') {
613
977
  if (!value) return setError('Base URL is required.');
614
978
  setLlm((old: any) => ({ ...old, baseUrl: value }));
615
- if (llm().provider === 'ollama') return navigate('llm-model');
979
+ // Ollama n'exige pas de clé : on peut découvrir tout de suite.
980
+ if (llm().engine === 'ollama') {
981
+ startDiscovery({ ...llm(), baseUrl: value });
982
+ return navigate('llm-model');
983
+ }
616
984
  return navigate('llm-apikey');
617
985
  }
618
986
  if (currentRoute === 'llm-apikey') {
619
987
  if (!value) return setError('API key is required.');
620
988
  setLlm((old: any) => ({ ...old, apiKey: value }));
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 });
621
992
  return navigate('llm-model');
622
993
  }
623
994
  if (currentRoute === 'vector-baseurl') {
624
995
  const baseUrl = value || vector().baseUrl || llm().baseUrl;
625
996
  if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
626
- setVector((old: any) => ({ ...old, provider: llm().provider, baseUrl }));
997
+ setVector((old: any) => ({
998
+ ...old,
999
+ provider: llm().provider,
1000
+ engine: llm().engine,
1001
+ baseUrl,
1002
+ }));
627
1003
  return navigate('vector-apikey');
628
1004
  }
629
1005
  if (currentRoute === 'vector-apikey') {
1006
+ if (vectorBaseUrlDiverged() && !value) {
1007
+ return setError(
1008
+ 'API key is required: the vector base URL differs from the LLM one, so the LLM key is not reused.',
1009
+ );
1010
+ }
630
1011
  const apiKey = value || llm().apiKey || undefined;
631
1012
  if (!apiKey) return setError('API key is required (or set LLM key first).');
632
1013
  setVector((old: any) => ({ ...old, apiKey }));
1014
+ // Le catalogue affiché aux étapes embeddings et rerank doit venir de
1015
+ // l'endpoint vecteur, pas du LLM : ce sont deux serveurs distincts dès
1016
+ // que l'URL diverge, et proposer les modèles de chat de l'un pour les
1017
+ // embeddings de l'autre n'a aucun sens.
1018
+ if (vectorBaseUrlDiverged()) {
1019
+ startDiscovery({ ...vector(), apiKey });
1020
+ }
633
1021
  return navigate('vector-model');
634
1022
  }
635
1023
  if (currentRoute === 'llm-model') {
@@ -671,6 +1059,8 @@ export function SetupWizard(props: {
671
1059
  });
672
1060
 
673
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.
674
1064
  if (busy()) return;
675
1065
  const s = step();
676
1066
  const keyName = String(key.name ?? '').toLowerCase();
@@ -716,13 +1106,33 @@ export function SetupWizard(props: {
716
1106
  const closeIdx = pasted.indexOf('\x1b[201~');
717
1107
  if (closeIdx !== -1) pasted = pasted.slice(0, closeIdx);
718
1108
  pasted = pasted.split('\r').join('');
719
- if (pasted) setInput((value) => value + pasted);
1109
+ if (pasted) { setHighlight(-1); setInput((value) => value + pasted); }
720
1110
  return;
721
1111
  }
722
1112
  // Explicit clipboard paste (Ctrl+V or Cmd+V on macOS)
723
1113
  if (isPaste) {
724
1114
  const pasted = readClipboard();
725
- 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);
726
1136
  return;
727
1137
  }
728
1138
  if (isEnter) {
@@ -730,10 +1140,12 @@ export function SetupWizard(props: {
730
1140
  return;
731
1141
  }
732
1142
  if (keyName === 'backspace') {
1143
+ setHighlight(-1);
733
1144
  setInput((value) => value.slice(0, -1));
734
1145
  return;
735
1146
  }
736
1147
  if (sequence.length >= 1 && !sequence.startsWith('\x1b') && sequence >= ' ') {
1148
+ setHighlight(-1);
737
1149
  setInput((value) => value + sequence);
738
1150
  }
739
1151
  }
@@ -750,10 +1162,50 @@ export function SetupWizard(props: {
750
1162
  const displayValue = () => {
751
1163
  const s = step();
752
1164
  if (s.kind !== 'text') return '';
753
- const value = input();
754
- 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 ?? '');
755
1169
  };
756
1170
  const inputHasValue = () => step().kind === 'text' && input().length > 0;
1171
+
1172
+ /**
1173
+ * Suggestions filtrées par ce qui est tapé.
1174
+ *
1175
+ * Une gateway correctement remplie expose plusieurs centaines de modèles :
1176
+ * une liste brute est inutilisable, et un select classique interdirait de
1177
+ * saisir un modèle absent du catalogue. On garde donc le champ texte comme
1178
+ * seule vérité et on n'affiche qu'un rappel filtré — ce qui règle d'un coup
1179
+ * les trois cas : liste énorme, modèle absent, endpoint injoignable.
1180
+ */
1181
+ const SUGGESTION_ROWS = 6;
1182
+ const filteredSuggestions = createMemo(() => {
1183
+ const current = step() as any;
1184
+ const all: string[] = current?.suggestions ?? [];
1185
+ if (all.length === 0) {
1186
+ return { matches: [] as string[], rows: [] as string[], offset: 0, total: 0, matched: 0 };
1187
+ }
1188
+ const needle = input().trim().toLowerCase();
1189
+ const matches = needle
1190
+ ? all.filter((item) => item.toLowerCase().includes(needle))
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));
1201
+ return {
1202
+ matches,
1203
+ rows: matches.slice(offset, offset + SUGGESTION_ROWS),
1204
+ offset,
1205
+ total: all.length,
1206
+ matched: matches.length,
1207
+ };
1208
+ });
757
1209
  const lineWidth = () => Math.max(10, dialogWidth() - 10);
758
1210
  const displayLine1 = () => displayValue().slice(0, lineWidth());
759
1211
  const displayLine2 = () => displayValue().slice(lineWidth(), lineWidth() * 2);
@@ -762,20 +1214,42 @@ export function SetupWizard(props: {
762
1214
  const showLine3 = () => displayValue().length > lineWidth() * 2;
763
1215
  const contextPath = () => targetWorkspace()?.workspacePath ?? (currentGap()?.context?.workspacePath ?? null);
764
1216
 
765
- const contextSummary = createMemo(() => {
766
- const parts: string[] = [];
767
- const wp = targetWorkspace()?.workspacePath ?? (currentGap()?.context?.workspacePath ?? null);
768
- if (wp) parts.push(wp);
769
- if (language()) parts.push(`lang:${language()}`);
770
- const p = llm().provider;
771
- if (p) parts.push(p);
772
- if (llm().baseUrl) parts.push(llm().baseUrl);
773
- if (llm().apiKey) parts.push('key:***');
774
- const model = llm().model;
775
- if (model) parts.push(model);
776
- if (vector().baseUrl && vector().baseUrl !== llm().baseUrl) parts.push(`vec:${vector().baseUrl}`);
777
- if (vector().embeddingModel) parts.push(vector().embeddingModel);
778
- 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;
779
1253
  });
780
1254
 
781
1255
  return (
@@ -794,17 +1268,34 @@ export function SetupWizard(props: {
794
1268
  flexDirection="column"
795
1269
  overflow="hidden"
796
1270
  >
797
- <For each={logs().slice(-4)}>
798
- {(entry) => <text height={1} fg={entry.icon === '✓' ? '#8BD5CA' : '#9CA3AF'}>{entry.icon} {entry.label}{entry.detail ? ` - ${entry.detail}` : ''}</text>}
799
- </For>
800
- <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>
801
1285
  <text height={1}>{''}</text>
1286
+
802
1287
  <Show when={(step() as any).message || (step() as any).label}>
803
- <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>
804
1291
  </Show>
805
1292
  <Show when={(step() as any).note}>
806
- <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>
807
1296
  </Show>
1297
+ <text height={1}>{''}</text>
1298
+
808
1299
  <Show when={step().kind === 'text'}>
809
1300
  <box
810
1301
  height={5}
@@ -839,6 +1330,41 @@ export function SetupWizard(props: {
839
1330
  </box>
840
1331
  </box>
841
1332
  </Show>
1333
+ <Show when={step().kind === 'text' && filteredSuggestions().total > 0}>
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,
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
+ }}
1361
+ </For>
1362
+ <Show when={filteredSuggestions().matched > filteredSuggestions().rows.length}>
1363
+ <text height={1} fg="#7F8C8D">
1364
+ {` … ${filteredSuggestions().matched - filteredSuggestions().rows.length} more (↑↓ to scroll)`}
1365
+ </text>
1366
+ </Show>
1367
+ </Show>
842
1368
  <Show when={step().kind !== 'text'}>
843
1369
  <For each={currentItems()}>
844
1370
  {(item, index) => (
@@ -847,25 +1373,50 @@ export function SetupWizard(props: {
847
1373
  fg={(item as any).muted ? '#4B5563' : index() === selected() ? '#111318' : '#D6DEE8'}
848
1374
  bg={index() === selected() && !(item as any).muted ? '#8BD5CA' : '#111318'}
849
1375
  >
850
- {(item as any).muted ? ' ---' : `${index() === selected() ? '> ' : ' '}${item.label}`}
1376
+ {(item as any).muted ? ' ───' : `${index() === selected() ? '> ' : ' '}${item.label}`}
851
1377
  </text>
852
1378
  )}
853
1379
  </For>
854
1380
  </Show>
855
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
+
856
1390
  <Show when={error()}>
857
- {(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
+ )}
858
1399
  </Show>
1400
+
859
1401
  <box flexGrow={1} />
860
- <Show when={contextSummary()}>
861
- <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>
862
1407
  </Show>
863
1408
  <text height={1}>{''}</text>
864
1409
  <box height={1} flexDirection="row">
865
- <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>
866
1417
  <box flexGrow={1} />
867
1418
  <Show when={routeHistory().length > 0}>
868
- <text fg="#7F8C8D">Ctrl+Z ←</text>
1419
+ <text fg="#7F8C8D">Ctrl+Z Back</text>
869
1420
  </Show>
870
1421
  </box>
871
1422
  </box>