@kidsinai/kids-client 0.0.12 → 0.0.17
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/package.json +2 -2
- package/src/core/course-pack.ts +45 -21
- package/src/core/env.ts +6 -0
- package/src/core/events.ts +59 -8
- package/src/core/setup.ts +1 -1
- package/src/index.tsx +42 -1
- package/src/render/ink/App.tsx +4 -2
- package/src/render/ink/components/Header.tsx +10 -1
- package/src/render/ink/screens/CoursePackPicker.tsx +68 -19
- package/src/render/ink/screens/MissionScreen.tsx +13 -4
- package/src/render/ink/screens/StartupScreen.tsx +16 -6
- package/src/render/ink/theme.ts +6 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@kidsinai/kids-client",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.17",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Own-client TUI for Kids OpenCode — talks to local `opencode serve` via @opencode-ai/sdk v2 with kid-warm rendering, mission progress, permission dialog, and stderr-tail audit pipeline.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"ink-spinner": "^5.0.0",
|
|
35
35
|
"ink-text-input": "^6.0.0",
|
|
36
36
|
"react": "^18.3.1",
|
|
37
|
-
"@kidsinai/kids-opencode-plugin": "^0.0.
|
|
37
|
+
"@kidsinai/kids-opencode-plugin": "^0.0.17"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@opencode-ai/sdk": "^1.14.51",
|
package/src/core/course-pack.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* — used by CoursePackPicker.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readdirSync, statSync } from "node:fs"
|
|
10
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
11
11
|
import { join } from "node:path"
|
|
12
12
|
import {
|
|
13
13
|
bundledCoursePacksDir,
|
|
@@ -52,38 +52,62 @@ export interface InstalledPack {
|
|
|
52
52
|
shortDescription: string | null
|
|
53
53
|
missionCount: number
|
|
54
54
|
starsBudget: number
|
|
55
|
+
/** Project-type metadata for the picker, surfaced from pack.yml. */
|
|
56
|
+
icon: string | null
|
|
57
|
+
pickerLabel: string | null
|
|
58
|
+
typeCategory: "game" | "website" | "slides" | "video" | null
|
|
59
|
+
pickerOrder: number
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
58
63
|
* Enumerate packs available in the bundled course-packs/ directory. Used
|
|
59
64
|
* by the picker screen. Tolerant of malformed packs (skips, doesn't
|
|
60
65
|
* throw).
|
|
66
|
+
*
|
|
67
|
+
* Packs whose folder name starts with `_` (e.g. `_stub`) are hidden from
|
|
68
|
+
* the picker but remain loadable by id — they're CI fixtures, not kid content.
|
|
61
69
|
*/
|
|
62
70
|
export function listInstalledPacks(): InstalledPack[] {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// Walk both the public dir and the private submodule dir (if mounted).
|
|
72
|
+
// Same pack id appearing in both is deduped — private wins because the
|
|
73
|
+
// plugin's packDir() resolves private-first; listing follows the same order.
|
|
74
|
+
const publicDir = bundledCoursePacksDir()
|
|
75
|
+
const privateDir = join(publicDir, "private")
|
|
76
|
+
const dirs = [privateDir, publicDir].filter((d) => existsSync(d))
|
|
77
|
+
const seen = new Set<string>()
|
|
70
78
|
const out: InstalledPack[] = []
|
|
71
|
-
for (const
|
|
79
|
+
for (const dir of dirs) {
|
|
80
|
+
let entries: string[]
|
|
72
81
|
try {
|
|
73
|
-
|
|
74
|
-
if (!statSync(full).isDirectory()) continue
|
|
75
|
-
const pack = loadCoursePack(id)
|
|
76
|
-
if (!pack) continue
|
|
77
|
-
out.push({
|
|
78
|
-
id: pack.id,
|
|
79
|
-
title: pack.title,
|
|
80
|
-
shortDescription: pack.short_description ?? null,
|
|
81
|
-
missionCount: pack.missions?.length ?? 0,
|
|
82
|
-
starsBudget: pack.estimated_stars_budget ?? 0,
|
|
83
|
-
})
|
|
82
|
+
entries = readdirSync(dir)
|
|
84
83
|
} catch {
|
|
85
|
-
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
for (const id of entries) {
|
|
87
|
+
if (id.startsWith("_") || id.startsWith(".")) continue
|
|
88
|
+
if (seen.has(id)) continue
|
|
89
|
+
try {
|
|
90
|
+
const full = join(dir, id)
|
|
91
|
+
if (!statSync(full).isDirectory()) continue
|
|
92
|
+
const pack = loadCoursePack(id)
|
|
93
|
+
if (!pack) continue
|
|
94
|
+
seen.add(id)
|
|
95
|
+
out.push({
|
|
96
|
+
id: pack.id,
|
|
97
|
+
title: pack.title,
|
|
98
|
+
shortDescription: pack.short_description ?? null,
|
|
99
|
+
missionCount: pack.missions?.length ?? 0,
|
|
100
|
+
starsBudget: pack.estimated_stars_budget ?? 0,
|
|
101
|
+
icon: pack.icon ?? null,
|
|
102
|
+
pickerLabel: pack.picker_label ?? null,
|
|
103
|
+
typeCategory: pack.type_category ?? null,
|
|
104
|
+
pickerOrder: pack.picker_order ?? Number.MAX_SAFE_INTEGER,
|
|
105
|
+
})
|
|
106
|
+
} catch {
|
|
107
|
+
// skip malformed entry
|
|
108
|
+
}
|
|
86
109
|
}
|
|
87
110
|
}
|
|
111
|
+
out.sort((a, b) => a.pickerOrder - b.pickerOrder)
|
|
88
112
|
return out
|
|
89
113
|
}
|
package/src/core/env.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface KidsClientEnv {
|
|
|
29
29
|
coursePack: string | null
|
|
30
30
|
/** Optional mission id (e.g. "mission-1"). */
|
|
31
31
|
mission: string | null
|
|
32
|
+
/** Optional guided-flow vibe id picked by the kid (e.g. "space"). */
|
|
33
|
+
vibeId: string | null
|
|
34
|
+
/** Optional kid-chosen project name. Surfaced in scaffold template vars. */
|
|
35
|
+
projectName: string | null
|
|
32
36
|
/** Locale hint ("zh-Hans" / "en"). Picked from KIDS_LOCALE or $LANG. */
|
|
33
37
|
locale: "zh-Hans" | "en"
|
|
34
38
|
/** Path to opencode binary so client can spawn `opencode serve`. */
|
|
@@ -57,6 +61,8 @@ export function readEnv(): KidsClientEnv {
|
|
|
57
61
|
bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
|
|
58
62
|
coursePack: process.env.KIDS_COURSE_PACK || null,
|
|
59
63
|
mission: process.env.KIDS_MISSION || null,
|
|
64
|
+
vibeId: process.env.KIDS_VIBE_ID || null,
|
|
65
|
+
projectName: process.env.KIDS_PROJECT_NAME || null,
|
|
60
66
|
locale,
|
|
61
67
|
opencodeBin: process.env.OPENCODE_BIN ?? "opencode",
|
|
62
68
|
configDir: process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"),
|
package/src/core/events.ts
CHANGED
|
@@ -67,15 +67,23 @@ export class EventSubscriber {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
private async consume(): Promise<void> {
|
|
70
|
-
// The SDK exposes the SSE stream via client.global.event().
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
70
|
+
// The SDK exposes the SSE stream via client.global.event(). The shape
|
|
71
|
+
// has changed across SDK versions:
|
|
72
|
+
// • old: event() returns an AsyncIterable directly
|
|
73
|
+
// • new (>=1.14.51): event() returns Promise<{ stream: AsyncGenerator }>
|
|
74
|
+
// Handle both. The error "undefined is not a function (near '...raw of
|
|
75
|
+
// stream...')" came from `for await`-ing a Promise (the new shape) under
|
|
76
|
+
// the old code path.
|
|
77
|
+
const eventApi = (this.client as unknown as { global?: { event: (...a: unknown[]) => unknown } }).global
|
|
74
78
|
if (!eventApi || typeof eventApi.event !== "function") {
|
|
75
79
|
throw new Error("@opencode-ai/sdk/v2: client.global.event() not available — SDK version drift")
|
|
76
80
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
81
|
+
const result = await Promise.resolve(eventApi.event())
|
|
82
|
+
const iterable = pickAsyncIterable(result)
|
|
83
|
+
if (!iterable) {
|
|
84
|
+
throw new Error(`@opencode-ai/sdk/v2: client.global.event() returned an unrecognised shape: ${describeShape(result)}`)
|
|
85
|
+
}
|
|
86
|
+
for await (const raw of iterable) {
|
|
79
87
|
if (this.abort.signal.aborted) return
|
|
80
88
|
if (this.retries > 0) {
|
|
81
89
|
this.retries = 0
|
|
@@ -86,8 +94,19 @@ export class EventSubscriber {
|
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
private dispatch(raw: unknown): void {
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
// Across SDK versions an event is either:
|
|
98
|
+
// • { payload: { type, …fields } } (older shape)
|
|
99
|
+
// • { type, …fields } (newer shape — yielded
|
|
100
|
+
// directly by the
|
|
101
|
+
// AsyncGenerator)
|
|
102
|
+
// • { data: { type, …fields } } (StreamEvent wrapper from some helpers)
|
|
103
|
+
// Unwrap to a single `payload` view so the switch below stays the same.
|
|
104
|
+
const e = raw as { payload?: Record<string, unknown>; data?: Record<string, unknown>; type?: string } & Record<string, unknown>
|
|
105
|
+
const payload: ({ type?: string } & Record<string, unknown>) | null =
|
|
106
|
+
(e?.payload && typeof e.payload === "object") ? (e.payload as { type?: string } & Record<string, unknown>)
|
|
107
|
+
: (e?.data && typeof e.data === "object" && typeof (e.data as { type?: unknown }).type === "string") ? (e.data as { type?: string } & Record<string, unknown>)
|
|
108
|
+
: (typeof e?.type === "string") ? (e as { type?: string } & Record<string, unknown>)
|
|
109
|
+
: null
|
|
91
110
|
if (!payload || typeof payload.type !== "string") return
|
|
92
111
|
const t = payload.type
|
|
93
112
|
switch (t) {
|
|
@@ -166,3 +185,35 @@ function stringifyErr(err: unknown): string {
|
|
|
166
185
|
return String(err)
|
|
167
186
|
}
|
|
168
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The SDK has shipped at least three event() return shapes over its 1.14.x
|
|
191
|
+
* line. Find the AsyncIterable in whichever shape we got, or null if none.
|
|
192
|
+
*/
|
|
193
|
+
function pickAsyncIterable(value: unknown): AsyncIterable<unknown> | null {
|
|
194
|
+
if (!value) return null
|
|
195
|
+
// Shape 1: the value IS the iterable.
|
|
196
|
+
if (typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
|
|
197
|
+
return value as AsyncIterable<unknown>
|
|
198
|
+
}
|
|
199
|
+
// Shape 2: { stream: AsyncGenerator } — the >=1.14.51 ServerSentEventsResult.
|
|
200
|
+
const s = (value as { stream?: unknown }).stream
|
|
201
|
+
if (s && typeof (s as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
|
|
202
|
+
return s as AsyncIterable<unknown>
|
|
203
|
+
}
|
|
204
|
+
// Shape 3: { data: { stream: ... } } — wrapped data envelope.
|
|
205
|
+
const d = (value as { data?: { stream?: unknown } }).data
|
|
206
|
+
if (d && typeof d === "object") {
|
|
207
|
+
const inner = (d as { stream?: unknown }).stream
|
|
208
|
+
if (inner && typeof (inner as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
|
|
209
|
+
return inner as AsyncIterable<unknown>
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function describeShape(value: unknown): string {
|
|
216
|
+
if (value == null) return String(value)
|
|
217
|
+
if (typeof value !== "object") return typeof value
|
|
218
|
+
return `object keys=[${Object.keys(value as object).join(",")}]`
|
|
219
|
+
}
|
package/src/core/setup.ts
CHANGED
|
@@ -80,7 +80,7 @@ export const PROVIDERS: ProviderChoice[] = [
|
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
id: "openai",
|
|
83
|
-
label: "OpenAI GPT (ChatGPT Plus/Pro
|
|
83
|
+
label: "OpenAI GPT (sign in with ChatGPT Plus/Pro)",
|
|
84
84
|
hint: "Already pay for ChatGPT Plus/Pro? Sign in with that — no API key. Otherwise pay-as-you-go ~$5-10/month.",
|
|
85
85
|
envVar: "OPENAI_API_KEY",
|
|
86
86
|
apiKeyUrl: "https://platform.openai.com/api-keys",
|
package/src/index.tsx
CHANGED
|
@@ -31,6 +31,7 @@ import { listInstalledPacks, resolveContext } from "./core/course-pack.ts"
|
|
|
31
31
|
import { readLastSession, writeLastSession } from "./core/last-session.ts"
|
|
32
32
|
import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
|
|
33
33
|
import { App } from "./render/ink/App.tsx"
|
|
34
|
+
import { FREE_PLAY_PACK_ID } from "./render/ink/screens/CoursePackPicker.tsx"
|
|
34
35
|
import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
|
|
35
36
|
import { OAUTH_HANDOFF_EXIT_CODE, saveSetup, saveSetupOauth, type ProviderId } from "./core/setup.ts"
|
|
36
37
|
import { reloadEnvFile } from "./core/env-reload.ts"
|
|
@@ -73,6 +74,7 @@ interface AppHandlers {
|
|
|
73
74
|
onPickerBack: () => void
|
|
74
75
|
onMissionNext: () => void
|
|
75
76
|
onMissionBack: () => void
|
|
77
|
+
onMissionExit: () => void
|
|
76
78
|
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
77
79
|
onSetupContinue: () => Promise<void>
|
|
78
80
|
onSetupSkip: () => void
|
|
@@ -82,6 +84,25 @@ interface AppHandlers {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
async function main(): Promise<void> {
|
|
87
|
+
// Switch to the terminal's alternate screen buffer so Ink draws on a
|
|
88
|
+
// canvas isolated from whatever was in the terminal before us — most
|
|
89
|
+
// importantly the green "Complete authorization…" lines printed by
|
|
90
|
+
// `opencode auth login` between two execs of kids-client. On exit, the
|
|
91
|
+
// kid's original terminal contents (incl. scrollback) come back.
|
|
92
|
+
//
|
|
93
|
+
// NOTE: do NOT install SIGINT/SIGTERM handlers here — the existing
|
|
94
|
+
// `process.on("SIGINT", () => void services.quit())` registration below
|
|
95
|
+
// is the cleanup owner; double-handling closed the raw-mode stdin out
|
|
96
|
+
// from under Ink and surfaced as "EIO on fd 8" when the kid pressed Esc.
|
|
97
|
+
// The "exit" listener alone is enough to restore the terminal for normal
|
|
98
|
+
// exits + the OAuth handoff `process.exit(OAUTH_HANDOFF_EXIT_CODE)`.
|
|
99
|
+
if (process.stdout.isTTY) {
|
|
100
|
+
process.stdout.write("\x1b[?1049h\x1b[H")
|
|
101
|
+
process.on("exit", () => {
|
|
102
|
+
try { process.stdout.write("\x1b[?1049l") } catch { /* terminal already closed */ }
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
const env: KidsClientEnv = readEnv()
|
|
86
107
|
const store = new Store()
|
|
87
108
|
const installedPacks = listInstalledPacks()
|
|
@@ -209,6 +230,10 @@ function makeHandlers(
|
|
|
209
230
|
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
210
231
|
onMissionNext: ifBooted((s) => s.handlers.onMissionNext()),
|
|
211
232
|
onMissionBack: () => store.update({ screen: { kind: "mission" } }),
|
|
233
|
+
// Leave an in-progress mission and return to the startup menu. The serve +
|
|
234
|
+
// session keep running in the background; the kid just re-enters from the
|
|
235
|
+
// picker. Mirrors onHelpBack / onPickerBack.
|
|
236
|
+
onMissionExit: () => store.update({ screen: { kind: "startup" } }),
|
|
212
237
|
onSetupSave: async (provider, apiKey) => {
|
|
213
238
|
try {
|
|
214
239
|
saveSetup({ configDir: env.configDir, provider, apiKey })
|
|
@@ -378,7 +403,17 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
|
|
|
378
403
|
})
|
|
379
404
|
},
|
|
380
405
|
onDisconnected: (reason) => {
|
|
381
|
-
|
|
406
|
+
// This fires after the engine was already reachable, so the failure is a
|
|
407
|
+
// dropped event stream, not a failed startup. Make the detail say so —
|
|
408
|
+
// the variant's title still reads "AI teacher didn't start", but the
|
|
409
|
+
// detail keeps it from being misleading.
|
|
410
|
+
store.update({
|
|
411
|
+
screen: {
|
|
412
|
+
kind: "error",
|
|
413
|
+
variant: "serve_unreachable",
|
|
414
|
+
detail: `lost connection to the AI engine after it started — ${reason}`,
|
|
415
|
+
},
|
|
416
|
+
})
|
|
382
417
|
},
|
|
383
418
|
onReconnected: () => {
|
|
384
419
|
flashToast(store, {
|
|
@@ -574,6 +609,12 @@ function makeFullHandlers(
|
|
|
574
609
|
}
|
|
575
610
|
},
|
|
576
611
|
onPickPack: (packId) => {
|
|
612
|
+
if (packId === FREE_PLAY_PACK_ID) {
|
|
613
|
+
// Synthetic "I don't know yet — just chat" entry → free-play.
|
|
614
|
+
store.update({ coursePack: null, mission: null, packTitle: null, missionTitle: null, missionIndex: null, missionTotal: null })
|
|
615
|
+
store.update({ screen: { kind: "mission" } })
|
|
616
|
+
return
|
|
617
|
+
}
|
|
577
618
|
store.update({ coursePack: packId, mission: null })
|
|
578
619
|
refreshContext()
|
|
579
620
|
store.update({ screen: { kind: "mission" } })
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -57,6 +57,8 @@ export interface AppDeps {
|
|
|
57
57
|
onPickerBack: () => void
|
|
58
58
|
onMissionNext: () => void
|
|
59
59
|
onMissionBack: () => void
|
|
60
|
+
/** Leave an in-progress mission and return to the startup menu. */
|
|
61
|
+
onMissionExit: () => void
|
|
60
62
|
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
61
63
|
onSetupContinue: () => Promise<void>
|
|
62
64
|
onSetupSkip: () => void
|
|
@@ -105,9 +107,9 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
105
107
|
case "tour":
|
|
106
108
|
return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
|
|
107
109
|
case "startup":
|
|
108
|
-
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} />
|
|
110
|
+
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} onQuit={deps.onQuit} />
|
|
109
111
|
case "mission":
|
|
110
|
-
return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
|
|
112
|
+
return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} onExit={deps.onMissionExit} />
|
|
111
113
|
case "help":
|
|
112
114
|
return <HelpScreen locale={deps.locale} onBack={deps.onHelpBack} />
|
|
113
115
|
case "course_picker":
|
|
@@ -26,8 +26,17 @@ export function Header({ packTitle, missionTitle, missionIndex, missionTotal, st
|
|
|
26
26
|
starsBudget > 0
|
|
27
27
|
? `⭐ ${starsBalance}/${starsBudget}`
|
|
28
28
|
: `⭐ ${starsBalance}`
|
|
29
|
+
// borderStyle="round" + justifyContent="space-between" without an explicit
|
|
30
|
+
// width caused a cascade of stacked top-borders under Ink 5 + Bun — the
|
|
31
|
+
// Header re-rendered on every keystroke / spinner tick with a slightly
|
|
32
|
+
// different computed width, and Ink's diff failed to clear the old top
|
|
33
|
+
// border. Forcing width to the current terminal column count locks the
|
|
34
|
+
// measurement, and "single" border chars sidestep the rounded-corner
|
|
35
|
+
// width-counting glitch we hit in workshop dogfood (round corners stay
|
|
36
|
+
// available on Setup / Tour / Help screens which don't re-render rapidly).
|
|
37
|
+
const width = process.stdout.columns && process.stdout.columns > 4 ? process.stdout.columns : 80
|
|
29
38
|
return (
|
|
30
|
-
<Box borderStyle="
|
|
39
|
+
<Box borderStyle="single" borderColor={theme.border} paddingX={1} justifyContent="space-between" width={width}>
|
|
31
40
|
<Text color={theme.accent}>{left}</Text>
|
|
32
41
|
<Text color={theme.stars}>{stars}</Text>
|
|
33
42
|
</Box>
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* On selection, the parent transitions to MissionScreen with that pack
|
|
4
|
-
* loaded.
|
|
2
|
+
* Project-type picker — first impression for a kid with no `--course` flag.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
4
|
+
* Lists installed packs (Game / Website / …) plus a synthetic "I don't know
|
|
5
|
+
* yet — just chat" entry that drops into free-play. Selection routes via the
|
|
6
|
+
* orchestrator's onPick callback; the magic `_free` id triggers free-play.
|
|
7
|
+
*
|
|
8
|
+
* Up / Down to move, Enter to select, Esc to go back to StartupScreen.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import React, { useState } from "react"
|
|
11
|
+
import React, { useMemo, useState } from "react"
|
|
10
12
|
import { Box, Text, useInput } from "ink"
|
|
11
13
|
import { getTheme } from "../theme.ts"
|
|
12
14
|
import type { InstalledPack } from "../../../core/course-pack.ts"
|
|
13
15
|
|
|
16
|
+
/** Synthetic id reserved for the "just chat" entry; orchestrator maps this to free-play. */
|
|
17
|
+
export const FREE_PLAY_PACK_ID = "_free"
|
|
18
|
+
|
|
14
19
|
interface CoursePackPickerProps {
|
|
15
20
|
locale: "zh-Hans" | "en"
|
|
16
21
|
packs: InstalledPack[]
|
|
@@ -18,19 +23,32 @@ interface CoursePackPickerProps {
|
|
|
18
23
|
onBack: () => void
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
interface PickerRow {
|
|
27
|
+
id: string
|
|
28
|
+
icon: string
|
|
29
|
+
label: string
|
|
30
|
+
description: string | null
|
|
31
|
+
/** Optional meta line ("3 missions · budget 40⭐") — null hides it. */
|
|
32
|
+
meta: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPickerProps): React.ReactElement {
|
|
22
36
|
const theme = getTheme()
|
|
37
|
+
const t = STRINGS[locale]
|
|
38
|
+
const rows: PickerRow[] = useMemo(() => buildRows(packs, t), [packs, t])
|
|
23
39
|
const [idx, setIdx] = useState(0)
|
|
24
40
|
useInput((_, key) => {
|
|
25
41
|
if (key.escape) onBack()
|
|
26
42
|
else if (key.upArrow) setIdx((i) => Math.max(0, i - 1))
|
|
27
|
-
else if (key.downArrow) setIdx((i) => Math.min(
|
|
28
|
-
else if (key.return &&
|
|
43
|
+
else if (key.downArrow) setIdx((i) => Math.min(rows.length - 1, i + 1))
|
|
44
|
+
else if (key.return && rows[idx]) onPick(rows[idx]!.id)
|
|
29
45
|
})
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
46
|
+
const noRealPacks = packs.length === 0
|
|
47
|
+
if (noRealPacks) {
|
|
48
|
+
// Course Pack install is broken — surface it loudly, but still let the kid
|
|
49
|
+
// drop into free-play via the synthetic entry.
|
|
32
50
|
return (
|
|
33
|
-
<Box flexDirection="column" borderStyle="
|
|
51
|
+
<Box flexDirection="column" borderStyle="single" borderColor={theme.warn} paddingX={2} paddingY={1} width={process.stdout.columns && process.stdout.columns > 4 ? process.stdout.columns : 80}>
|
|
34
52
|
<Text color={theme.warn} bold>{t.empty}</Text>
|
|
35
53
|
<Box marginTop={1}>
|
|
36
54
|
<Text color={theme.fgDim}>{t.emptyHint}</Text>
|
|
@@ -42,20 +60,23 @@ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPi
|
|
|
42
60
|
)
|
|
43
61
|
}
|
|
44
62
|
return (
|
|
45
|
-
<Box flexDirection="column" borderStyle="
|
|
63
|
+
<Box flexDirection="column" borderStyle="single" borderColor={theme.accent} paddingX={2} paddingY={1} width={process.stdout.columns && process.stdout.columns > 4 ? process.stdout.columns : 80}>
|
|
46
64
|
<Text color={theme.accent} bold>{t.title}</Text>
|
|
47
65
|
<Box marginTop={1} flexDirection="column">
|
|
48
|
-
{
|
|
66
|
+
{rows.map((row, i) => {
|
|
49
67
|
const active = i === idx
|
|
50
68
|
return (
|
|
51
|
-
<Box key={
|
|
69
|
+
<Box key={row.id}>
|
|
52
70
|
<Text color={active ? theme.kid : theme.fg}>{active ? "▶ " : " "}</Text>
|
|
71
|
+
<Text>{row.icon}{row.icon ? " " : ""}</Text>
|
|
53
72
|
<Box flexDirection="column" flexGrow={1}>
|
|
54
|
-
<Text color={active ? theme.accent : theme.fg} bold={active}>{
|
|
55
|
-
{
|
|
56
|
-
<Text color={theme.fgDim} dimColor={!active}> {
|
|
73
|
+
<Text color={active ? theme.accent : theme.fg} bold={active}>{row.label}</Text>
|
|
74
|
+
{row.description && (
|
|
75
|
+
<Text color={theme.fgDim} dimColor={!active}> {row.description}</Text>
|
|
76
|
+
)}
|
|
77
|
+
{row.meta && (
|
|
78
|
+
<Text color={theme.fgDim} dimColor> {row.meta}</Text>
|
|
57
79
|
)}
|
|
58
|
-
<Text color={theme.fgDim} dimColor> {t.meta(p.missionCount, p.starsBudget)}</Text>
|
|
59
80
|
</Box>
|
|
60
81
|
</Box>
|
|
61
82
|
)
|
|
@@ -68,22 +89,50 @@ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPi
|
|
|
68
89
|
)
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
interface PickerStrings {
|
|
93
|
+
freePlayLabel: string
|
|
94
|
+
freePlayDescription: string
|
|
95
|
+
meta: (missions: number, stars: number) => string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildRows(packs: InstalledPack[], t: PickerStrings): PickerRow[] {
|
|
99
|
+
const rows: PickerRow[] = packs.map((p) => ({
|
|
100
|
+
id: p.id,
|
|
101
|
+
icon: p.icon ?? "📦",
|
|
102
|
+
label: p.pickerLabel ?? p.title,
|
|
103
|
+
description: p.shortDescription,
|
|
104
|
+
meta: p.missionCount > 0 ? t.meta(p.missionCount, p.starsBudget) : null,
|
|
105
|
+
}))
|
|
106
|
+
rows.push({
|
|
107
|
+
id: FREE_PLAY_PACK_ID,
|
|
108
|
+
icon: "🤔",
|
|
109
|
+
label: t.freePlayLabel,
|
|
110
|
+
description: t.freePlayDescription,
|
|
111
|
+
meta: null,
|
|
112
|
+
})
|
|
113
|
+
return rows
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
const STRINGS = {
|
|
72
117
|
"zh-Hans": {
|
|
73
|
-
title: "
|
|
118
|
+
title: "你好呀. 今天想做点啥?",
|
|
74
119
|
empty: "还没装任何 Course Pack",
|
|
75
120
|
emptyHint: "Course Pack 是 Airbotix 老师做的引导式项目。请家长重新装 kids-opencode 把它带回来。",
|
|
76
121
|
backHint: "[Esc / Enter] 返回",
|
|
77
122
|
hints: "[↑↓] 选 · [Enter] 确认 · [Esc] 返回",
|
|
123
|
+
freePlayLabel: "还没想好 — 聊聊看",
|
|
124
|
+
freePlayDescription: "先跟 AI 聊一聊,再决定做啥也行。",
|
|
78
125
|
meta: (missions: number, stars: number) =>
|
|
79
126
|
stars > 0 ? `${missions} 个 Mission · 预算 ${stars}⭐` : `${missions} 个 Mission`,
|
|
80
127
|
},
|
|
81
128
|
en: {
|
|
82
|
-
title: "
|
|
129
|
+
title: "Welcome, friend. What do you want to make today?",
|
|
83
130
|
empty: "No Course Packs installed yet",
|
|
84
131
|
emptyHint: "Course Packs are guided projects from Airbotix. Ask a grown-up to reinstall kids-opencode.",
|
|
85
132
|
backHint: "[Esc / Enter] Back",
|
|
86
133
|
hints: "[↑↓] move · [Enter] choose · [Esc] back",
|
|
134
|
+
freePlayLabel: "I don't know yet — just chat",
|
|
135
|
+
freePlayDescription: "Talk to the AI first, then decide what to make.",
|
|
87
136
|
meta: (missions: number, stars: number) =>
|
|
88
137
|
stars > 0 ? `${missions} missions · budget ${stars}⭐` : `${missions} missions`,
|
|
89
138
|
},
|
|
@@ -24,20 +24,29 @@ interface MissionScreenProps {
|
|
|
24
24
|
locale: "zh-Hans" | "en"
|
|
25
25
|
onPrompt: (text: string) => void
|
|
26
26
|
onAbort: () => void
|
|
27
|
+
/** Leave the mission and return to the startup menu. */
|
|
28
|
+
onExit: () => void
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export function MissionScreen({ state, locale, onPrompt, onAbort }: MissionScreenProps): React.ReactElement {
|
|
31
|
+
export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: MissionScreenProps): React.ReactElement {
|
|
30
32
|
const theme = getTheme()
|
|
31
33
|
const [draft, setDraft] = useState("")
|
|
32
34
|
const placeholder = locale === "zh-Hans" ? "想做什么?告诉我吧(中文/英文都行)" : "What would you like to make? (English or Chinese)"
|
|
33
35
|
|
|
36
|
+
// Esc is overloaded so it never eats the kid's typing: while the AI is
|
|
37
|
+
// thinking it interrupts; with text typed it clears the draft; when idle and
|
|
38
|
+
// empty it leaves the mission back to the startup menu (so the kid isn't
|
|
39
|
+
// trapped here — dogfood feedback).
|
|
34
40
|
useInput((_, key) => {
|
|
35
|
-
if (key.escape
|
|
41
|
+
if (!key.escape) return
|
|
42
|
+
if (state.thinking) onAbort()
|
|
43
|
+
else if (draft.length > 0) setDraft("")
|
|
44
|
+
else onExit()
|
|
36
45
|
})
|
|
37
46
|
|
|
38
47
|
const hint = locale === "zh-Hans"
|
|
39
|
-
? "提示:做完一关时打 /check 或「我做完了」就能验收 · 按 Esc 打断 AI"
|
|
40
|
-
: "Tip: type /check or 'I'm done' to validate · Esc interrupts the AI"
|
|
48
|
+
? "提示:做完一关时打 /check 或「我做完了」就能验收 · 按 Esc 打断 AI / 返回菜单"
|
|
49
|
+
: "Tip: type /check or 'I'm done' to validate · Esc interrupts the AI / returns to menu"
|
|
41
50
|
|
|
42
51
|
return (
|
|
43
52
|
<Box flexDirection="column">
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
* §3.1 Startup screen — first impression. Must paint within 5s.
|
|
3
3
|
*
|
|
4
4
|
* Quick keys:
|
|
5
|
-
* Enter →
|
|
6
|
-
*
|
|
5
|
+
* Enter → if a Course Pack is preselected (via --course): continue.
|
|
6
|
+
* Otherwise: open the project-type picker.
|
|
7
|
+
* c → open the project-type picker explicitly
|
|
8
|
+
* f → start a free-play session (no Course Pack)
|
|
7
9
|
* r → resume the last session
|
|
8
10
|
* w → open Airbotix Portal wallet / login in the parent's browser
|
|
9
11
|
* h → show kid-friendly help
|
|
12
|
+
* q → quit Kids OpenCode
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import React from "react"
|
|
@@ -22,16 +25,19 @@ interface StartupScreenProps {
|
|
|
22
25
|
toast: ToastState | null
|
|
23
26
|
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
24
27
|
onOpenWallet: () => void
|
|
28
|
+
onQuit: () => void
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet }: StartupScreenProps): React.ReactElement {
|
|
31
|
+
export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet, onQuit }: StartupScreenProps): React.ReactElement {
|
|
28
32
|
const theme = getTheme()
|
|
29
33
|
useInput((input, key) => {
|
|
30
|
-
if (key.return) onStart(
|
|
34
|
+
if (key.return) onStart("course")
|
|
31
35
|
else if (input === "c") onStart("course")
|
|
36
|
+
else if (input === "f") onStart("free")
|
|
32
37
|
else if (input === "r") onStart("resume")
|
|
33
38
|
else if (input === "w" || input === "W") onOpenWallet()
|
|
34
39
|
else if (input === "h") onStart("help")
|
|
40
|
+
else if (input === "q" || input === "Q") onQuit()
|
|
35
41
|
})
|
|
36
42
|
const t = STRINGS[locale]
|
|
37
43
|
return (
|
|
@@ -53,11 +59,13 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
|
|
|
53
59
|
</Box>
|
|
54
60
|
<Box marginTop={2}>
|
|
55
61
|
<KeyHints hints={[
|
|
56
|
-
{ key: "Enter", label: coursePack ? t.startCourse : t.
|
|
57
|
-
{ key: "c", label: t.pickCourse },
|
|
62
|
+
{ key: "Enter", label: coursePack ? t.startCourse : t.pickCourse },
|
|
63
|
+
...(coursePack ? [{ key: "c", label: t.pickCourse }] : []),
|
|
64
|
+
{ key: "f", label: t.startFree },
|
|
58
65
|
{ key: "r", label: t.resume },
|
|
59
66
|
{ key: "w", label: t.wallet },
|
|
60
67
|
{ key: "h", label: t.help },
|
|
68
|
+
{ key: "q", label: t.quit },
|
|
61
69
|
]} />
|
|
62
70
|
</Box>
|
|
63
71
|
{toast && (
|
|
@@ -83,6 +91,7 @@ const STRINGS = {
|
|
|
83
91
|
resume: "继续上次",
|
|
84
92
|
wallet: "钱包 / 充值(开浏览器)",
|
|
85
93
|
help: "帮助",
|
|
94
|
+
quit: "退出",
|
|
86
95
|
},
|
|
87
96
|
en: {
|
|
88
97
|
tagline: "🤖 Your AI coding buddy 🤖",
|
|
@@ -97,5 +106,6 @@ const STRINGS = {
|
|
|
97
106
|
resume: "Resume last session",
|
|
98
107
|
wallet: "Wallet / Top up (opens browser)",
|
|
99
108
|
help: "Help",
|
|
109
|
+
quit: "Quit",
|
|
100
110
|
},
|
|
101
111
|
} as const
|
package/src/render/ink/theme.ts
CHANGED
|
@@ -36,9 +36,13 @@ export interface Theme {
|
|
|
36
36
|
|
|
37
37
|
/** Default — vibrant on a dark terminal. */
|
|
38
38
|
const DARK: Theme = {
|
|
39
|
-
|
|
39
|
+
// Primary text is bright white and secondary text is plain white (not
|
|
40
|
+
// "gray"/blackBright, which renders near-invisible on many dark themes) so
|
|
41
|
+
// body copy actually reads. The fg/fgDim pair still differ enough to mark
|
|
42
|
+
// hierarchy. See dogfood feedback: "can't see the text, not prominent".
|
|
43
|
+
fg: "whiteBright",
|
|
40
44
|
bg: "black",
|
|
41
|
-
fgDim: "
|
|
45
|
+
fgDim: "white",
|
|
42
46
|
accent: "yellow",
|
|
43
47
|
warn: "yellow",
|
|
44
48
|
danger: "red",
|