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/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
|
-
/**
|
|
131
|
-
*
|
|
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
|
}
|