@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
package/src/shell/repl.js CHANGED
@@ -9,12 +9,13 @@ import { marked } from 'marked';
9
9
  import { markedTerminal } from 'marked-terminal';
10
10
  import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
11
11
  import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
12
- import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
12
+ import { serviceChoices as composeServiceChoices, serviceDescription } from '../core/compose.js';
13
13
  import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
14
14
  import { syncActivitiesToPlan } from '../core/plan.js';
15
15
  import { buildLlmTools, callMcpTool, formatMcpToolResult, parseToolCallName, resolveToolCallName } from '../core/mcp.js';
16
16
  import { runBoundedToolLoop } from '../core/toolLoop.js';
17
17
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
18
+ import { togglableAgentNames } from '../core/agentsCompose.js';
18
19
  import { listSkills } from '../core/skills.js';
19
20
  import { listWikircProfiles } from '../core/wikirc.js';
20
21
  import { listWorkspaces } from '../core/workspaces.js';
@@ -78,7 +79,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
78
79
  '/stop': 'Stop one service or the workspace service set.',
79
80
  '/logs': 'Show recent logs for a service.',
80
81
  '/mcp': 'Inspect or call workspace MCP servers.',
81
- '/connector': 'List connectors or authorize one — /connector auth <connector>.',
82
+ '/connector': 'List connectors, or authorize one with all its rights — /connector auth google.',
82
83
  '/wiki': 'Run llm-wiki commands for the active workspace.',
83
84
  '/skills': 'List workspace skills.',
84
85
  '/upload': 'Upload a document — /upload <path>',
@@ -168,11 +169,34 @@ export function conversationKey(session) {
168
169
  return session.workspace || GLOBAL_CONVERSATION_KEY;
169
170
  }
170
171
 
172
+ /**
173
+ * Horodate un message au moment où il entre dans la conversation.
174
+ *
175
+ * Posé sur le `push` du tableau plutôt qu'aux trente-deux endroits qui créent
176
+ * un message : un seul de ces endroits oublié, et la ligne perdrait son heure
177
+ * sans que rien ne le signale. Un message qui porte déjà `at` — rejoué depuis
178
+ * l'historique, par exemple — garde le sien.
179
+ */
180
+ function stampConversation(messages) {
181
+ if (messages.__stamped) return messages;
182
+ Object.defineProperty(messages, '__stamped', { value: true });
183
+ const push = messages.push.bind(messages);
184
+ Object.defineProperty(messages, 'push', {
185
+ value: (...entries) =>
186
+ push(...entries.map((entry) =>
187
+ entry && typeof entry === 'object' && entry.at === undefined
188
+ ? Object.assign(entry, { at: Date.now() })
189
+ : entry,
190
+ )),
191
+ });
192
+ return messages;
193
+ }
194
+
171
195
  export function conversationMessages(session) {
172
196
  const key = conversationKey(session);
173
197
  session.conversations ??= { [GLOBAL_CONVERSATION_KEY]: [] };
174
198
  session.conversations[key] ??= [];
175
- return session.conversations[key];
199
+ return stampConversation(session.conversations[key]);
176
200
  }
177
201
 
178
202
  // Ordered from verifiable to hopeful. Mirrors tui.tsx's clipboardCommands():
@@ -256,8 +280,19 @@ function wikircProfileNames(session) {
256
280
  : [];
257
281
  }
258
282
 
259
- function serviceNames() {
260
- return composeServiceNames();
283
+ function serviceChoices() {
284
+ return composeServiceChoices();
285
+ }
286
+
287
+ /**
288
+ * Agents proposés un par un, en plus du raccourci `agents`.
289
+ *
290
+ * Seulement ceux derrière un drapeau de profil. Lister tous les agents avait
291
+ * rallongé la liste de quatre entrées, dont `mailer` qui n'existe même pas tant
292
+ * que l'opérateur ne l'a pas décommenté dans son override.
293
+ */
294
+ function togglableAgents() {
295
+ return togglableAgentNames();
261
296
  }
262
297
 
263
298
  function skillNames(session) {
@@ -282,6 +317,11 @@ function completionValuesFor(parts, inputBuffer, session) {
282
317
  if (command === '/mcp' && parts[1] === 'call' && tokenIndex === 3) return mcpToolNames(session, parts[2]);
283
318
  if (command === '/connector' && tokenIndex === 1) return ['auth', 'list'];
284
319
  if (command === '/connector' && previousToken === 'auth') return ['google'];
320
+ // Pas de complétion des droits après le connecteur. `/connector auth google`
321
+ // demande déjà tout ce que l'agent sait faire, en une autorisation : proposer
322
+ // une liste laissait croire qu'il fallait choisir, et transformait une
323
+ // commande à taper d'un trait en menu à trois entrées. Les droits restent
324
+ // saisissables un par un pour restreindre, et le libellé de /connector le dit.
285
325
  if (command === '/upload' && tokenIndex === 1) return ['convert'];
286
326
  if (command === '/upload' && parts[1] === 'convert' && tokenIndex === 2) return ['pending'];
287
327
  if (command === '/uploads' && tokenIndex === 1) return ['clean', 'list'];
@@ -301,9 +341,11 @@ function completionValuesFor(parts, inputBuffer, session) {
301
341
  if (command === '/wiki' && tokenIndex === 1) return ['run'];
302
342
  if (command === '/skills' && tokenIndex === 1) return ['edit', 'list', 'run', 'show'];
303
343
  if (command === '/skills' && ['edit', 'run', 'show'].includes(previousToken ?? '')) return skillNames(session);
304
- if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceNames()];
305
- if (command === '/stop' && tokenIndex === 1) return ['agents', ...serviceNames()];
306
- if (command === '/logs' && tokenIndex === 1) return serviceNames();
344
+ // Vocabulaire de l'opérateur uniquement : `serviceChoices()` écarte les noms
345
+ // Compose bruts que les alias désignent déjà. Ils restent tapables.
346
+ if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
347
+ if (command === '/stop' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
348
+ if (command === '/logs' && tokenIndex === 1) return ['all', ...serviceChoices()];
307
349
  return [];
308
350
  }
309
351
 
@@ -533,12 +575,19 @@ export function completionDescription(value, parts) {
533
575
  if (subcommand) return subcommand;
534
576
  if (command === '/use') return 'Load this workspace.';
535
577
  if (command === '/start') {
536
- if (value === 'all') return 'Start the workspace services AND the external agents.';
537
- if (value === 'agents' || value === 'agent') return 'Start the external agents only.';
538
- if (value === 'services') return 'Start the workspace services only.';
578
+ if (value === 'all') return 'Everything: workspace services AND external agents.';
579
+ if (value === 'agents' || value === 'agent') return 'External agents only (CME, documents, connectors…).';
580
+ if (value === 'services') return 'Workspace services only, without the external agents.';
581
+ if (togglableAgents().includes(value)) return `Start only the ${value} agent, leaving the other agents as they are.`;
539
582
  return serviceDescription(value) ?? 'Start this Docker Compose service.';
540
583
  }
541
- if (command === '/stop') return serviceDescription(value) ?? 'Stop this Docker Compose service.';
584
+ if (command === '/stop') {
585
+ if (value === 'all') return 'Everything: workspace services AND external agents.';
586
+ if (value === 'agents' || value === 'agent') return 'External agents only.';
587
+ if (value === 'services') return 'Workspace services only.';
588
+ if (togglableAgents().includes(value)) return `Stop only the ${value} agent, leaving the other agents running.`;
589
+ return serviceDescription(value) ?? 'Stop this Docker Compose service.';
590
+ }
542
591
  if (command === '/logs') return serviceDescription(value) ?? 'Show logs for this Docker Compose service.';
543
592
  if (command === '/workspace') {
544
593
  if (parts[1] === 'delete' && value === '--confirm') return 'Confirm workspace deletion.';
@@ -31,28 +31,50 @@ test('ShellUI inserts StyledText as a child instead of stringifying it through c
31
31
  assert.match(source, /<text[^>]*>\{styledSegments\(line\.segments\)\}<\/text>/);
32
32
  });
33
33
 
34
- test('ShellUI renders the user header full-width before constraining only its body', async () => {
34
+ test('every message line carries its speaker gutter, and no full-width rule', async () => {
35
35
  const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
36
- const headerBranch = source.indexOf("line.copyContent !== undefined ? (");
37
- const userBodyBranch = source.indexOf("line.role === 'user' && line.userBubbleWidth ? (", headerBranch);
38
- assert.ok(headerBranch >= 0 && userBodyBranch > headerBranch);
39
- assert.match(source, /const userBubbleWidth = message\.role === 'user'/);
40
- assert.match(source, /Math\.floor\(columns \* 0\.6\)/);
41
- assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.5\)/);
42
- assert.match(source.slice(userBodyBranch), /width=\{line\.userBubbleWidth\}/);
43
- assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
44
- assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
45
- assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
46
- assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
36
+ // Le filet pleine largeur ouvrait chaque message : l'élément le plus encré de
37
+ // l'écran, répété à chaque tour, et qui séparait au lieu de grouper. La
38
+ // gouttière dit la même chose — à qui est le tour, où le bloc commence et où
39
+ // il finit — pour un caractère par ligne.
40
+ assert.match(source, /const GUTTER = '▌ ';/);
41
+ assert.match(source, /function withGutter\(/);
42
+ assert.match(source, /renderedBody\.map\(\(line\) => withGutter\(line, message\.role\)\)/);
43
+ assert.doesNotMatch(source, /'─'\.repeat\(rightLength\)/);
44
+
45
+ // La bulle alignée à droite repliait les messages utilisateur à 60 % de la
46
+ // largeur : phrases coupées en plein milieu, et deux colonnes de lecture
47
+ // selon le locuteur.
48
+ assert.doesNotMatch(source, /userBubbleWidth\s*=/);
49
+ assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.6\)/);
50
+ assert.match(source, /const contentColumns = Math\.max\(12, columns - GUTTER\.length\)/);
47
51
  });
48
52
 
49
- test('ShellUI exposes copy without a reply or redo action', async () => {
53
+ test('the header carries the clock and the copy action, pushed to the right edge', async () => {
50
54
  const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
51
- assert.match(source, /const COPY_BTN = ' \[ copy \]';/);
55
+ assert.match(source, /const COPY_BTN = ' copy';/);
56
+ assert.match(source, /messageHeaderSegments\(message\.role, columns, message\.at\)/);
57
+ // Le remplissage pousse `copy` au bord droit au lieu de le coller au libellé.
58
+ assert.match(source, /const pad = Math\.max\(1, columns - used - COPY_BTN\.length\)/);
52
59
  assert.doesNotMatch(source, /\[\s*(?:reply|redo)\s*\]/i);
53
60
  assert.doesNotMatch(source, /onRedo|redoContent|redoIndex|REDO_BTN/);
54
61
  });
55
62
 
63
+ test('a message is stamped once, when it enters the conversation', async () => {
64
+ const { conversationMessages } = await import('./repl.js');
65
+ const session = { workspace: null };
66
+ const messages = conversationMessages(session);
67
+ // Posé sur le `push` du tableau : trente-deux endroits créent un message, et
68
+ // un seul oublié perdrait son heure sans que rien ne le signale.
69
+ messages.push({ role: 'user', content: 'bonjour' });
70
+ assert.ok(Number.isFinite(messages[0].at));
71
+ // Un message qui porte déjà son heure garde la sienne.
72
+ messages.push({ role: 'donna', content: 'rejoué', at: 42 });
73
+ assert.equal(messages[1].at, 42);
74
+ // Et le tableau n'est instrumenté qu'une fois.
75
+ assert.equal(conversationMessages(session), messages);
76
+ });
77
+
56
78
  test('redo translates the local thread index into a runtime conversation index', async () => {
57
79
  const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
58
80
  const redo = session.slice(
@@ -559,7 +581,10 @@ test('/agent <question> submits one runtime request and remains in chat mode', a
559
581
  assert.equal(session.chatMode, true);
560
582
  assert.equal(result.oneShotAgent, true);
561
583
  assert.equal(result.runtimeOutcome.kind, 'accepted');
562
- assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion', _pending: true }]);
584
+ assert.deepEqual(
585
+ conversationMessages(session).map(({ at, ...rest }) => rest),
586
+ [{ role: 'user', content: 'lance ingestion', _pending: true }],
587
+ );
563
588
  } finally {
564
589
  restore();
565
590
  }
@@ -658,10 +683,14 @@ test('agent mode without runtime records a visible error instead of falling back
658
683
  const message = recordRuntimeUnavailableAgentInput(session, 'salut', { error: 'port 7788 already in use' });
659
684
 
660
685
  assert.equal(message, '⚠ Runtime indisponible : port 7788 already in use — /agent désactivé, /chat reste possible');
661
- assert.deepEqual(conversationMessages(session), [
662
- { role: 'user', content: 'salut' },
663
- { role: 'command', content: message },
664
- ]);
686
+ // `at` est posé à l'insertion : on compare le reste.
687
+ assert.deepEqual(
688
+ conversationMessages(session).map(({ at, ...rest }) => rest),
689
+ [
690
+ { role: 'user', content: 'salut' },
691
+ { role: 'command', content: message },
692
+ ],
693
+ );
665
694
  });
666
695
 
667
696
  test('runtime status exposes the disconnected reason', () => {
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const source = readFileSync(
7
+ fileURLToPath(new URL('./SetupWizard.tsx', import.meta.url)),
8
+ 'utf8',
9
+ );
10
+
11
+ test('model discovery never blocks a wizard step', () => {
12
+ // La régression d'origine : `await discoverModels()` sur l'étape clé API
13
+ // gelait l'écran pendant tout le délai réseau, sans rien afficher.
14
+ assert.doesNotMatch(source, /await\s+(start|discover)\w*[Dd]iscover\w*\(/);
15
+ assert.doesNotMatch(source, /await\s+fetch(Models|GatewayCatalog)\(/);
16
+ assert.match(source, /startDiscovery\(\{ \.\.\.llm\(\), apiKey: value \}\);\s*\n\s*return navigate\('llm-model'\)/);
17
+ });
18
+
19
+ test('a late answer cannot overwrite a newer discovery', () => {
20
+ assert.match(source, /discoveryRun \+= 1;/);
21
+ assert.match(source, /if \(run !== discoveryRun\) return;/);
22
+ });
23
+
24
+ test('the gateway flat list is shown before the typed catalog', () => {
25
+ assert.match(source, /onPartial:/);
26
+ assert.match(source, /Refining chat\/embedding\/rerank types/);
27
+ });
28
+
29
+ test('the discovery panel reports state, endpoint and transport', () => {
30
+ for (const fragment of [
31
+ 'Reading the model catalog',
32
+ 'Model catalog unavailable',
33
+ 'Cause: ',
34
+ 'Transport: ',
35
+ 'transportSummary()',
36
+ 'model(s) available',
37
+ ]) {
38
+ assert.ok(source.includes(fragment), `discovery panel must show: ${fragment}`);
39
+ }
40
+ });
41
+
42
+ test('the wizard fills the terminal instead of a fixed narrow box', () => {
43
+ const width = source.match(/const dialogWidth = \(\) => ([^;]+);/);
44
+ const height = source.match(/const dialogHeight = \(\) => ([^;]+);/);
45
+ assert.ok(width && height);
46
+ assert.match(width[1], /Math\.min\(1\d\d,/);
47
+ assert.match(height[1], /Math\.min\([34]\d,/);
48
+ });
@@ -22,11 +22,22 @@ test('the wizard treats scaffolded config values as unset', { skip: !existsSync(
22
22
  // Cross-repo guard: if the scaffold ever ships a different fake endpoint or
23
23
  // secret, the wizard would prefill it as a real answer and a skipped step
24
24
  // would write it to the operator's config.
25
+ // `embeddingModel` et `rerankerModel` ont ete ajoutes ici apres coup : ils
26
+ // etaient les seuls champs du scaffold a porter une valeur d'apparence reelle
27
+ // (`BAAI/bge-m3`), que le wizard preremplissait donc comme une reponse
28
+ // choisie — et qui filtrait le catalogue sur zero resultat des que le serveur
29
+ // nommait le meme modele autrement.
25
30
  const placeholders = [
26
- ...scaffold.matchAll(/^\s*(baseUrl|apiKey|model):\s*(\S+)\s*$/gm),
31
+ ...scaffold.matchAll(/^\s*(baseUrl|apiKey|model|embeddingModel|rerankerModel):\s*(\S+)\s*$/gm),
27
32
  ].map(([, key, value]) => [key, value]);
28
33
 
29
- assert.ok(placeholders.length >= 4, 'expected the scaffold to declare endpoints and secrets');
34
+ assert.ok(placeholders.length >= 6, 'expected the scaffold to declare endpoints, secrets and models');
35
+ for (const key of ['embeddingModel', 'rerankerModel']) {
36
+ assert.ok(
37
+ placeholders.some(([name]) => name === key),
38
+ `the scaffold must declare ${key} so this guard covers it`,
39
+ );
40
+ }
30
41
  for (const [key, value] of placeholders) {
31
42
  assert.match(value, regex, `scaffolded ${key}=${value} must be recognised as a placeholder`);
32
43
  }
@@ -41,6 +52,8 @@ test('preloading a profile filters placeholders out of every prefilled field', (
41
52
  'model: configuredValue(config.llm.model)',
42
53
  'baseUrl: configuredValue(config.retrieval.vector.baseUrl)',
43
54
  'apiKey: configuredValue(config.retrieval.vector.apiKey)',
55
+ 'embeddingModel: configuredValue(config.retrieval.vector.embeddingModel)',
56
+ 'rerankerModel: configuredValue(config.retrieval.vector.rerankerModel)',
44
57
  ]) {
45
58
  assert.ok(source.includes(field), `preloadWikirc must filter ${field}`);
46
59
  }
@@ -7,8 +7,31 @@ const source = readFileSync(new URL('./SetupWizard.tsx', import.meta.url), 'utf8
7
7
  test('the model steps feed the discovered catalog into the filter', () => {
8
8
  // Trois étapes doivent porter des suggestions : modèle LLM, embeddings,
9
9
  // reranker. Sans elles, le filtre n'a rien a filtrer.
10
- const occurrences = source.match(/suggestions: discovered/g) ?? [];
10
+ const occurrences = source.match(/\.\.\.suggestionField\(/g) ?? [];
11
11
  assert.equal(occurrences.length, 3);
12
+ assert.match(source, /suggestions: discovered/);
13
+ });
14
+
15
+ test('no model is preselected when a catalog was discovered', () => {
16
+ // Preremplir avec le premier modele decouvert filtrait la liste sur
17
+ // lui-meme : neuf entrees sur dix devenaient invisibles.
18
+ assert.match(source, /prefill: usable \?\? \(discovered\.length === 0 \? example : ''\)/);
19
+ assert.doesNotMatch(source, /prefill: [^\n]*discovered\[0\]/);
20
+ });
21
+
22
+ test('a configured model absent from the catalog is not prefilled', () => {
23
+ // Le `BAAI/bge-m3` du scaffold face a un serveur qui nomme le meme modele
24
+ // `bge-m3` : preremplir filtrait la liste sur zero resultat, et l'ecran
25
+ // affichait « No match » devant dix modeles disponibles.
26
+ assert.match(source, /discovered\.length === 0 \|\| discovered\.includes\(configured\)/);
27
+ assert.match(source, /is not in this catalog — pick one below/);
28
+ });
29
+
30
+ test('the list is navigable with the arrows and confirmed in two steps', () => {
31
+ assert.match(source, /keyName === 'down' \|\| keyName === 'up'/);
32
+ assert.match(source, /if \(hovered && \(isEnter \|\| keyName === 'tab'\)\)/);
33
+ // Le premier Enter depose la valeur, il ne valide pas l'etape.
34
+ assert.match(source, /setInput\(hovered\);\s*\n\s*setHighlight\(-1\);\s*\n\s*return;/);
12
35
  });
13
36
 
14
37
  test('the typed value always wins over the suggestion list', () => {
@@ -20,5 +43,16 @@ test('the typed value always wins over the suggestion list', () => {
20
43
 
21
44
  test('suggestions are filtered by the current input, not shown raw', () => {
22
45
  assert.match(source, /all\.filter\(\(item\) => item\.toLowerCase\(\)\.includes\(needle\)\)/);
23
- assert.match(source, /matches\.slice\(0, SUGGESTION_ROWS\)/);
46
+ // Fenetre glissante : la liste defile au lieu de s'arreter aux premieres
47
+ // entrees, sinon un catalogue de 200 modeles reste inatteignable.
48
+ assert.match(source, /matches\.slice\(offset, offset \+ SUGGESTION_ROWS\)/);
49
+ });
50
+
51
+ test('the language typed by the operator is the one displayed back', () => {
52
+ // Sans ce setLanguage, le recapitulatif affichait la langue lue dans le
53
+ // scaffold a la creation du workspace, pas celle qui vient d'etre saisie.
54
+ assert.match(
55
+ source,
56
+ /writeLanguageConfig\([^)]*\);\s*(\n\s*\/\/[^\n]*)*\n\s*setLanguage\(lang\);/,
57
+ );
24
58
  });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Découpe un texte en lignes tenant dans la largeur d'une boîte TUI.
3
+ *
4
+ * Le wizard rendait chaque libellé, note ou erreur dans un `<text height={1}>` :
5
+ * au delà de la largeur du dialogue le texte était coupé net, sans ellipse ni
6
+ * indice. Les phrases les plus utiles — la note d'une étape, la cause d'une
7
+ * erreur de connexion — sont les plus longues, donc les plus tronquées.
8
+ *
9
+ * Le module est volontairement en JavaScript pur, hors du composant : c'est ce
10
+ * qui le rend testable sans transpiler du JSX.
11
+ *
12
+ * @param {string} text texte à découper ; les `\n` explicites sont respectés.
13
+ * @param {number} width largeur utile en colonnes.
14
+ * @param {number} maxLines nombre de lignes maximum ; la dernière est
15
+ * marquée d'une ellipse quand il reste du texte, pour que la troncature
16
+ * restante soit au moins visible.
17
+ * @returns {string[]}
18
+ */
19
+ export function wrapText(text, width, maxLines = 6) {
20
+ const safeWidth = Math.max(8, Math.floor(width) || 8);
21
+ const lines = [];
22
+ for (const paragraph of String(text ?? '').split('\n')) {
23
+ if (paragraph.trim() === '') {
24
+ lines.push('');
25
+ continue;
26
+ }
27
+ let current = '';
28
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
29
+ // Un mot plus long que la boîte — une URL, un chemin, un nom de modèle
30
+ // qualifié — est coupé plutôt que de déborder : mieux vaut le lire en
31
+ // deux morceaux que pas du tout.
32
+ if (word.length > safeWidth) {
33
+ if (current) {
34
+ lines.push(current);
35
+ current = '';
36
+ }
37
+ for (let i = 0; i < word.length; i += safeWidth) {
38
+ lines.push(word.slice(i, i + safeWidth));
39
+ }
40
+ current = lines.pop() ?? '';
41
+ continue;
42
+ }
43
+ if (!current) current = word;
44
+ else if (current.length + 1 + word.length <= safeWidth) current += ` ${word}`;
45
+ else {
46
+ lines.push(current);
47
+ current = word;
48
+ }
49
+ }
50
+ if (current) lines.push(current);
51
+ }
52
+ if (lines.length <= maxLines) return lines;
53
+ const kept = lines.slice(0, maxLines);
54
+ const last = kept[maxLines - 1] ?? '';
55
+ kept[maxLines - 1] = `${last.slice(0, Math.max(1, safeWidth - 1))}…`;
56
+ return kept;
57
+ }
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { wrapText } from './wrapText.js';
6
+
7
+ test('wraps on word boundaries within the given width', () => {
8
+ const lines = wrapText('Required: an agentic model with tool calling support.', 20);
9
+ assert.ok(lines.length > 1);
10
+ for (const line of lines) assert.ok(line.length <= 20, `too wide: ${line}`);
11
+ assert.equal(lines.join(' '), 'Required: an agentic model with tool calling support.');
12
+ });
13
+
14
+ test('keeps explicit line breaks', () => {
15
+ assert.deepEqual(wrapText('one\ntwo', 40), ['one', 'two']);
16
+ });
17
+
18
+ test('splits a word longer than the box instead of letting it overflow', () => {
19
+ const lines = wrapText('https://gateway.internal.example.com/v1/models', 16);
20
+ for (const line of lines) assert.ok(line.length <= 16, `too wide: ${line}`);
21
+ assert.equal(lines.join(''), 'https://gateway.internal.example.com/v1/models');
22
+ });
23
+
24
+ test('marks the truncation instead of cutting silently', () => {
25
+ const lines = wrapText('a b c d e f g h i j k l m n o p', 4, 2);
26
+ assert.equal(lines.length, 2);
27
+ assert.ok(lines[1].endsWith('…'));
28
+ });
29
+
30
+ test('a short text is returned untouched', () => {
31
+ assert.deepEqual(wrapText('API key', 40), ['API key']);
32
+ });
33
+
34
+ test('the wizard renders every prose field through wrapText', () => {
35
+ const source = readFileSync(
36
+ fileURLToPath(new URL('./SetupWizard.tsx', import.meta.url)),
37
+ 'utf8',
38
+ );
39
+ // Garde-fou : un `<text height={1}>` autour d'un libellé, d'une note ou
40
+ // d'une erreur réintroduirait la troncature silencieuse.
41
+ for (const call of [
42
+ 'wrapText((step() as any).message ?? (step() as any).label, textWidth(), 5)',
43
+ 'wrapText((step() as any).note, textWidth(), 4)',
44
+ 'wrapText(message(), textWidth(), 5)',
45
+ ]) {
46
+ assert.ok(source.includes(call), `missing wrapped render: ${call}`);
47
+ }
48
+ });