aegiscode 6.2.0 → 6.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -4
- package/bin/aegiscode.js +13 -1
- package/package.json +1 -1
- package/src/app.js +201 -10
- package/src/chatflow.js +120 -20
- package/src/commands.js +99 -13
- package/src/config.js +11 -1
- package/src/events.js +32 -0
- package/src/models.js +123 -0
- package/src/render.js +4 -0
- package/src/screens.js +463 -0
- package/src/theme.js +68 -2
- package/vendor/desktop/lib/local/engine.js +11 -0
package/src/commands.js
CHANGED
|
@@ -52,6 +52,7 @@ const {
|
|
|
52
52
|
} = require('./config.js');
|
|
53
53
|
const { copyToClipboard } = require('./clipboard.js');
|
|
54
54
|
const { snapshotCheckpoint, listCheckpoints, loadCheckpoint } = require('./checkpoint.js');
|
|
55
|
+
const screens = require('./screens.js');
|
|
55
56
|
const { summarizeTranscript, recapLine } = require('./summarize.js');
|
|
56
57
|
const { sessionAccounting, accountingFromUsage, estimateTokens } = require('./tokens.js');
|
|
57
58
|
const { transcriptToMarkdown, transcriptToJSON, writeExportFile, lastAssistantText } = require('./export.js');
|
|
@@ -102,6 +103,19 @@ const tip = (c, text) => c.push({ role: 'tip', text });
|
|
|
102
103
|
const done = (c, text) => c.push({ role: 'done', text });
|
|
103
104
|
const shortCwd = () => process.cwd().split('/').filter(Boolean).pop() || '~';
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Refresh the pinnable-model list on the session context before reading it.
|
|
108
|
+
* `c.loadModels()` (owned by the app/chatflow) fetches from the server and
|
|
109
|
+
* records the result on `c.state().models`; it is best-effort — offline it
|
|
110
|
+
* rejects, and the caller falls through to the honest empty note rather than
|
|
111
|
+
* opening an empty picker.
|
|
112
|
+
*/
|
|
113
|
+
async function loadModels(c) {
|
|
114
|
+
try {
|
|
115
|
+
await c.loadModels();
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
/**
|
|
106
120
|
* The ÆGIS LLM routes are gated on the pooled brain being reachable — exactly
|
|
107
121
|
* as the reference hides its ÆGIS routes when the backend is absent. `hidden`
|
|
@@ -190,13 +204,17 @@ const COMMANDS = [
|
|
|
190
204
|
c.render();
|
|
191
205
|
return true;
|
|
192
206
|
}
|
|
193
|
-
note(c, `Running ${cmd} in ${shortCwd()} — output streams below.`);
|
|
194
|
-
c.push({ role: 'tool', label: 'Bash', args: cmd });
|
|
207
|
+
note(c, `Running ${cmd} in ${shortCwd()} — output streams below, Esc stops it.`);
|
|
195
208
|
const job = runDevServer(cmd, {
|
|
196
209
|
cwd: process.cwd(),
|
|
197
210
|
onLine: (line) => c.push({ role: 'note', text: ` ${line}` }),
|
|
198
211
|
});
|
|
212
|
+
// Bind the job to the session so the status line draws "esc to stop" and
|
|
213
|
+
// Esc actually stops it (the reference's openStream/closeStream contract).
|
|
214
|
+
if (!job.label) job.label = cmd;
|
|
215
|
+
c.openStream(job);
|
|
199
216
|
job.done.then(({ code, stopped }) => {
|
|
217
|
+
c.closeStream(job);
|
|
200
218
|
done(c, stopped ? `${cmd} stopped` : `${cmd} exited (code ${code})`);
|
|
201
219
|
c.render();
|
|
202
220
|
});
|
|
@@ -290,7 +308,7 @@ const COMMANDS = [
|
|
|
290
308
|
c.render();
|
|
291
309
|
const summary = await c.withWorking((signal) =>
|
|
292
310
|
summarizeTranscript(c.transcript, {
|
|
293
|
-
callModel: (p) => c.ask(p).then((r) => r.text),
|
|
311
|
+
callModel: (p, opts) => c.ask(p, opts).then((r) => r.text),
|
|
294
312
|
model: c.ctx.model,
|
|
295
313
|
signal,
|
|
296
314
|
}));
|
|
@@ -375,7 +393,12 @@ const COMMANDS = [
|
|
|
375
393
|
: 'Clipboard unavailable; nothing copied.');
|
|
376
394
|
} else {
|
|
377
395
|
try {
|
|
378
|
-
|
|
396
|
+
// Honour the command context's cwd. The handler used to fall through
|
|
397
|
+
// to writeExportFile's own `process.cwd()` default, so /export wrote
|
|
398
|
+
// into whatever directory the process happened to be in — which meant
|
|
399
|
+
// running the command smoke test (it invokes every handler) littered
|
|
400
|
+
// the repository root with aegiscodex-export-*.md files.
|
|
401
|
+
const p = writeExportFile(body, isJson ? 'json' : 'md', c.ctx.cwd || process.cwd());
|
|
379
402
|
note(c, `Exported conversation to ${p}`);
|
|
380
403
|
} catch (e) {
|
|
381
404
|
note(c, `/export: ${e.message}`);
|
|
@@ -399,9 +422,23 @@ const COMMANDS = [
|
|
|
399
422
|
desc: 'Create an AEGIS.md file in the project',
|
|
400
423
|
handler: async (c, args) => {
|
|
401
424
|
const file = (args.file || 'AEGIS.md').trim();
|
|
402
|
-
|
|
425
|
+
// The command context's cwd, not process.cwd(): /cd moves the session to
|
|
426
|
+
// another directory, and a handler that reads process.cwd() then writes
|
|
427
|
+
// the file into the directory the user just left. It also means the
|
|
428
|
+
// command smoke test (which invokes every handler) no longer drops an
|
|
429
|
+
// AEGIS.md into the repository root.
|
|
430
|
+
const cwd = c.ctx.cwd || process.cwd();
|
|
431
|
+
const p = path.resolve(cwd, file);
|
|
432
|
+
// Refuse a path that resolves outside the project — the reference's /init
|
|
433
|
+
// takes no argument, and `path.join(cwd, '../../x')` writes outside it.
|
|
434
|
+
const root = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
|
|
435
|
+
if (p !== cwd && !p.startsWith(root)) {
|
|
436
|
+
note(c, `/init: refusing to write outside the project directory (${p}).`);
|
|
437
|
+
c.render();
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
403
440
|
if (fs.existsSync(p)) { note(c, `${file} already exists — not overwriting.`); c.render(); return true; }
|
|
404
|
-
const sniff = sniffProject(
|
|
441
|
+
const sniff = sniffProject(cwd);
|
|
405
442
|
try {
|
|
406
443
|
fs.writeFileSync(p, buildAegisMd(sniff) + '\n');
|
|
407
444
|
} catch (e) {
|
|
@@ -410,7 +447,7 @@ const COMMANDS = [
|
|
|
410
447
|
return true;
|
|
411
448
|
}
|
|
412
449
|
note(c, `Created ${p} (${sniff.lang})`);
|
|
413
|
-
if (fs.existsSync(path.join(
|
|
450
|
+
if (fs.existsSync(path.join(cwd, 'node_modules')) && !fs.existsSync(path.join(cwd, '.gitignore'))) {
|
|
414
451
|
tip(c, 'Add "node_modules/" to a .gitignore before committing.');
|
|
415
452
|
}
|
|
416
453
|
c.render();
|
|
@@ -435,10 +472,40 @@ const COMMANDS = [
|
|
|
435
472
|
const id = (args.sub || '').trim();
|
|
436
473
|
if (!id) {
|
|
437
474
|
note(c, `model: ${c.ctx.model || 'server default'}`);
|
|
438
|
-
|
|
475
|
+
// Populate the picker from the server first — app.js's state().models
|
|
476
|
+
// is empty until loadModels() has run, so an unguarded read renders an
|
|
477
|
+
// empty picker whose Enter does nothing.
|
|
478
|
+
await loadModels(c);
|
|
479
|
+
const models = c.state().models || [];
|
|
480
|
+
if (!models.length) {
|
|
481
|
+
// Say why, and what unblocks it: an unreachable catalog is almost
|
|
482
|
+
// always a missing key or no network, and "no models advertised"
|
|
483
|
+
// read as "the platform has none" rather than "this client could not
|
|
484
|
+
// ask".
|
|
485
|
+
note(c, c.state().online
|
|
486
|
+
? 'Could not read the model catalog (offline, or the server refused it) — /models retries.'
|
|
487
|
+
: // The key travels in the environment only (client/aegis.js reads
|
|
488
|
+
// AEGIS_API_KEY); /login is an unavailable command here, so
|
|
489
|
+
// pointing at it would send the user to a refusal.
|
|
490
|
+
'No API key set, so the model catalog cannot be read — export AEGIS_API_KEY (free at https://aegiscloud.org), then retry /model.');
|
|
491
|
+
c.render();
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
// A pin that is not in the catalog is not honoured — the pool answers
|
|
495
|
+
// from its own default with no error. Say so where the pin is visible
|
|
496
|
+
// rather than letting the reply look like the pinned model.
|
|
497
|
+
if (c.ctx.model && !models.some((m) => m.id === c.ctx.model)) {
|
|
498
|
+
note(c, `pinned model "${c.ctx.model}" is not in the catalog — the pool will answer with its own default; pick one below.`);
|
|
499
|
+
}
|
|
500
|
+
// The overlay's own copy promises /model add|remove (overlays.js, a
|
|
501
|
+
// separate workstream); this build refuses both, so say here how a
|
|
502
|
+
// model is actually selected.
|
|
503
|
+
note(c, '/model <id> pins one for this session; /models lists what the server advertises.');
|
|
504
|
+
c.openOverlay({ type: 'model', items: models, sel: 0, current: c.ctx.model });
|
|
439
505
|
return true;
|
|
440
506
|
}
|
|
441
507
|
if (id === 'list') {
|
|
508
|
+
await loadModels(c);
|
|
442
509
|
const models = c.state().models || [];
|
|
443
510
|
if (!models.length) note(c, 'No pinnable models advertised — /models lists the server\'s ids.');
|
|
444
511
|
else panel(c, panels.buildModelList(c.ctx.model, models, c.ctx));
|
|
@@ -452,14 +519,28 @@ const COMMANDS = [
|
|
|
452
519
|
}
|
|
453
520
|
if (id === '-') {
|
|
454
521
|
c.ctx.model = null;
|
|
455
|
-
|
|
456
|
-
// never writes ~/.aegiscode/config.json, so a test run stays hermetic.
|
|
522
|
+
c.saveConfig({ model: null, currentModelId: null });
|
|
457
523
|
note(c, 'Model pin cleared — the server will choose.');
|
|
458
524
|
c.render();
|
|
459
525
|
return true;
|
|
460
526
|
}
|
|
461
527
|
c.ctx.model = id;
|
|
528
|
+
// Persist, so the next launch restores it. app.js's restorePrefs() reads
|
|
529
|
+
// this on startup; without the write it had nothing to restore and the
|
|
530
|
+
// pin silently evaporated at exit. Tests point AEGISCODE_HOME at a temp
|
|
531
|
+
// dir, so the write stays inside that dir.
|
|
532
|
+
c.saveConfig({ model: id, currentModelId: id });
|
|
462
533
|
note(c, `Pinned model: ${id}`);
|
|
534
|
+
// The server accepts an id it does not advertise and answers from its own
|
|
535
|
+
// default — no error, a different model, and the spend attributed to the
|
|
536
|
+
// id that was pinned. Warn (never refuse: the catalog is cached, and
|
|
537
|
+
// refusing would break a pin made against a server that is briefly
|
|
538
|
+
// unreachable) so the mismatch is visible at the moment it is created.
|
|
539
|
+
await loadModels(c);
|
|
540
|
+
const models = c.state().models || [];
|
|
541
|
+
if (models.length && !models.some((m) => m.id === id)) {
|
|
542
|
+
note(c, `"${id}" is not in the AEGIS Cloud catalog — the pool will answer with its own default. /models lists the real ids.`);
|
|
543
|
+
}
|
|
463
544
|
c.render();
|
|
464
545
|
return true;
|
|
465
546
|
},
|
|
@@ -487,7 +568,7 @@ const COMMANDS = [
|
|
|
487
568
|
c.render();
|
|
488
569
|
const line = await c.withWorking((signal) =>
|
|
489
570
|
recapLine(c.transcript, {
|
|
490
|
-
callModel: (p) => c.ask(p).then((r) => r.text),
|
|
571
|
+
callModel: (p, opts) => c.ask(p, opts).then((r) => r.text),
|
|
491
572
|
model: c.ctx.model,
|
|
492
573
|
signal,
|
|
493
574
|
}));
|
|
@@ -580,8 +661,13 @@ const COMMANDS = [
|
|
|
580
661
|
handler: async (c, args) => {
|
|
581
662
|
const m = (args.mode || '').toLowerCase();
|
|
582
663
|
if (m === 'dark' || m === 'light') {
|
|
583
|
-
|
|
584
|
-
|
|
664
|
+
// Resolve through the theme table rather than hardcoding an index, so
|
|
665
|
+
// "light" names the same row the picker would have named: index 2
|
|
666
|
+
// ("Light mode"), not index 0 ("Auto"). themeIndex is what the
|
|
667
|
+
// colorblind/ANSI palettes key off, so a wrong index silently selects
|
|
668
|
+
// the wrong palette rather than merely displaying a wrong label.
|
|
669
|
+
const want = m === 'light' ? 2 : 1;
|
|
670
|
+
screens.applyTheme(c.ctx, want);
|
|
585
671
|
} else {
|
|
586
672
|
await c.showThemePicker();
|
|
587
673
|
}
|
package/src/config.js
CHANGED
|
@@ -43,7 +43,17 @@ function permissionsPath() {
|
|
|
43
43
|
|
|
44
44
|
const DEFAULT_CONFIG = {
|
|
45
45
|
themeIndex: 1, // Dark mode
|
|
46
|
-
model
|
|
46
|
+
// No pinned model. This host runs on AEGIS Cloud, whose pinnable ids are the
|
|
47
|
+
// server's (`/models`) — a client-side default here would have to name one,
|
|
48
|
+
// and the one it named (`sonnet`) is not advertised by the platform at all:
|
|
49
|
+
// the pool accepts an unknown id and answers from its own default with no
|
|
50
|
+
// error, so the pin looked honoured while the reply came from another model.
|
|
51
|
+
// Worse, onboarding persists this object on first run (`updateConfig` merges
|
|
52
|
+
// DEFAULT_CONFIG under the patch), so the phantom pin was written to disk for
|
|
53
|
+
// every user who ever completed the trust check. `null` = no pin; the server
|
|
54
|
+
// chooses, and app.js's validatePinnedModel() clears a stored id the catalog
|
|
55
|
+
// does not advertise.
|
|
56
|
+
model: null,
|
|
47
57
|
// Phase 6: the full model table (seeded from src/models.js MODELS on first
|
|
48
58
|
// read by pickerModels()). 'currentModelId' mirrors `model` under the
|
|
49
59
|
// aegiscode- name so /model add/remove/switch stay compatible both ways.
|
package/src/events.js
CHANGED
|
@@ -249,6 +249,36 @@ function drainQueue() {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/** Test seam: forget attached stdin, timers and queued keys. */
|
|
252
|
+
/** True once a key stream is attached — i.e. whether nextKey() has a source. */
|
|
253
|
+
function isKeyStreamAttached() {
|
|
254
|
+
return !!attachedStdin;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Tear the pump down, removing the data listener.
|
|
259
|
+
*
|
|
260
|
+
* `resetKeyStream()` deliberately only *drops the reference* (it is used
|
|
261
|
+
* between tests and after a child-process handover), which leaves the listener
|
|
262
|
+
* registered on stdin. Attaching again would then run two pumps over the same
|
|
263
|
+
* bytes and dispatch every keystroke twice. Onboarding attaches before the
|
|
264
|
+
* session loop does, so it needs a real detach rather than a reset.
|
|
265
|
+
*/
|
|
266
|
+
function detachKeyStream() {
|
|
267
|
+
if (attachedStdin && onData) {
|
|
268
|
+
try {
|
|
269
|
+
attachedStdin.removeListener('data', onData);
|
|
270
|
+
} catch {
|
|
271
|
+
/* stream already gone */
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
if (typeof attachedStdin.setRawMode === 'function') attachedStdin.setRawMode(false);
|
|
275
|
+
} catch {
|
|
276
|
+
/* not a TTY */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
resetKeyStream();
|
|
280
|
+
}
|
|
281
|
+
|
|
252
282
|
function resetKeyStream() {
|
|
253
283
|
clearTimeout(escTimer);
|
|
254
284
|
clearTimeout(pasteTimer);
|
|
@@ -270,6 +300,8 @@ module.exports = {
|
|
|
270
300
|
suspendKeyStream,
|
|
271
301
|
resumeKeyStream,
|
|
272
302
|
isKeyStreamSuspended,
|
|
303
|
+
isKeyStreamAttached,
|
|
304
|
+
detachKeyStream,
|
|
273
305
|
nextKey,
|
|
274
306
|
nextKeyTimeout,
|
|
275
307
|
requeueKeys,
|
package/src/models.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AEGIS Cloud model catalog shaping — the one place `/model`, the alt+p picker
|
|
5
|
+
* and `/models` agree on what a pinnable model is.
|
|
6
|
+
*
|
|
7
|
+
* The ids a user may pin are the *server's*, never ours: the pool adds, renames
|
|
8
|
+
* and retires providers without a client release, so the catalog is fetched
|
|
9
|
+
* (`client.listModels()` → GET /api/v1/models on aegiscloud.org) and this module
|
|
10
|
+
* only normalises the payload. It deliberately invents no id, and has no
|
|
11
|
+
* fallback list: an offline client offers nothing rather than a model name that
|
|
12
|
+
* would silently route somewhere else.
|
|
13
|
+
*
|
|
14
|
+
* The platform lists per-provider ids (`deepseek`, `anthropic`, `groq`, …) plus
|
|
15
|
+
* the pooled-brain tiers. The desktop host collapsed all of that to a single
|
|
16
|
+
* "Nexus" entry because its dropdown *is* the model choice and surfacing
|
|
17
|
+
* per-provider routing invites pinning a provider that happens to be dead right
|
|
18
|
+
* now (see desktop/lib/local/engine.js selectBrainEntry). This host keeps every
|
|
19
|
+
* distinct id — a CLI user pinning `deepseek` explicitly is a legitimate
|
|
20
|
+
* request — but drops the pure-alias duplicates the server itself marks
|
|
21
|
+
* `hidden: true, alias_of: "<id>"` (`aegis-brain`, `nexus-brain-smart`, …),
|
|
22
|
+
* which are the same model advertised under another spelling.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A pinned id of `null` means "no pin — let the pool choose" (the server's own default). */
|
|
26
|
+
const NO_PIN = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Labels for catalog ids the platform advertises without capabilities or a
|
|
30
|
+
* label of their own — `openai-gpt4o-mini` reads like a typo next to `openai`,
|
|
31
|
+
* and the brain tier is the one id whose *cost shape* a user needs before
|
|
32
|
+
* pinning it: verified live 2026-09-14, `model: "nexus-brain"` streams
|
|
33
|
+
* `pool-brain: 3 workers · effort=high · tier=brain · passes=4`, i.e. four
|
|
34
|
+
* billed provider calls per turn, where a provider id is one.
|
|
35
|
+
*/
|
|
36
|
+
const ID_NOTES = Object.freeze({
|
|
37
|
+
'openai-gpt4o-mini': 'OpenAI gpt-4o-mini, pooled',
|
|
38
|
+
'anthropic-haiku': 'Anthropic Haiku, pooled',
|
|
39
|
+
'nexus-brain': 'pooled brain · 3 workers + synthesis',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** One raw entry (string id or object) → a catalog entry, or null when unusable. */
|
|
43
|
+
function normalizeModel(entry) {
|
|
44
|
+
const m = typeof entry === 'string' ? { id: entry } : entry;
|
|
45
|
+
if (!m || typeof m !== 'object') return null;
|
|
46
|
+
const id = typeof m.id === 'string' ? m.id.trim() : '';
|
|
47
|
+
if (!id) return null;
|
|
48
|
+
const aliasOf =
|
|
49
|
+
typeof m.alias_of === 'string' && m.alias_of.trim() ? m.alias_of.trim() : null;
|
|
50
|
+
const capabilities = Array.isArray(m.capabilities)
|
|
51
|
+
? m.capabilities.filter((c) => typeof c === 'string' && c)
|
|
52
|
+
: [];
|
|
53
|
+
const label =
|
|
54
|
+
(typeof m.label === 'string' && m.label.trim()) ||
|
|
55
|
+
(typeof m.name === 'string' && m.name.trim()) ||
|
|
56
|
+
id;
|
|
57
|
+
// The picker's right-hand column: what this entry *is*. An alias says so —
|
|
58
|
+
// otherwise a user reads five brain tiers and assumes five different models.
|
|
59
|
+
const note = aliasOf
|
|
60
|
+
? `alias of ${aliasOf}`
|
|
61
|
+
: ID_NOTES[id] || capabilities.join(', ');
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
label,
|
|
65
|
+
note,
|
|
66
|
+
hidden: m.hidden === true,
|
|
67
|
+
aliasOf,
|
|
68
|
+
capabilities,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalise a raw `/api/v1/models` payload (`{models: [...]}` or the array). */
|
|
73
|
+
function normalizeModelCatalog(raw) {
|
|
74
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw && raw.models) ? raw.models : [];
|
|
75
|
+
const out = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const entry of list) {
|
|
78
|
+
const m = normalizeModel(entry);
|
|
79
|
+
if (!m || seen.has(m.id)) continue;
|
|
80
|
+
seen.add(m.id);
|
|
81
|
+
out.push(m);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The entries the picker and `/models` offer: every distinct advertised model,
|
|
88
|
+
* minus the alias duplicates — unless the catalog is *only* aliases, in which
|
|
89
|
+
* case the aliases are all the server advertises and are offered rather than
|
|
90
|
+
* leaving the user with an empty list.
|
|
91
|
+
*/
|
|
92
|
+
function pickerEntries(catalog) {
|
|
93
|
+
const all = Array.isArray(catalog) ? catalog.filter(Boolean) : [];
|
|
94
|
+
const distinct = all.filter((m) => !m.aliasOf);
|
|
95
|
+
return distinct.length ? distinct : all;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The catalog entry for `id`, or null — the "is this a real id?" question. */
|
|
99
|
+
function findModel(catalog, id) {
|
|
100
|
+
const want = typeof id === 'string' ? id.trim() : '';
|
|
101
|
+
if (!want) return null;
|
|
102
|
+
return (Array.isArray(catalog) ? catalog : []).find((m) => m && m.id === want) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The set of ids the server accepts, lower-cased: a catalog id and a pinned id
|
|
107
|
+
* are the same thing only when they match byte-for-byte, but `/model Nexus-Brain`
|
|
108
|
+
* is a typo a user will make and the server's routing is case-insensitive
|
|
109
|
+
* enough that warning about it would be noise.
|
|
110
|
+
*/
|
|
111
|
+
function catalogIds(catalog) {
|
|
112
|
+
return new Set((Array.isArray(catalog) ? catalog : []).map((m) => String((m && m.id) || '').toLowerCase()).filter(Boolean));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
NO_PIN,
|
|
117
|
+
ID_NOTES,
|
|
118
|
+
normalizeModel,
|
|
119
|
+
normalizeModelCatalog,
|
|
120
|
+
pickerEntries,
|
|
121
|
+
findModel,
|
|
122
|
+
catalogIds,
|
|
123
|
+
};
|
package/src/render.js
CHANGED
|
@@ -416,6 +416,10 @@ module.exports = {
|
|
|
416
416
|
renderNotice,
|
|
417
417
|
mdLines,
|
|
418
418
|
inline,
|
|
419
|
+
// Welcome-mark pieces, shared with the onboarding screens so there is exactly
|
|
420
|
+
// one renderer for the mascot/moon/whale mark rather than two that can drift.
|
|
421
|
+
artRow,
|
|
422
|
+
tintWhale,
|
|
419
423
|
// Style helpers kept for legacy consumers.
|
|
420
424
|
fg,
|
|
421
425
|
bg,
|