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/src/screens.js ADDED
@@ -0,0 +1,463 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Onboarding screens — trust check, theme picker, welcome.
5
+ *
6
+ * A direct port of `aegiscodex-dev/src/screens.js`, which is where the
7
+ * reference's session actually begins: a genuine first run is
8
+ *
9
+ * showTrustCheck(ctx) → showThemePicker(ctx) → updateConfig() → welcome
10
+ *
11
+ * and every later run goes straight to the welcome box. The CLI had none of
12
+ * this — `runInteractive` went directly into `chatflow.runSession`, so the
13
+ * trust check, the theme picker and the pre-session welcome did not exist and
14
+ * `configExists()` was dead code with zero callers. That is the gap this file
15
+ * closes; the ordering and the copy are the reference's.
16
+ *
17
+ * Two deliberate departures, both so this client stays honest:
18
+ *
19
+ * - `runOnboarding` **returns** whether the user declined rather than calling
20
+ * `process.exit` from inside a library function, so the caller owns process
21
+ * lifecycle (and a test can drive the whole flow).
22
+ * - The `What's new` box lists *this* package's release notes, not
23
+ * Aegiscodex's: printing another project's changelog in our banner would be
24
+ * a lie about what the user just installed.
25
+ *
26
+ * Every screen is split into a pure line-builder (`trustLines`,
27
+ * `themePickerLines`, `welcomeLines`) plus a thin key loop, so the exact
28
+ * content of each screen is asserted by tests with no terminal and no timing.
29
+ */
30
+
31
+ const { getSize, paint, span, lineWidth, clip, w } = require('./screen.js');
32
+ const events = require('./events.js');
33
+ const { nextKey, KEY } = events;
34
+ const { C, BOLD, BOLD_OFF, GLYPH, THEME_TABLE, themeOf } = require('./theme.js');
35
+ const { welcomeArtParts } = require('./art.js');
36
+ const { renderDiffPreview } = require('./markdown.js');
37
+ const render = require('./render.js');
38
+ const { updateConfig, configExists } = require('./config.js');
39
+
40
+ const VERSION = require('../package.json').version;
41
+
42
+ const PRODUCT = 'AEGIS Code';
43
+
44
+ /** The two boxes' contents. `What's new` is this package's own changelog. */
45
+ const TIPS = [
46
+ ' Run /init to create an AEGIS.md',
47
+ ' Use ↑↓ to recall past prompts',
48
+ ' Press / for commands',
49
+ ' Press Tab to complete a command',
50
+ ' Press ? for shortcuts',
51
+ ];
52
+
53
+ const WHATS_NEW = [
54
+ ' v6.3.0: onboarding — trust check,',
55
+ ' theme picker and this welcome',
56
+ ' v6.1.0: the / palette, the full',
57
+ ' command registry, session',
58
+ ' persistence and auto-checkpoints',
59
+ ' v6.0.0: the aegiscodex-dev design',
60
+ ' system — palette, mark, verbs',
61
+ ' /release-notes for more',
62
+ ];
63
+
64
+ /** Centre a plain string, returning one span line. Clip first, or a string
65
+ * wider than the terminal produces a row that wraps and shears the frame. */
66
+ function centered(text, cols, style) {
67
+ const t = clip(String(text), Math.max(1, cols));
68
+ const pad = Math.max(0, Math.floor((cols - w(t)) / 2));
69
+ return [span('', ' '.repeat(pad)), span(style, t)];
70
+ }
71
+
72
+ /** Wrap body copy to `cols - 4`, one span line per row (the reference's
73
+ * wrapGray, kept here because screen.js's wrapBlock takes spans). */
74
+ function wrapped(text, cols, style, indent = 0) {
75
+ const words = String(text).split(' ');
76
+ const out = [];
77
+ let line = [];
78
+ let len = 0;
79
+ for (const word of words) {
80
+ if (len + w(word) + 1 > cols - 4 - indent && line.length) {
81
+ out.push([span('', ' '.repeat(indent)), span(style, line.join(' '))]);
82
+ line = [word];
83
+ len = w(word);
84
+ } else {
85
+ if (line.length) len++;
86
+ line.push(word);
87
+ len += w(word);
88
+ }
89
+ }
90
+ if (line.length) out.push([span('', ' '.repeat(indent)), span(style, line.join(' '))]);
91
+ return out;
92
+ }
93
+
94
+ // ── trust check ──────────────────────────────────────────────────────────────
95
+
96
+ /** The trust screen's lines. `sel` is 0 = trust, 1 = exit. Pure. */
97
+ function trustLines(ctx, cols, sel = 0, cwd = process.cwd()) {
98
+ const lines = [];
99
+ lines.push([span(C.gold, '─'.repeat(Math.max(1, cols)))]);
100
+ lines.push([span(C.white + BOLD, 'Accessing workspace:')]);
101
+ lines.push([span(C.white + BOLD, clip(cwd, Math.max(1, cols - 1)))]);
102
+ lines.push([span('', '')]);
103
+ for (const l of wrapped(
104
+ `Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first.`,
105
+ cols,
106
+ C.white
107
+ )) {
108
+ lines.push(l);
109
+ }
110
+ lines.push([span('', '')]);
111
+ lines.push([span(C.white, `${PRODUCT} will be able to read, edit, and execute files here.`)]);
112
+ lines.push([span('', '')]);
113
+ lines.push([span(C.white + BOLD, 'Security guide')]);
114
+ lines.push([span('', '')]);
115
+ for (let i = 0; i < 2; i++) {
116
+ const active = sel === i;
117
+ const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
118
+ const n = span(C.gray, ` ${i + 1}.`);
119
+ const label = i === 0 ? 'Yes, I trust this folder' : 'No, exit';
120
+ lines.push([
121
+ left,
122
+ n,
123
+ span(active ? C.lavender : C.white, label),
124
+ ]);
125
+ }
126
+ lines.push([span('', '')]);
127
+ lines.push([span(C.gray, `Enter to confirm ${GLYPH.bullet} Esc to cancel`)]);
128
+ return lines;
129
+ }
130
+
131
+ /**
132
+ * The trust check. Resolves true when the folder is trusted, false when the
133
+ * user declines — the caller must abort the session on false, as the reference
134
+ * does (it never reaches `session(ctx)`).
135
+ */
136
+ async function showTrustCheck(ctx) {
137
+ const { cols } = getSize();
138
+ let sel = 0;
139
+ const renderScreen = () => paint(trustLines(ctx, cols, sel));
140
+ renderScreen();
141
+ for (;;) {
142
+ const key = await nextKey();
143
+ if (key.name === KEY.UP || key.name === KEY.DOWN || key.name === KEY.TAB) {
144
+ sel = 1 - sel;
145
+ renderScreen();
146
+ } else if (key.name === KEY.ENTER) {
147
+ return sel === 0;
148
+ } else if (key.name === KEY.ESC || key.name === KEY.CTRL_C || key.name === KEY.CTRL_D) {
149
+ return false;
150
+ } else if (key.name === 'char') {
151
+ const c = String(key.ch).trim();
152
+ if (c === '1') {
153
+ sel = 0;
154
+ renderScreen();
155
+ }
156
+ if (c === '2') {
157
+ sel = 1;
158
+ renderScreen();
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // ── theme picker ─────────────────────────────────────────────────────────────
165
+
166
+ /** Apply a picker row to a context: both the row index and the light flag, the
167
+ * way the reference commits (`ctx.light = sel === 2 || sel === 4 || sel === 6`). */
168
+ function applyTheme(ctx, sel) {
169
+ const row = THEME_TABLE[sel] || THEME_TABLE[1];
170
+ ctx.themeIndex = sel;
171
+ ctx.light = row.light;
172
+ return sel;
173
+ }
174
+
175
+ /** The theme picker's lines. Pure, so the 7 rows and their notes are testable. */
176
+ function themePickerLines(ctx, cols, rows, sel) {
177
+ const lines = [];
178
+ lines.push([span(C.coral, `Welcome to ${PRODUCT} v${VERSION}`)]);
179
+ lines.push([span(C.white + BOLD, "Let's get started.")]);
180
+ lines.push([span(C.white, 'Choose the text style that looks best with your terminal')]);
181
+ lines.push([span(C.gray, 'To change this later, run /theme')]);
182
+ lines.push([span('', '')]);
183
+ for (let i = 0; i < THEME_TABLE.length; i++) {
184
+ const t = THEME_TABLE[i];
185
+ const active = sel === i;
186
+ const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
187
+ const n = span(C.gray, `${i + 1}.`);
188
+ const check = active ? span(C.green, ' ' + GLYPH.check) : span('', '');
189
+ lines.push([
190
+ left,
191
+ n,
192
+ span(active ? C.lavender : C.white, t.name),
193
+ ...(t.note ? [span(C.gray, ' ' + t.note)] : []),
194
+ check,
195
+ ]);
196
+ }
197
+ // The diff preview is drawn in this terminal's palette, so a row actually
198
+ // shows what it will look like rather than describing it.
199
+ for (const l of renderDiffPreview(cols, ctx)) lines.push(l);
200
+ lines.push([span(C.gray, ' Syntax theme: Monokai Extended (ctrl+t to disable)')]);
201
+ while (lines.length < rows - 1) lines.push([span('', '')]);
202
+ return lines;
203
+ }
204
+
205
+ /**
206
+ * The theme picker. Resolves the chosen row index. Esc commits the highlighted
207
+ * row rather than discarding it, matching the reference — there is no "cancel"
208
+ * that leaves the theme unset on a first run.
209
+ */
210
+ async function showThemePicker(ctx, title = `Welcome to ${PRODUCT}`) {
211
+ const { cols, rows } = getSize();
212
+ let sel = typeof ctx.themeIndex === 'number' ? ctx.themeIndex : 1;
213
+ const renderScreen = () => paint(themePickerLines(ctx, cols, rows, sel));
214
+ renderScreen();
215
+ for (;;) {
216
+ const key = await nextKey();
217
+ if (key.name === KEY.UP) {
218
+ sel = Math.max(0, sel - 1);
219
+ renderScreen();
220
+ } else if (key.name === KEY.DOWN) {
221
+ sel = Math.min(THEME_TABLE.length - 1, sel + 1);
222
+ renderScreen();
223
+ } else if (key.name === KEY.ENTER || key.name === KEY.ESC || key.name === KEY.CTRL_C || key.name === KEY.CTRL_D) {
224
+ return applyTheme(ctx, sel);
225
+ } else if (key.name === KEY.CTRL_T) {
226
+ ctx.light = !ctx.light;
227
+ renderScreen();
228
+ } else if (key.name === 'char') {
229
+ const n = parseInt(key.ch, 10);
230
+ if (n >= 1 && n <= THEME_TABLE.length) {
231
+ sel = n - 1;
232
+ renderScreen();
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ // ── welcome ──────────────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * The welcome screen's lines: gold rule, the two-tone mark, the coral title,
242
+ * the two boxes, the footer hint. Pure, so a test can assert all of it.
243
+ *
244
+ * The mark reuses `render.artRow`, the same renderer the scrollback banner
245
+ * uses, so the mascot cannot render two different ways in two places.
246
+ */
247
+ function welcomeLines(ctx, cols, rows, firstRun = true) {
248
+ const t = themeOf(ctx);
249
+ const lines = [];
250
+ lines.push([span(t.gold, '━' + '─'.repeat(Math.max(0, cols - 2)) + '━')]);
251
+ lines.push([span('', '')]);
252
+
253
+ const parts = welcomeArtParts(cols);
254
+ if (cols >= parts.width + 2) {
255
+ const leftPad = Math.max(0, Math.floor((cols - parts.width) / 2));
256
+ for (const row of parts.rows) {
257
+ lines.push(wrapPlain(render.artRow(ctx, row, parts, leftPad)));
258
+ }
259
+ } else {
260
+ lines.push(centered(`${PRODUCT}`, cols, t.gold + BOLD));
261
+ }
262
+ lines.push([span('', '')]);
263
+
264
+ const title = firstRun ? `Welcome to ${PRODUCT}` : 'Welcome back!';
265
+ lines.push(centered(title, cols, t.coral + BOLD));
266
+ // Shorten the subtitle rather than clipping it: a truncated tagline reads as
267
+ // a rendering fault, a shorter one just reads as a shorter one.
268
+ const tagline =
269
+ cols >= 40
270
+ ? 'Cloud brain in your shell — one account, three hosts.'
271
+ : 'Cloud brain in your shell.';
272
+ lines.push(centered(tagline, cols, t.gray));
273
+ lines.push([span('', '')]);
274
+
275
+ lines.push(...boxes(t, cols, rows - lines.length - 4));
276
+ lines.push([span('', '')]);
277
+ lines.push([span(t.gray, GLYPH.hint), span(t.white, ' Try "write a test for <filepath>"')]);
278
+ while (lines.length < rows - 1) lines.push([span('', '')]);
279
+ return lines;
280
+ }
281
+
282
+ /**
283
+ * One box: a `╭─ Header ────╮` top rule, the body rows, a bottom rule. The
284
+ * reference draws only the side rails, which reads as a table next to the
285
+ * identity panel's rules; the rounds match the CLI's overlay frames.
286
+ */
287
+ function boxLines(t, header, rows, boxW) {
288
+ const inner = Math.max(1, boxW - 4);
289
+ // The top rule must be exactly `boxW` cells, like the body rows and the
290
+ // bottom rule: `╭` + `─` + head + dashes + `╮`. Deriving the pad from
291
+ // `inner + 2 - head.length` made the rule one cell too wide, so a full-width
292
+ // pair of boxes overran the terminal by two columns and wrapped, shearing the
293
+ // frame. The header is clipped rather than allowed to push the box open.
294
+ const head = ` ${clip(header, Math.max(0, boxW - 4))} `;
295
+ const top = [
296
+ span(t.gray, '╭─'),
297
+ span(t.gray + BOLD, head),
298
+ span(t.gray, '─'.repeat(Math.max(0, boxW - 3 - head.length)) + '╮'),
299
+ ];
300
+ const out = [top];
301
+ for (const r of rows) {
302
+ const text = clip(String(r), inner);
303
+ out.push([span(t.gray, '│ '), span(t.white, text + ' '.repeat(Math.max(0, inner - w(text)))), span(t.gray, ' │')]);
304
+ }
305
+ out.push([span(t.gray, '╰' + '─'.repeat(inner + 2) + '╯')]);
306
+ return out;
307
+ }
308
+
309
+ /** The Tips + What's new boxes, side by side (stacked when too narrow). */
310
+ function boxes(t, cols, avail) {
311
+ const gap = 2;
312
+ const twoUp = Math.floor((cols - gap) / 2);
313
+ const tipRows = TIPS.map((s) => `${GLYPH.pointer}${s}`);
314
+ const newsRows = WHATS_NEW;
315
+ if (twoUp >= 30) {
316
+ let a = boxLines(t, 'Tips for getting started', tipRows, twoUp);
317
+ let b = boxLines(t, "What's new", newsRows, twoUp);
318
+ // Equalise the heights so both bottom rules land on the same row — boxes of
319
+ // different heights leave a ragged pair of corners otherwise.
320
+ const h = Math.max(a.length, b.length);
321
+ const padBox = (box) => {
322
+ if (box.length >= h) return box;
323
+ const out = box.slice(0, box.length - 1);
324
+ const blank = [span(t.gray, '│ '), span('', ' '.repeat(Math.max(0, twoUp - 4))), span(t.gray, ' │')];
325
+ while (out.length < h - 1) out.push(blank);
326
+ out.push(box[box.length - 1]);
327
+ return out;
328
+ };
329
+ a = padBox(a);
330
+ b = padBox(b);
331
+ const out = [];
332
+ for (let i = 0; i < h; i++) {
333
+ out.push([...a[i], span('', ' '.repeat(gap)), ...b[i]]);
334
+ }
335
+ return out;
336
+ }
337
+ // Narrow: stack, so neither box is rendered at an unreadable width.
338
+ const wd = Math.max(20, cols - 1);
339
+ return [...boxLines(t, 'Tips for getting started', tipRows, wd), ...boxLines(t, "What's new", newsRows, wd)];
340
+ }
341
+
342
+ /**
343
+ * Split a plain ANSI string back into the span model `paint` expects. Only the
344
+ * SGR prefixes `render.artRow` emits are recognised, and every non-SGR run is
345
+ * taken verbatim, so an unknown escape degrades to visible text rather than
346
+ * being silently dropped.
347
+ */
348
+ function wrapPlain(s) {
349
+ const out = [];
350
+ const re = /\x1b\[[0-9;]*m/g;
351
+ let last = 0;
352
+ let style = '';
353
+ let m;
354
+ while ((m = re.exec(s)) !== null) {
355
+ if (m.index > last) out.push(make(style, s.slice(last, m.index)));
356
+ style = m[0];
357
+ last = m.index + m[0].length;
358
+ }
359
+ if (last < s.length) out.push(make(style, s.slice(last)));
360
+ return out;
361
+ }
362
+
363
+ function make(style, text) {
364
+ const sp = span(style, text);
365
+ if (style) {
366
+ sp.s = style;
367
+ sp.w = w(text);
368
+ }
369
+ return sp;
370
+ }
371
+
372
+ /**
373
+ * The welcome screen. Enter or Esc continues into the session; ctrl+c/d is the
374
+ * user asking to leave, which is returned to the caller as `{ exit: true }`
375
+ * rather than calling `process.exit` from here.
376
+ */
377
+ async function showWelcome(ctx, firstRun = true) {
378
+ const renderScreen = () => {
379
+ const { cols, rows } = getSize();
380
+ paint(welcomeLines(ctx, cols, rows, firstRun));
381
+ };
382
+ renderScreen();
383
+ for (;;) {
384
+ const key = await nextKey();
385
+ if (key.name === KEY.ENTER || key.name === KEY.ESC) return { exit: false };
386
+ if (key.name === KEY.CTRL_C || key.name === KEY.CTRL_D) return { exit: true };
387
+ }
388
+ }
389
+
390
+ // ── the sequence ─────────────────────────────────────────────────────────────
391
+
392
+ /**
393
+ * Run the pre-session onboarding, exactly as the reference orders it.
394
+ *
395
+ * @param {object} ctx the shared command context (in: themeIndex, light)
396
+ * @param {object} [o]
397
+ * @param {boolean} [o.continue] skip onboarding (the reference's --continue)
398
+ * @param {() => boolean} [o.seen] an explicit "has run before" probe; defaults
399
+ * to the config file's existence
400
+ * @param {(patch:object)=>void} [o.save] persist patch; defaults to updateConfig
401
+ * @returns {Promise<{ok:boolean, firstRun:boolean, themeIndex:number}>} `ok`
402
+ * is false when the user declined the trust check or asked to exit.
403
+ */
404
+ async function runOnboarding(ctx, o = {}) {
405
+ const seen = o.seen || configExists;
406
+ const save = o.save || updateConfig;
407
+ // Injectable so the *sequence* — which screens run, in what order, and what is
408
+ // persisted — can be asserted without a terminal. The screens themselves are
409
+ // tested directly through their pure line-builders.
410
+ const ui = o.ui || { showTrustCheck, showThemePicker, showWelcome };
411
+ if (o.continue) return { ok: true, firstRun: false, themeIndex: ctx.themeIndex };
412
+
413
+ // Onboarding runs *before* the session loop, and the session loop is what
414
+ // normally attaches the key pump — so without this the first screen paints and
415
+ // then blocks forever on a key queue nothing feeds. Only attach if nobody
416
+ // else has (the chatflow re-attaches for the session), and only ever detach
417
+ // what we attached.
418
+ const owns = !events.isKeyStreamAttached() && !!(process.stdin && process.stdin.isTTY);
419
+ if (owns) events.attachKeyStream(process.stdin);
420
+ try {
421
+ return await runScreens();
422
+ } finally {
423
+ if (owns) events.detachKeyStream();
424
+ }
425
+
426
+ async function runScreens() {
427
+ if (!seen()) {
428
+ // Genuine first run: trust check, then the theme picker, then persist so
429
+ // neither is ever shown again. Re-running this every launch greeted
430
+ // returning users with "Let's get started." and discarded their session.
431
+ const trusted = await ui.showTrustCheck(ctx);
432
+ if (!trusted) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex };
433
+ await ui.showThemePicker(ctx);
434
+ save({ themeIndex: ctx.themeIndex, light: ctx.light });
435
+ const welcome = await ui.showWelcome(ctx, true);
436
+ if (welcome && welcome.exit) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex };
437
+ return { ok: true, firstRun: true, themeIndex: ctx.themeIndex };
438
+ }
439
+
440
+ const welcome = await ui.showWelcome(ctx, false);
441
+ if (welcome && welcome.exit) return { ok: false, firstRun: false, themeIndex: ctx.themeIndex };
442
+ return { ok: true, firstRun: false, themeIndex: ctx.themeIndex };
443
+ }
444
+ }
445
+
446
+ module.exports = {
447
+ PRODUCT,
448
+ TIPS,
449
+ WHATS_NEW,
450
+ trustLines,
451
+ themePickerLines,
452
+ applyTheme,
453
+ welcomeLines,
454
+ boxes,
455
+ boxLines,
456
+ centered,
457
+ wrapped,
458
+ wrapPlain,
459
+ showTrustCheck,
460
+ showThemePicker,
461
+ showWelcome,
462
+ runOnboarding,
463
+ };
package/src/theme.js CHANGED
@@ -127,9 +127,69 @@ const DONE_VERBS = ['Churned', 'Worked'];
127
127
 
128
128
  const THEMES = { dark: C, light: LIGHT };
129
129
 
130
- /** The palette object for a context. Colours are always derived from one of the
131
- * two theme objects never typed inline. */
130
+ /**
131
+ * The two extra palette families the reference's theme picker offers.
132
+ *
133
+ * `cb` is the colourblind-friendly variant: the red/green pair (the one that
134
+ * carries meaning in diffs and errors) is remapped to the blue/orange axis so
135
+ * the two remain distinguishable under deuteranopia and protanopia. `ansi` uses
136
+ * the terminal's own 16-colour table, for terminals whose truecolour output is
137
+ * remapped anyway — the whole point being that the *terminal* decides, not us.
138
+ */
139
+ const CB_DARK = { ...C, green: RGB(86, 182, 194), red: RGB(255, 146, 43), gold: RGB(255, 203, 71) };
140
+ const CB_LIGHT = { ...LIGHT, green: RGB(30, 130, 145), red: RGB(200, 100, 20), gold: RGB(150, 110, 0) };
141
+
142
+ // 16-colour SGR foregrounds. Written as escape strings rather than RGB triples
143
+ // because the variant exists precisely to hand the decision to the terminal.
144
+ const A = (n) => `\x1b[${n}m`;
145
+ const ANSI_DARK = {
146
+ gold: A(93), coral: A(91), lavender: A(94), blue: A(94), green: A(92),
147
+ red: A(91), gray: A(90), dim: A(90), white: A(97), black: A(40),
148
+ darkBg: '\x1b[48;5;236m',
149
+ };
150
+ const ANSI_LIGHT = {
151
+ gold: A(33), coral: A(31), lavender: A(34), blue: A(34), green: A(32),
152
+ red: A(31), gray: A(90), dim: A(37), white: A(30), black: A(47),
153
+ darkBg: '\x1b[48;5;254m',
154
+ };
155
+
156
+ /**
157
+ * The theme picker's table, copied from `aegiscodex-dev/src/screens.js` THEMES
158
+ * (names and notes verbatim). `light` is the flag the reference sets on commit
159
+ * (`ctx.light = sel === 2 || sel === 4 || sel === 6`); `palette` is what this
160
+ * client resolves the row to, since it has no terminal-background probe and so
161
+ * resolves "Auto" to the dark palette.
162
+ */
163
+ const THEME_TABLE = [
164
+ { name: 'Auto', note: '(match terminal)', light: false, palette: C },
165
+ { name: 'Dark mode', note: '', light: false, palette: C },
166
+ { name: 'Light mode', note: '', light: true, palette: LIGHT },
167
+ { name: 'Dark mode', note: '(colorblind-friendly)', light: false, palette: CB_DARK },
168
+ { name: 'Light mode', note: '(colorblind-friendly)', light: true, palette: CB_LIGHT },
169
+ { name: 'Dark mode', note: '(ANSI colors only)', light: false, palette: ANSI_DARK },
170
+ { name: 'Light mode', note: '(ANSI colors only)', light: true, palette: ANSI_LIGHT },
171
+ ];
172
+
173
+ /** The palette object for a theme-picker row index (out-of-range → dark). */
174
+ function themeForIndex(i) {
175
+ const row = THEME_TABLE[i];
176
+ return row ? row.palette : C;
177
+ }
178
+
179
+ /**
180
+ * The palette object for a context. Colours are always derived from one of the
181
+ * theme objects — never typed inline.
182
+ *
183
+ * The light flag alone decides for the three default-brightness rows (indices
184
+ * 0–2), which keeps `themeOf({light:true}) === LIGHT` and `themeOf({}) === C`
185
+ * exactly as before. The colourblind and ANSI families are only consulted when
186
+ * `themeIndex` actually names one of them (3–6), so an app that seeds
187
+ * `themeIndex` to 0/1 for light/dark is unaffected.
188
+ */
132
189
  function themeOf(ctx) {
190
+ if (ctx && typeof ctx.themeIndex === 'number' && ctx.themeIndex >= 3 && ctx.themeIndex <= 6) {
191
+ return themeForIndex(ctx.themeIndex);
192
+ }
133
193
  return ctx && ctx.light ? LIGHT : C;
134
194
  }
135
195
 
@@ -148,7 +208,13 @@ module.exports = {
148
208
  RESET_BG,
149
209
  C,
150
210
  LIGHT,
211
+ CB_DARK,
212
+ CB_LIGHT,
213
+ ANSI_DARK,
214
+ ANSI_LIGHT,
151
215
  THEMES,
216
+ THEME_TABLE,
217
+ themeForIndex,
152
218
  GLYPH,
153
219
  VERBS,
154
220
  DONE_VERBS,
@@ -473,6 +473,17 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
473
473
 
474
474
  async function listModels(cls) {
475
475
  if (cls === 'aegis') {
476
+ // The catalog is behind the account's key: GET /api/v1/models answers
477
+ // `401 {"error":{"message":"No API key"}}` without one (verified against
478
+ // aegiscloud.org). Aegis Cloud is the *default* class, so firing the call
479
+ // anyway painted a raw "listModels failed: … 401" over the default model
480
+ // picker for every user who had just installed the app and not yet
481
+ // pasted a key — the one state where the UI must say what unblocks it and
482
+ // not what went wrong. Report the missing key as a state (`needsKey`) and
483
+ // let the renderer invite the user to connect; nothing else about the
484
+ // class changes, and the model dropdown keeps its "server default (auto)"
485
+ // entry so the class is usable the moment a key lands.
486
+ if (!aegis.apiKey) return { class: cls, models: [], needsKey: true };
476
487
  const data = await aegis.listModels();
477
488
  return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
478
489
  }