termi-kids 0.1.0
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/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The home menu, the new-project flow, and the badge shelf state.
|
|
3
|
+
*
|
|
4
|
+
* Badge state is a small JSON file at TERMI_HOME/badges.json. markBadge
|
|
5
|
+
* returns true only on a first earn, and awardBadge celebrates that moment.
|
|
6
|
+
* Cross-wave modules (project store, create, chat) load lazily inside the
|
|
7
|
+
* functions that need them, so this module imports cleanly on its own.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import * as p from '@clack/prompts';
|
|
12
|
+
import { atomicWriteFileSync, termiHome } from '../config/paths.js';
|
|
13
|
+
import { saveSettings } from '../config/settings.js';
|
|
14
|
+
import { guardFetchState, guardProgressBar } from '../safety/guarddownload.js';
|
|
15
|
+
import { nameIsOkay } from '../safety/prefilter.js';
|
|
16
|
+
import { scaffolds, scaffoldById } from '../projects/scaffolds/index.js';
|
|
17
|
+
import { suggestProjectNames } from '../setup/wizard.js';
|
|
18
|
+
import { BADGES, celebrate, confetti, renderBadgeShelf } from '../ui/celebrate.js';
|
|
19
|
+
import { mascot } from '../ui/mascot.js';
|
|
20
|
+
import { style } from '../ui/theme.js';
|
|
21
|
+
import { T } from '../ui/text.js';
|
|
22
|
+
import { executeIdeas } from './commands.js';
|
|
23
|
+
/** Where the earned-badge list lives. */
|
|
24
|
+
export function badgesFilePath() {
|
|
25
|
+
return path.join(termiHome(), 'badges.json');
|
|
26
|
+
}
|
|
27
|
+
/** The earned badge ids, oldest first. Missing or broken file means none. */
|
|
28
|
+
export function loadBadges() {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(fs.readFileSync(badgesFilePath(), 'utf8'));
|
|
31
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
32
|
+
const earned = parsed.earned;
|
|
33
|
+
if (Array.isArray(earned)) {
|
|
34
|
+
return earned.filter((id) => typeof id === 'string');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// No file yet means no badges yet.
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Records a badge as earned. Returns true only the first time, false for
|
|
45
|
+
* repeats and for ids that are not real badges.
|
|
46
|
+
*/
|
|
47
|
+
export function markBadge(id) {
|
|
48
|
+
if (!BADGES.some((badge) => badge.id === id)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const earned = loadBadges();
|
|
52
|
+
if (earned.includes(id)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
earned.push(id);
|
|
56
|
+
atomicWriteFileSync(badgesFilePath(), JSON.stringify({ earned }, null, 2));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Marks a badge and, on a first earn, throws a small celebration.
|
|
61
|
+
* Returns true when this was the first earn.
|
|
62
|
+
*/
|
|
63
|
+
export async function awardBadge(id, write = (text) => {
|
|
64
|
+
console.log(text);
|
|
65
|
+
}) {
|
|
66
|
+
const first = markBadge(id);
|
|
67
|
+
if (!first) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const def = BADGES.find((badge) => badge.id === id);
|
|
71
|
+
const label = def !== undefined ? def.label : id;
|
|
72
|
+
const fast = process.env.TERMI_FAST_TEXT === '1';
|
|
73
|
+
await confetti(4, { delayMs: fast ? 0 : 70, write: (chunk) => write(chunk.replace(/\n$/, '')) });
|
|
74
|
+
write(celebrate(T.celebrations.badgeEarned.replace('{badge}', label)));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Pulls the recap line out of a TERMI.md body. Tolerant of two shapes:
|
|
79
|
+
* an inline "Recap: ..." line (bullets and bold allowed) and a
|
|
80
|
+
* "## Recap" heading followed by the recap text.
|
|
81
|
+
*/
|
|
82
|
+
export function recapFromTermiMd(text) {
|
|
83
|
+
const lines = text.split('\n');
|
|
84
|
+
for (const rawLine of lines) {
|
|
85
|
+
const line = rawLine.trim().replace(/\*\*/g, '').replace(/^[-*]\s*/, '');
|
|
86
|
+
const inline = /^recap(?:[ -]?line)?\s*[:=]\s*(.+)$/i.exec(line);
|
|
87
|
+
if (inline?.[1] !== undefined && inline[1].trim().length > 0) {
|
|
88
|
+
return inline[1].trim();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
92
|
+
if (/^#{1,6}\s*recap/i.test(lines[i]?.trim() ?? '')) {
|
|
93
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
94
|
+
const candidate = lines[j]?.trim() ?? '';
|
|
95
|
+
if (candidate.length > 0 && !candidate.startsWith('#')) {
|
|
96
|
+
return candidate.replace(/^[-*]\s*/, '');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
async function loadProjectModules() {
|
|
104
|
+
try {
|
|
105
|
+
const store = await import('../projects/store.js');
|
|
106
|
+
const create = await import('../projects/create.js');
|
|
107
|
+
return { store, create };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const CUSTOM_NAME = '__custom__';
|
|
114
|
+
async function pickProjectName(themeLabel) {
|
|
115
|
+
const suggestions = suggestProjectNames(themeLabel);
|
|
116
|
+
const pick = await p.select({
|
|
117
|
+
message: 'Pick a name.',
|
|
118
|
+
options: [
|
|
119
|
+
...suggestions.map((name) => ({ value: name, label: name })),
|
|
120
|
+
{ value: CUSTOM_NAME, label: 'Type my own name' },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
if (p.isCancel(pick)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (pick !== CUSTOM_NAME) {
|
|
127
|
+
return pick;
|
|
128
|
+
}
|
|
129
|
+
const typed = await p.text({
|
|
130
|
+
message: 'What is its name?',
|
|
131
|
+
validate: (value) => {
|
|
132
|
+
const trimmed = (value ?? '').trim();
|
|
133
|
+
if (trimmed.length === 0)
|
|
134
|
+
return 'It needs a name.';
|
|
135
|
+
if (!nameIsOkay(trimmed))
|
|
136
|
+
return 'That name will not work. Try another one.';
|
|
137
|
+
return undefined;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
if (p.isCancel(typed)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return typed.trim();
|
|
144
|
+
}
|
|
145
|
+
async function remixFlow(store, create) {
|
|
146
|
+
const metas = store.listProjects();
|
|
147
|
+
const sourceSlug = await p.select({
|
|
148
|
+
message: 'Which project do you want to remix?',
|
|
149
|
+
options: metas.map((m) => ({ value: m.slug, label: m.prettyName })),
|
|
150
|
+
});
|
|
151
|
+
if (p.isCancel(sourceSlug)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const source = store.openProject(sourceSlug);
|
|
155
|
+
if (source === null) {
|
|
156
|
+
p.log.warn('I could not open that one.');
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
for (;;) {
|
|
160
|
+
const name = await p.text({
|
|
161
|
+
message: 'Name your remix.',
|
|
162
|
+
validate: (value) => ((value ?? '').trim().length > 0 ? undefined : 'It needs a name.'),
|
|
163
|
+
});
|
|
164
|
+
if (p.isCancel(name)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const trimmed = name.trim();
|
|
168
|
+
if (create.slugifyName(trimmed).collision) {
|
|
169
|
+
p.log.warn('That name is taken. Try another one.');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const made = create.createProject(source.meta.scaffoldId, source.meta.themeId, trimmed);
|
|
173
|
+
for (const file of source.listKidFiles()) {
|
|
174
|
+
const content = source.readFile(file.relPath);
|
|
175
|
+
if (content !== null) {
|
|
176
|
+
made.project.writeFile(file.relPath, content);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await awardBadge('remixer');
|
|
180
|
+
return made.project;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The full new-project flow: scaffold, theme, name (with collision help),
|
|
185
|
+
* remix option, badges. Returns the created project, or null on cancel.
|
|
186
|
+
*/
|
|
187
|
+
export async function runNewProject(settings) {
|
|
188
|
+
void settings;
|
|
189
|
+
const mods = await loadProjectModules();
|
|
190
|
+
if (mods === null) {
|
|
191
|
+
p.log.warn('Project tools are not ready yet.');
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const { store, create } = mods;
|
|
195
|
+
const existing = store.listProjects();
|
|
196
|
+
const options = scaffolds.map((s) => ({
|
|
197
|
+
value: s.id,
|
|
198
|
+
label: `${s.emoji} ${s.label}`,
|
|
199
|
+
hint: s.ageNote,
|
|
200
|
+
}));
|
|
201
|
+
if (existing.length > 0) {
|
|
202
|
+
options.push({
|
|
203
|
+
value: '__remix__',
|
|
204
|
+
label: 'Remix one of your projects',
|
|
205
|
+
hint: 'Copy a project and make it new.',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const pick = await p.select({ message: 'What do you want to make?', options });
|
|
209
|
+
if (p.isCancel(pick)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (pick === '__remix__') {
|
|
213
|
+
return remixFlow(store, create);
|
|
214
|
+
}
|
|
215
|
+
const scaffold = scaffoldById(pick);
|
|
216
|
+
if (scaffold === undefined) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const themeId = await p.select({
|
|
220
|
+
message: 'Pick a style.',
|
|
221
|
+
options: scaffold.themes.map((t) => ({ value: t.id, label: `${t.emoji} ${t.label}` })),
|
|
222
|
+
});
|
|
223
|
+
if (p.isCancel(themeId)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const theme = scaffold.themes.find((t) => t.id === themeId);
|
|
227
|
+
if (theme === undefined) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
let prettyName = await pickProjectName(theme.label);
|
|
231
|
+
if (prettyName === null) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const slugInfo = create.slugifyName(prettyName);
|
|
235
|
+
if (slugInfo.collision) {
|
|
236
|
+
const what = await p.select({
|
|
237
|
+
message: 'You already have a project with that name.',
|
|
238
|
+
options: [
|
|
239
|
+
{ value: 'open', label: 'Open it' },
|
|
240
|
+
{ value: 'suffix', label: `Call this one ${prettyName} 2` },
|
|
241
|
+
{ value: 'rename', label: 'Pick a new name' },
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
if (p.isCancel(what)) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (what === 'open') {
|
|
248
|
+
return store.openProject(slugInfo.slug);
|
|
249
|
+
}
|
|
250
|
+
if (what === 'suffix') {
|
|
251
|
+
prettyName = `${prettyName} 2`;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const again = await pickProjectName(theme.label);
|
|
255
|
+
if (again === null) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
prettyName = again;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const made = create.createProject(scaffold.id, theme.id, prettyName);
|
|
262
|
+
await awardBadge('first-project');
|
|
263
|
+
if (store.listProjects().length >= 5) {
|
|
264
|
+
await awardBadge('five-projects');
|
|
265
|
+
}
|
|
266
|
+
const starters = made.starterPrompts.slice(0, 2);
|
|
267
|
+
if (starters.length > 0) {
|
|
268
|
+
p.note(starters.map((line) => `- ${line}`).join('\n'), 'Try saying');
|
|
269
|
+
}
|
|
270
|
+
return made.project;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Opens the chat for a project and keeps going while the kid asks for
|
|
274
|
+
* a new project from inside the chat.
|
|
275
|
+
*/
|
|
276
|
+
export async function openChatLoop(project, settings) {
|
|
277
|
+
let current = project;
|
|
278
|
+
for (;;) {
|
|
279
|
+
settings.lastProjectSlug = current.meta.slug;
|
|
280
|
+
try {
|
|
281
|
+
saveSettings(settings);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// The chat still works when settings cannot persist.
|
|
285
|
+
}
|
|
286
|
+
let exit = 'quit';
|
|
287
|
+
try {
|
|
288
|
+
const chat = await import('./chat.js');
|
|
289
|
+
exit = await chat.runChat(current, settings);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
p.log.warn('The build chat is not ready yet.');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (exit !== 'new') {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const next = await runNewProject(settings);
|
|
299
|
+
if (next === null) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
current = next;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function loadLastProject(settings) {
|
|
306
|
+
if (settings.lastProjectSlug === null) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const store = await import('../projects/store.js');
|
|
311
|
+
const context = store.openProject(settings.lastProjectSlug);
|
|
312
|
+
if (context === null) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
let recap = null;
|
|
316
|
+
try {
|
|
317
|
+
recap = recapFromTermiMd(context.readTermiMd());
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
recap = null;
|
|
321
|
+
}
|
|
322
|
+
return { context, recap };
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** The home menu loop for a returning kid. */
|
|
329
|
+
export async function showHome(settings) {
|
|
330
|
+
let guardWasLoading = false;
|
|
331
|
+
const nickname = settings.kidNickname.trim();
|
|
332
|
+
console.log(mascot('happy'));
|
|
333
|
+
console.log(nickname.length > 0 ? T.home.welcomeBack.replace('{name}', nickname) : T.home.firstHello);
|
|
334
|
+
let last = await loadLastProject(settings);
|
|
335
|
+
if (last !== null) {
|
|
336
|
+
if (last.recap !== null) {
|
|
337
|
+
console.log(style.dim(T.home.recapIntro.replace('{recap}', last.recap)));
|
|
338
|
+
}
|
|
339
|
+
console.log(T.home.nextStep);
|
|
340
|
+
}
|
|
341
|
+
for (;;) {
|
|
342
|
+
// A quiet one-liner while the safety checker still downloads, and one
|
|
343
|
+
// "it is on now" note the first time the menu renders after it lands.
|
|
344
|
+
const fetchState = guardFetchState();
|
|
345
|
+
if (settings.localClassifier && fetchState.status === 'downloading') {
|
|
346
|
+
console.log(style.dim(`${T.home.guardLoading} ${guardProgressBar(fetchState)}`));
|
|
347
|
+
guardWasLoading = true;
|
|
348
|
+
}
|
|
349
|
+
else if (guardWasLoading && fetchState.status === 'ready') {
|
|
350
|
+
console.log(style.dim(T.home.guardOn));
|
|
351
|
+
guardWasLoading = false;
|
|
352
|
+
}
|
|
353
|
+
const options = [];
|
|
354
|
+
if (last !== null) {
|
|
355
|
+
options.push({ value: 'continue', label: `Keep building ${last.context.meta.prettyName}` });
|
|
356
|
+
}
|
|
357
|
+
options.push({ value: 'open', label: T.home.menuGo }, { value: 'new', label: T.home.menuNew }, { value: 'ideas', label: T.home.menuIdeas }, { value: 'learn', label: 'Learn AI tricks' }, { value: 'badges', label: T.home.menuBadges }, { value: 'grownups', label: T.home.menuGrownups }, { value: 'quit', label: T.home.menuQuit });
|
|
358
|
+
const pick = await p.select({
|
|
359
|
+
message: 'What now?',
|
|
360
|
+
options,
|
|
361
|
+
initialValue: last !== null ? 'continue' : 'new',
|
|
362
|
+
});
|
|
363
|
+
if (p.isCancel(pick) || pick === 'quit') {
|
|
364
|
+
p.outro(T.home.goodbye);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (pick === 'continue' && last !== null) {
|
|
368
|
+
await openChatLoop(last.context, settings);
|
|
369
|
+
}
|
|
370
|
+
else if (pick === 'open') {
|
|
371
|
+
await openPicked(settings);
|
|
372
|
+
}
|
|
373
|
+
else if (pick === 'new') {
|
|
374
|
+
const made = await runNewProject(settings);
|
|
375
|
+
if (made !== null) {
|
|
376
|
+
await openChatLoop(made, settings);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else if (pick === 'ideas') {
|
|
380
|
+
const scaffoldId = last !== null ? last.context.meta.scaffoldId : 'games';
|
|
381
|
+
await executeIdeas(scaffoldId, (text) => {
|
|
382
|
+
console.log(text);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
else if (pick === 'learn') {
|
|
386
|
+
try {
|
|
387
|
+
const learn = await import('../learn/runner.js');
|
|
388
|
+
await learn.runLearnMenu();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
p.log.warn('Learn mode is taking a nap. Try again soon.');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (pick === 'badges') {
|
|
395
|
+
console.log(renderBadgeShelf(loadBadges()));
|
|
396
|
+
}
|
|
397
|
+
else if (pick === 'grownups') {
|
|
398
|
+
try {
|
|
399
|
+
const panel = await import('../grownups/panel.js');
|
|
400
|
+
await panel.runPanel();
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
p.log.warn(T.grownups.needsGrownup);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
last = await loadLastProject(settings);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function openPicked(settings) {
|
|
410
|
+
const mods = await loadProjectModules();
|
|
411
|
+
if (mods === null) {
|
|
412
|
+
p.log.warn('Project tools are not ready yet.');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const metas = mods.store.listProjects();
|
|
416
|
+
if (metas.length === 0) {
|
|
417
|
+
p.log.info('No projects yet. Pick "Make something new"!');
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const first = metas[0];
|
|
421
|
+
const initial = settings.lastProjectSlug !== null && metas.some((m) => m.slug === settings.lastProjectSlug)
|
|
422
|
+
? settings.lastProjectSlug
|
|
423
|
+
: first?.slug;
|
|
424
|
+
const pick = await p.select({
|
|
425
|
+
message: 'Pick a project.',
|
|
426
|
+
options: metas.map((m) => ({ value: m.slug, label: m.prettyName })),
|
|
427
|
+
...(initial !== undefined ? { initialValue: initial } : {}),
|
|
428
|
+
});
|
|
429
|
+
if (p.isCancel(pick)) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const project = mods.store.openProject(pick);
|
|
433
|
+
if (project === null) {
|
|
434
|
+
p.log.warn('I could not open that one.');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await openChatLoop(project, settings);
|
|
438
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The big "Termi" banner shown on launch.
|
|
3
|
+
* Figlet art through the brand gradient, with a plain fallback.
|
|
4
|
+
*/
|
|
5
|
+
import figlet from 'figlet';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { colorsOk, gradientBlock, style, unicodeOk } from './theme.js';
|
|
8
|
+
/** One line under the logo. Short and friendly. */
|
|
9
|
+
export const TAGLINE = 'Build games, art, and stories with your robot buddy.';
|
|
10
|
+
let cachedArt = null;
|
|
11
|
+
let figletRenders = 0;
|
|
12
|
+
/** Figlet is sync and a bit slow, so render once and keep it. */
|
|
13
|
+
function figletArt() {
|
|
14
|
+
if (cachedArt === null) {
|
|
15
|
+
figletRenders += 1;
|
|
16
|
+
cachedArt = figlet.textSync('Termi', { font: 'Standard' });
|
|
17
|
+
}
|
|
18
|
+
return cachedArt;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Render the launch banner.
|
|
22
|
+
* Full mode: gradient figlet art plus a dim tagline.
|
|
23
|
+
* Plain mode (no colors or ASCII terminals): bold name plus tagline.
|
|
24
|
+
*/
|
|
25
|
+
export function renderBanner() {
|
|
26
|
+
if (!unicodeOk() || !colorsOk()) {
|
|
27
|
+
return `${chalk.bold('Termi')}\n${TAGLINE}`;
|
|
28
|
+
}
|
|
29
|
+
const art = figletArt().replace(/\s+$/u, '');
|
|
30
|
+
return `${gradientBlock(art)}\n${style.dim(TAGLINE)}`;
|
|
31
|
+
}
|
|
32
|
+
/** Test hook: how many times figlet actually ran. */
|
|
33
|
+
export function bannerRenderCount() {
|
|
34
|
+
return figletRenders;
|
|
35
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Celebrations: confetti, the hooray box, and the badge shelf.
|
|
3
|
+
* Celebrations stay unexpected. No currency, no streaks, no pressure.
|
|
4
|
+
*/
|
|
5
|
+
import boxen from 'boxen';
|
|
6
|
+
import { mascot } from './mascot.js';
|
|
7
|
+
import { style, unicodeOk } from './theme.js';
|
|
8
|
+
/** The full badge set. Triggers live with the features that earn them. */
|
|
9
|
+
export const BADGES = [
|
|
10
|
+
{
|
|
11
|
+
id: 'first-project',
|
|
12
|
+
label: 'First Project',
|
|
13
|
+
emoji: '\u{1F331}',
|
|
14
|
+
hint: 'Make your first project.',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'first-change',
|
|
18
|
+
label: 'First Change',
|
|
19
|
+
emoji: '\u{1F58D}',
|
|
20
|
+
hint: 'Ask Termi to change your project.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'game-shipped',
|
|
24
|
+
label: 'Game Shipped',
|
|
25
|
+
emoji: '\u{1F680}',
|
|
26
|
+
hint: 'Finish a game with /done.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'bug-squasher',
|
|
30
|
+
label: 'Bug Squasher',
|
|
31
|
+
emoji: '\u{1F41B}',
|
|
32
|
+
hint: 'Fix a bug with Termi.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'remixer',
|
|
36
|
+
label: 'Remixer',
|
|
37
|
+
emoji: '\u{1F300}',
|
|
38
|
+
hint: 'Remix one of your projects.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'five-projects',
|
|
42
|
+
label: 'Five Projects',
|
|
43
|
+
emoji: '\u{1F3C6}',
|
|
44
|
+
hint: 'Make five projects.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'quest-hero',
|
|
48
|
+
label: 'Quest Hero',
|
|
49
|
+
emoji: '\u{1F5FA}',
|
|
50
|
+
hint: 'Finish a build quest with /quest.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'learn-1',
|
|
54
|
+
label: 'AI Spotter',
|
|
55
|
+
emoji: '\u{1F916}',
|
|
56
|
+
hint: 'Finish the Meet your AI helper lesson.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'learn-2',
|
|
60
|
+
label: 'Prompt Pro',
|
|
61
|
+
emoji: '✨',
|
|
62
|
+
hint: 'Finish the Super prompts lesson.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'learn-3',
|
|
66
|
+
label: 'Step Master',
|
|
67
|
+
emoji: '\u{1F422}',
|
|
68
|
+
hint: 'Finish the Small steps win lesson.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'learn-4',
|
|
72
|
+
label: 'Code Detective',
|
|
73
|
+
emoji: '\u{1F50D}',
|
|
74
|
+
hint: 'Finish the Be a code detective lesson.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'learn-5',
|
|
78
|
+
label: 'Bug Tamer',
|
|
79
|
+
emoji: '\u{1F527}',
|
|
80
|
+
hint: 'Finish the When it goes wrong lesson.',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'learn-6',
|
|
84
|
+
label: 'The Boss',
|
|
85
|
+
emoji: '\u{1F451}',
|
|
86
|
+
hint: 'Finish the You are the boss lesson.',
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
function sleep(ms) {
|
|
90
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
|
+
}
|
|
92
|
+
const CONFETTI_UNICODE = ['✨', '✦', '✶', '•', '*'];
|
|
93
|
+
const CONFETTI_ASCII = ['*', '+', '.', 'o', '~'];
|
|
94
|
+
/** Print a short confetti burst, one line per frame. */
|
|
95
|
+
export async function confetti(frames = 6, opts = {}) {
|
|
96
|
+
const delayMs = opts.delayMs ?? 80;
|
|
97
|
+
const width = opts.width ?? 36;
|
|
98
|
+
const write = opts.write ?? ((chunk) => void process.stdout.write(chunk));
|
|
99
|
+
const rng = opts.rng ?? Math.random;
|
|
100
|
+
const pieces = unicodeOk() ? CONFETTI_UNICODE : CONFETTI_ASCII;
|
|
101
|
+
for (let f = 0; f < frames; f += 1) {
|
|
102
|
+
let line = '';
|
|
103
|
+
for (let i = 0; i < width; i += 1) {
|
|
104
|
+
if (rng() < 0.28) {
|
|
105
|
+
line += pieces[Math.floor(rng() * pieces.length)] ?? '*';
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
line += ' ';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
write(`${line}\n`);
|
|
112
|
+
if (delayMs > 0)
|
|
113
|
+
await sleep(delayMs);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** A boxed hooray moment with the celebrating mascot. */
|
|
117
|
+
export function celebrate(message) {
|
|
118
|
+
const body = `${mascot('celebrating')}\n\n${message}`;
|
|
119
|
+
return boxen(body, {
|
|
120
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
121
|
+
borderStyle: unicodeOk() ? 'round' : 'classic',
|
|
122
|
+
borderColor: 'magenta',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Render the badge shelf: earned badges shine, locked ones show their hint. */
|
|
126
|
+
export function renderBadgeShelf(earnedIds) {
|
|
127
|
+
const earned = new Set(earnedIds);
|
|
128
|
+
const unicode = unicodeOk();
|
|
129
|
+
const lines = BADGES.map((badge) => {
|
|
130
|
+
if (earned.has(badge.id)) {
|
|
131
|
+
const mark = unicode ? badge.emoji : '[*]';
|
|
132
|
+
return ` ${mark} ${style.title(badge.label)}`;
|
|
133
|
+
}
|
|
134
|
+
const mark = unicode ? '\u{1F512}' : '[ ]';
|
|
135
|
+
return ` ${mark} ${style.dim(badge.label)} ${style.dim(badge.hint)}`;
|
|
136
|
+
});
|
|
137
|
+
const count = BADGES.filter((badge) => earned.has(badge.id)).length;
|
|
138
|
+
const header = style.title('Your badges');
|
|
139
|
+
const tally = `You earned ${count} of ${BADGES.length} badges.`;
|
|
140
|
+
return [header, ...lines, '', tally].join('\n');
|
|
141
|
+
}
|