@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.
- package/.env.example +8 -5
- package/README.md +42 -0
- package/agents.docker-compose.yml +11 -16
- package/docker-compose.yml +9 -8
- package/package.json +2 -2
- package/src/agent/graph.js +20 -0
- package/src/cli/wiki-manager.js +20 -0
- package/src/commands/slash.js +85 -13
- package/src/commands/slash.test.js +85 -3
- package/src/core/agentsCompose.js +86 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +24 -2
- package/src/core/dockerCompose.test.js +18 -0
- package/src/core/env.js +2 -1
- package/src/core/env.test.js +14 -0
- package/src/core/googleGrants.js +38 -0
- package/src/core/googleGrants.test.js +59 -0
- package/src/core/mcp.js +1 -1
- package/src/core/mcp.test.js +1 -0
- package/src/core/mcpEndpoints.js +96 -0
- package/src/core/mcpEndpoints.test.js +126 -0
- package/src/core/modelFetch.js +241 -27
- package/src/core/modelFetch.test.js +213 -0
- package/src/core/profileServiceStatus.test.js +146 -0
- package/src/core/wikiSetup.js +11 -2
- package/src/core/wikiWorkspace.test.js +16 -3
- package/src/orchestrator/agentRegistry.js +13 -1
- package/src/orchestrator/agentRegistry.test.js +20 -0
- package/src/runtime/lifecycle.js +105 -3
- package/src/runtime/lifecycle.test.js +68 -0
- package/src/runtime/server.js +28 -0
- package/src/shell/LeftPane.tsx +55 -41
- package/src/shell/SetupWizard.tsx +416 -116
- package/src/shell/repl.js +61 -12
- package/src/shell/repl.test.js +48 -19
- package/src/shell/setupWizardDiscovery.test.js +48 -0
- package/src/shell/setupWizardPlaceholders.test.js +15 -2
- package/src/shell/setupWizardSuggestions.test.js +36 -2
- package/src/shell/wrapText.js +57 -0
- package/src/shell/wrapText.test.js +48 -0
- 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 {
|
|
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
|
|
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
|
|
260
|
-
return
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
if (command === '/
|
|
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 '
|
|
537
|
-
if (value === 'agents' || value === 'agent') return '
|
|
538
|
-
if (value === 'services') return '
|
|
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')
|
|
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.';
|
package/src/shell/repl.test.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
assert.match(source, /
|
|
41
|
-
assert.
|
|
42
|
-
assert.match(source
|
|
43
|
-
assert.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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('
|
|
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 = '
|
|
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(
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
{
|
|
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 >=
|
|
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(/
|
|
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
|
-
|
|
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
|
+
});
|