@kidsinai/kids-client 0.0.3 → 0.0.4

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 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.3",
4
+ "version": "0.0.4",
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",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Re-read ~/.config/kids-opencode/env after the setup wizard saves it,
3
+ * and inject the values into process.env. This lets the SAME process
4
+ * continue with the new LLM key — no `kids-opencode` re-run needed.
5
+ */
6
+
7
+ import { readFileSync, existsSync } from "node:fs"
8
+ import { join } from "node:path"
9
+
10
+ export function reloadEnvFile(configDir: string): Record<string, string> {
11
+ const path = join(configDir, "env")
12
+ if (!existsSync(path)) return {}
13
+ const out: Record<string, string> = {}
14
+ for (const raw of readFileSync(path, "utf8").split("\n")) {
15
+ const line = raw.trim()
16
+ if (!line || line.startsWith("#")) continue
17
+ const eq = line.indexOf("=")
18
+ if (eq <= 0) continue
19
+ const key = line.slice(0, eq).trim()
20
+ let value = line.slice(eq + 1).trim()
21
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
22
+ out[key] = value
23
+ process.env[key] = value
24
+ }
25
+ return out
26
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * In-TUI installer for the upstream `opencode` CLI binary.
3
+ *
4
+ * The kid (or their parent) should never have to drop back to a shell
5
+ * prompt and paste a `curl ... | sh` line. If the AI engine isn't
6
+ * installed when the wizard starts, we run the installer ourselves and
7
+ * stream its progress to the SetupScreen.
8
+ */
9
+
10
+ import { spawn } from "node:child_process"
11
+ import { existsSync } from "node:fs"
12
+ import { homedir } from "node:os"
13
+ import { join } from "node:path"
14
+
15
+ /** True if the upstream opencode binary is on PATH or in its standard install location. */
16
+ export function hasOpencodeBinary(): boolean {
17
+ // PATH lookup (POSIX). `which` and `command -v` aren't reliable as
18
+ // child_process commands without a shell, so just check $PATH dirs.
19
+ const pathDirs = (process.env.PATH ?? "").split(":")
20
+ const candidates = [
21
+ ...pathDirs.map((d) => join(d, "opencode")),
22
+ join(homedir(), ".opencode", "bin", "opencode"),
23
+ "/usr/local/bin/opencode",
24
+ "/opt/homebrew/bin/opencode",
25
+ ]
26
+ return candidates.some((p) => existsSync(p))
27
+ }
28
+
29
+ export interface InstallResult {
30
+ ok: boolean
31
+ error?: string
32
+ }
33
+
34
+ /**
35
+ * Run the upstream installer in a subprocess, streaming each output line
36
+ * to `onProgress`. Resolves when the subprocess exits.
37
+ *
38
+ * Uses /bin/sh + curl pipe to match upstream's official install command.
39
+ * The whole thing typically takes 15-45 seconds depending on network.
40
+ */
41
+ export function installOpencode(onProgress: (line: string) => void): Promise<InstallResult> {
42
+ return new Promise((resolve) => {
43
+ const child = spawn("sh", ["-c", "curl -fsSL https://opencode.ai/install | sh"], {
44
+ stdio: ["ignore", "pipe", "pipe"],
45
+ env: { ...process.env },
46
+ })
47
+
48
+ const handleStream = (stream: NodeJS.ReadableStream): void => {
49
+ let buf = ""
50
+ stream.on("data", (chunk: Buffer | string) => {
51
+ buf += chunk.toString()
52
+ let nl: number
53
+ while ((nl = buf.indexOf("\n")) >= 0) {
54
+ const line = buf.slice(0, nl)
55
+ buf = buf.slice(nl + 1)
56
+ const trimmed = line.replace(/\r/g, "").trim()
57
+ if (trimmed) onProgress(trimmed)
58
+ }
59
+ })
60
+ }
61
+ if (child.stdout) handleStream(child.stdout)
62
+ if (child.stderr) handleStream(child.stderr)
63
+
64
+ child.on("error", (err) => {
65
+ resolve({ ok: false, error: err.message })
66
+ })
67
+ child.on("close", (code) => {
68
+ if (code === 0) {
69
+ // Make sure the new bin dir is on PATH for the remainder of THIS run,
70
+ // so subsequent calls (postinstall plugin registration etc.) can find
71
+ // opencode without waiting for a shell restart.
72
+ const newBin = join(homedir(), ".opencode", "bin")
73
+ if (!process.env.PATH?.includes(newBin)) {
74
+ process.env.PATH = `${newBin}:${process.env.PATH ?? ""}`
75
+ }
76
+ resolve({ ok: true })
77
+ } else {
78
+ resolve({ ok: false, error: `installer exited with code ${code}` })
79
+ }
80
+ })
81
+ })
82
+ }
package/src/index.tsx CHANGED
@@ -1,23 +1,26 @@
1
1
  /**
2
2
  * kids-client entry. Composes core/* and renders the Ink app.
3
3
  *
4
- * Boot sequence:
5
- * 1. readEnv() — pull KIDS_* + OPENCODE_* from process.env
6
- * 2. validateEnv() — hard-fail with ErrorScreen if password/key missing
7
- * 3. ServeManager.ensureReady() spawn `opencode serve` if down, poll /app
8
- * 4. createKidsClient() instantiate SDK v2 client w/ Basic Auth
9
- * 5. SessionManager ready to prompt
10
- * 6. EventSubscriber.run() SSE loop dispatches to store
11
- * 7. Ink render(<App />) kid sees Startup screen, picks a flow
4
+ * Boot orchestration (V0.0.3):
5
+ * 1. readEnv + initial render in "loading"
6
+ * 2. validateEnv:
7
+ * - "needs_setup" render SetupScreen, await user completion,
8
+ * reload env from file, re-validate, continue inline
9
+ * - "config_missing" / "auth_failed" error screen, exit
10
+ * - ok fall through to bootServices
11
+ * 3. bootServices: audit pipeline + opencode serve subprocess +
12
+ * SDK v2 client + SSE subscriber + SIGINT/SIGTERM
13
+ * 4. Render startup screen; user picks a flow.
12
14
  *
13
- * Cleanup on SIGINT / SIGTERM: stop subscriber, stop audit pipeline,
14
- * kill serve child, exit. (V0 MVP: client crash takes serve with it.)
15
+ * Inline boot guarantee: the user never sees "run kids-opencode again".
16
+ * SetupScreen save reload env boot serve MissionScreen,
17
+ * all in the SAME process.
15
18
  */
16
19
 
17
20
  import React from "react"
18
21
  import { render } from "ink"
19
22
  import { join } from "node:path"
20
- import { readEnv, validateEnv } from "./core/env.ts"
23
+ import { readEnv, validateEnv, type KidsClientEnv } from "./core/env.ts"
21
24
  import { ServeManager } from "./core/serve-manager.ts"
22
25
  import { createKidsClient, type OpencodeClient } from "./core/connection.ts"
23
26
  import { SessionManager } from "./core/session.ts"
@@ -30,57 +33,176 @@ import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
30
33
  import { App } from "./render/ink/App.tsx"
31
34
  import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
32
35
  import { saveSetup, type ProviderId } from "./core/setup.ts"
36
+ import { reloadEnvFile } from "./core/env-reload.ts"
33
37
  import type { InstalledPack } from "./core/course-pack.ts"
34
- import { findMission, loadCoursePack } from "@kidsinai/kids-opencode-plugin"
38
+ import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
39
+
40
+ interface ServiceSet {
41
+ audit: AuditPipeline
42
+ serve: ServeManager
43
+ client: OpencodeClient
44
+ session: SessionManager
45
+ subscriber: EventSubscriber
46
+ quit: () => Promise<void>
47
+ handlers: FullHandlers
48
+ }
49
+
50
+ interface FullHandlers {
51
+ onStart: (mode: "free" | "course" | "resume" | "help") => void
52
+ onPrompt: (text: string) => Promise<void>
53
+ onPermissionReply: (decision: "allow" | "deny" | "edit") => Promise<void>
54
+ onAbort: () => Promise<void>
55
+ onErrorRetry: () => Promise<void>
56
+ onPickPack: (packId: string) => void
57
+ onMissionNext: () => void
58
+ }
59
+
60
+ interface AppHandlers {
61
+ onStart: (mode: "free" | "course" | "resume" | "help") => void
62
+ onPrompt: (text: string) => void
63
+ onPermissionReply: (decision: "allow" | "deny" | "edit") => void
64
+ onDangerousAcknowledge: () => void
65
+ onErrorRetry: () => void | Promise<void>
66
+ onQuit: () => void | Promise<void>
67
+ onAbort: () => void
68
+ onHelpBack: () => void
69
+ onPickPack: (packId: string) => void
70
+ onPickerBack: () => void
71
+ onMissionNext: () => void
72
+ onMissionBack: () => void
73
+ onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
74
+ onSetupContinue: () => Promise<void>
75
+ onSetupSkip: () => void
76
+ }
35
77
 
36
78
  async function main(): Promise<void> {
37
- const env = readEnv()
79
+ const env: KidsClientEnv = readEnv()
38
80
  const store = new Store()
39
81
  const installedPacks = listInstalledPacks()
82
+
40
83
  store.update({
41
84
  coursePack: env.coursePack,
42
85
  mission: env.mission,
43
86
  screen: { kind: "loading", message: env.locale === "zh-Hans" ? "正在唤醒 AI 老师…" : "Waking up the AI teacher…" },
44
87
  })
45
88
 
46
- const check = validateEnv(env)
89
+ // Resolve course pack metadata upfront if available.
90
+ applyCoursePackContext(env, store)
91
+
92
+ // Mutable holder for service set; populated by bootServices().
93
+ const servicesHolder: { current: ServiceSet | null } = { current: null }
94
+
95
+ // Promise that the SetupScreen flow resolves when the user has completed
96
+ // (or chosen to skip) setup. main() awaits it before continuing.
97
+ let resolveSetup: (() => void) | null = null
98
+ const setupGate = new Promise<void>((r) => { resolveSetup = r })
99
+
100
+ const handlers: AppHandlers = makeHandlers(store, env, servicesHolder, resolveSetupFn => {
101
+ resolveSetup = resolveSetupFn
102
+ }, () => resolveSetup)
103
+
104
+ renderApp(store, env, installedPacks, handlers)
105
+
106
+ // First validation pass.
107
+ let check = validateEnv(env)
108
+ if (!check.ok && check.variant === "needs_setup") {
109
+ store.update({ screen: { kind: "setup" } })
110
+ await setupGate
111
+
112
+ // Re-source env file (the setup wizard wrote it).
113
+ reloadEnvFile(env.configDir)
114
+ Object.assign(env, readEnv())
115
+ check = validateEnv(env)
116
+ }
117
+
47
118
  if (!check.ok) {
48
- if (check.variant === "needs_setup") {
49
- // First-run wizard. Render the setup screen; the wizard's onSave
50
- // writes config + env file, then re-launches main() to pick up
51
- // the new key.
52
- store.update({ screen: { kind: "setup" } })
53
- renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
54
- return
55
- }
56
- store.update({ screen: { kind: "error", variant: check.variant, detail: check.reason } })
57
- renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
119
+ const variant = check.variant === "needs_setup" ? "auth_failed" : check.variant
120
+ store.update({ screen: { kind: "error", variant, detail: check.reason } })
58
121
  return
59
122
  }
60
123
 
61
- // Resolve course pack context up front (free-play if coursePack is null).
62
- const ctx = resolveContext(env.coursePack, env.mission)
63
- if (ctx) {
64
- store.update({
65
- packTitle: ctx.packTitle,
66
- missionTitle: ctx.missionTitle,
67
- missionIndex: ctx.missionIndex,
68
- missionTotal: ctx.missionTotal,
69
- starsBudget: ctx.starsBudget,
70
- starsBalance: ctx.starsBudget, // start full; charges deduct via audit hook
71
- })
72
- } else if (env.coursePack) {
73
- // Pack id provided but not found — surface as a toast on the startup screen.
74
- store.update({
75
- toast: {
76
- kind: "warn",
77
- text: env.locale === "zh-Hans"
78
- ? `没找到 Course Pack: ${env.coursePack}(按 c 重新选)`
79
- : `Course Pack not found: ${env.coursePack} (press c to pick)`,
80
- },
81
- })
124
+ // Bootstrap services in-process. Loading screen is shown while we wait.
125
+ store.update({
126
+ screen: {
127
+ kind: "loading",
128
+ message: env.locale === "zh-Hans" ? "启动 AI 引擎…" : "Starting AI engine…",
129
+ },
130
+ })
131
+
132
+ const services = await bootServices(env, store)
133
+ if (!services) {
134
+ // bootServices already updated the store with the failure screen.
135
+ return
136
+ }
137
+ servicesHolder.current = services
138
+
139
+ // SIGINT / SIGTERM cleanly tears down.
140
+ process.on("SIGINT", () => void services.quit())
141
+ process.on("SIGTERM", () => void services.quit())
142
+
143
+ // Land on startup screen.
144
+ store.update({ screen: { kind: "startup" } })
145
+ }
146
+
147
+ // ─── handler factory ──────────────────────────────────────────────────────
148
+
149
+ function makeHandlers(
150
+ store: Store,
151
+ env: KidsClientEnv,
152
+ servicesHolder: { current: ServiceSet | null },
153
+ _setResolveSetup: (fn: (() => void) | null) => void,
154
+ getResolveSetup: () => (() => void) | null,
155
+ ): AppHandlers {
156
+ const ifBooted = <A extends unknown[]>(fn: (s: ServiceSet, ...args: A) => unknown) => (...args: A) => {
157
+ const s = servicesHolder.current
158
+ if (s) return fn(s, ...args)
159
+ return undefined
82
160
  }
83
161
 
162
+ return {
163
+ onStart: ifBooted((s, mode: "free" | "course" | "resume" | "help") => s.handlers.onStart(mode)),
164
+ onPrompt: ifBooted((s, text: string) => s.handlers.onPrompt(text)),
165
+ onPermissionReply: ifBooted((s, d: "allow" | "deny" | "edit") => s.handlers.onPermissionReply(d)),
166
+ onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
167
+ onErrorRetry: async () => {
168
+ const s = servicesHolder.current
169
+ if (s) return s.handlers.onErrorRetry()
170
+ // Pre-boot error retry: re-run main isn't trivial; just exit.
171
+ process.exit(1)
172
+ },
173
+ onQuit: async () => {
174
+ const s = servicesHolder.current
175
+ if (s) return s.quit()
176
+ process.exit(0)
177
+ },
178
+ onAbort: ifBooted((s) => s.handlers.onAbort()),
179
+ onHelpBack: () => store.update({ screen: { kind: "startup" } }),
180
+ onPickPack: ifBooted((s, id: string) => s.handlers.onPickPack(id)),
181
+ onPickerBack: () => store.update({ screen: { kind: "startup" } }),
182
+ onMissionNext: ifBooted((s) => s.handlers.onMissionNext()),
183
+ onMissionBack: () => store.update({ screen: { kind: "mission" } }),
184
+ onSetupSave: async (provider, apiKey) => {
185
+ try {
186
+ saveSetup({ configDir: env.configDir, provider, apiKey })
187
+ return { ok: true }
188
+ } catch (err) {
189
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
190
+ }
191
+ },
192
+ onSetupContinue: async () => {
193
+ const r = getResolveSetup()
194
+ if (r) r()
195
+ },
196
+ onSetupSkip: () => {
197
+ const r = getResolveSetup()
198
+ if (r) r()
199
+ },
200
+ }
201
+ }
202
+
203
+ // ─── service bootstrap ────────────────────────────────────────────────────
204
+
205
+ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSet | null> {
84
206
  const audit = new AuditPipeline({
85
207
  bufferPath: join(env.configDir, "audit-buffer.jsonl"),
86
208
  })
@@ -100,8 +222,7 @@ async function main(): Promise<void> {
100
222
  const readiness = await serve.ensureReady()
101
223
  if (readiness.kind === "timeout") {
102
224
  store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
103
- renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, serve))
104
- return
225
+ return null
105
226
  }
106
227
 
107
228
  const client = createKidsClient({
@@ -114,8 +235,8 @@ async function main(): Promise<void> {
114
235
  onSessionCreated: (e) => {
115
236
  store.update({ sessionId: e.sessionID })
116
237
  writeLastSession(env.configDir, {
117
- coursePack: env.coursePack,
118
- mission: env.mission,
238
+ coursePack: store.getSnapshot().coursePack,
239
+ mission: store.getSnapshot().mission,
119
240
  lastActiveAt: new Date().toISOString(),
120
241
  projectDir: process.cwd(),
121
242
  })
@@ -137,7 +258,6 @@ async function main(): Promise<void> {
137
258
  },
138
259
  onTextEnded: (e) => store.endStream(e.messageID),
139
260
  onPermissionAsked: (e) => {
140
- // pickup of stars_estimated from the latest plugin audit event.
141
261
  const recentAudit = store.getSnapshot().auditBuffer.slice(-10).reverse() as Array<Record<string, unknown>>
142
262
  const matching = recentAudit.find(
143
263
  (a) => a && typeof a === "object" && a.event === "tool.execute.before" && a.tool === e.tool,
@@ -174,121 +294,25 @@ async function main(): Promise<void> {
174
294
  })
175
295
  void subscriber.run()
176
296
 
177
- // First screen: startup. If env already had a course pack, kid can either
178
- // jump straight in (Enter) or pick a different one (c).
179
- store.update({ screen: { kind: "startup" } })
180
-
181
- const handleQuit = async (): Promise<void> => {
297
+ const quit = async (): Promise<void> => {
182
298
  subscriber.stop()
183
299
  await audit.stop()
184
300
  await serve.shutdown()
185
301
  process.exit(0)
186
302
  }
187
- process.on("SIGINT", () => void handleQuit())
188
- process.on("SIGTERM", () => void handleQuit())
189
-
190
- const handlers = fullHandlers(store, env, session, client, serve, handleQuit)
191
- renderApp(store, env, installedPacks, handlers)
192
- }
193
303
 
194
- // ─── handler factories ───────────────────────────────────────────────────
304
+ const handlers = makeFullHandlers(store, env, session, client, serve)
195
305
 
196
- interface AppHandlers {
197
- onStart: (mode: "free" | "course" | "resume" | "help") => void
198
- onPrompt: (text: string) => void
199
- onPermissionReply: (decision: "allow" | "deny" | "edit") => void
200
- onDangerousAcknowledge: () => void
201
- onErrorRetry: () => void | Promise<void>
202
- onQuit: () => void | Promise<void>
203
- onAbort: () => void
204
- onHelpBack: () => void
205
- onPickPack: (packId: string) => void
206
- onPickerBack: () => void
207
- onMissionNext: () => void
208
- onMissionBack: () => void
209
- onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
210
- onSetupSkip: () => void
211
- }
212
-
213
- function makeSetupHandlers(store: Store, env: ReturnType<typeof readEnv>): Pick<AppHandlers, "onSetupSave" | "onSetupSkip"> {
214
- return {
215
- onSetupSave: async (provider, apiKey) => {
216
- try {
217
- saveSetup({ configDir: env.configDir, provider, apiKey })
218
- return { ok: true }
219
- } catch (err) {
220
- return { ok: false, reason: err instanceof Error ? err.message : String(err) }
221
- }
222
- },
223
- onSetupSkip: () => {
224
- // After setup completes (or user skips), tell the user to restart so
225
- // the wrapper picks up the new env file. Re-launching main() in-process
226
- // would require tearing down Ink which is messy; a re-exec is cleaner.
227
- process.stderr.write("\nKids OpenCode: setup saved. Please run `kids-opencode` again to start.\n")
228
- process.exit(0)
229
- },
230
- }
231
- }
232
-
233
- /**
234
- * Minimal handlers for the pre-validation / pre-readiness error path.
235
- * Many actions are no-ops because the app isn't fully booted; quit is
236
- * the realistic action.
237
- */
238
- function baseHandlers(
239
- store: Store,
240
- env: ReturnType<typeof readEnv>,
241
- _session: SessionManager | null,
242
- _client: OpencodeClient | null,
243
- serve: ServeManager | null,
244
- ): AppHandlers {
245
- const noop = (): void => {}
246
- const setup = makeSetupHandlers(store, env)
247
- return {
248
- onStart: noop,
249
- onPrompt: noop,
250
- onPermissionReply: noop,
251
- onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
252
- onErrorRetry: async () => {
253
- if (!serve) {
254
- process.exit(1)
255
- return
256
- }
257
- store.update({
258
- screen: {
259
- kind: "loading",
260
- message: env.locale === "zh-Hans" ? "再试一次…" : "Trying again…",
261
- },
262
- })
263
- const again = await serve.ensureReady()
264
- if (again.kind === "timeout") {
265
- store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
266
- } else {
267
- store.update({ screen: { kind: "startup" } })
268
- }
269
- },
270
- onQuit: async () => {
271
- if (serve) await serve.shutdown()
272
- process.exit(0)
273
- },
274
- onAbort: noop,
275
- onHelpBack: () => store.update({ screen: { kind: "startup" } }),
276
- onPickPack: noop,
277
- onPickerBack: () => store.update({ screen: { kind: "startup" } }),
278
- onMissionNext: noop,
279
- onMissionBack: noop,
280
- ...setup,
281
- }
306
+ return { audit, serve, client, session, subscriber, quit, handlers }
282
307
  }
283
308
 
284
- function fullHandlers(
309
+ function makeFullHandlers(
285
310
  store: Store,
286
- env: ReturnType<typeof readEnv>,
311
+ env: KidsClientEnv,
287
312
  session: SessionManager,
288
313
  client: OpencodeClient,
289
314
  serve: ServeManager,
290
- quit: () => Promise<void>,
291
- ): AppHandlers {
315
+ ): FullHandlers {
292
316
  const updateLastSession = (): void => {
293
317
  writeLastSession(env.configDir, {
294
318
  coursePack: store.getSnapshot().coursePack,
@@ -329,10 +353,9 @@ function fullHandlers(
329
353
  refreshContext()
330
354
  flashToast(store, {
331
355
  kind: "info",
332
- text:
333
- env.locale === "zh-Hans"
334
- ? `继续上次:${last.coursePack}${last.mission ? " · " + last.mission : ""}`
335
- : `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
356
+ text: env.locale === "zh-Hans"
357
+ ? `继续上次:${last.coursePack}${last.mission ? " · " + last.mission : ""}`
358
+ : `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
336
359
  })
337
360
  } else {
338
361
  flashToast(store, {
@@ -343,14 +366,12 @@ function fullHandlers(
343
366
  store.update({ screen: { kind: "mission" } })
344
367
  return
345
368
  }
346
- // mode === "free" (or unrecognised) — enter MissionScreen.
347
369
  store.update({ screen: { kind: "mission" } })
348
370
  },
349
371
  onPrompt: async (text) => {
350
372
  const snap = store.getSnapshot()
351
373
  store.appendMessage({ id: `kid-${Date.now()}`, actor: "kid", text, streaming: false, ts: Date.now() })
352
374
 
353
- // In-TUI mission check intercept. Don't even hit the LLM.
354
375
  if (snap.mission && isCompletionTrigger(text, env.locale)) {
355
376
  const outcome = runCheck({
356
377
  missionId: snap.mission,
@@ -384,14 +405,12 @@ function fullHandlers(
384
405
  return
385
406
  }
386
407
 
387
- // Dangerous topic intercept on kid input.
388
408
  const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(text) : detectDangerousTopicEn(text)
389
409
  if (hit) {
390
410
  store.update({ dangerousTopic: { category: hit, snippet: text } })
391
411
  return
392
412
  }
393
413
 
394
- // Normal LLM prompt.
395
414
  store.update({ thinking: true })
396
415
  updateLastSession()
397
416
  try {
@@ -412,17 +431,23 @@ function fullHandlers(
412
431
  if (decision === "edit") {
413
432
  flashToast(store, {
414
433
  kind: "info",
415
- text:
416
- env.locale === "zh-Hans"
417
- ? "你来改这一步,告诉 AI 你想怎么做"
418
- : "You take this step — tell the AI what you'd prefer",
434
+ text: env.locale === "zh-Hans"
435
+ ? "你来改这一步,告诉 AI 你想怎么做"
436
+ : "You take this step — tell the AI what you'd prefer",
419
437
  })
420
438
  }
421
- } catch {
422
- // SSE will surface the timeout / error via onLlmError.
423
- }
439
+ } catch { /* SSE surfaces errors */ }
440
+ },
441
+ onAbort: async () => {
442
+ try {
443
+ await session.abort()
444
+ store.update({ thinking: false })
445
+ flashToast(store, {
446
+ kind: "warn",
447
+ text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
448
+ })
449
+ } catch { /* ignore */ }
424
450
  },
425
- onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
426
451
  onErrorRetry: async () => {
427
452
  store.update({
428
453
  screen: {
@@ -437,26 +462,11 @@ function fullHandlers(
437
462
  store.update({ screen: { kind: "startup" } })
438
463
  }
439
464
  },
440
- onQuit: quit,
441
- onAbort: async () => {
442
- try {
443
- await session.abort()
444
- store.update({ thinking: false })
445
- flashToast(store, {
446
- kind: "warn",
447
- text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
448
- })
449
- } catch {
450
- // ignore
451
- }
452
- },
453
- onHelpBack: () => store.update({ screen: { kind: "startup" } }),
454
465
  onPickPack: (packId) => {
455
466
  store.update({ coursePack: packId, mission: null })
456
467
  refreshContext()
457
468
  store.update({ screen: { kind: "mission" } })
458
469
  },
459
- onPickerBack: () => store.update({ screen: { kind: "startup" } }),
460
470
  onMissionNext: () => {
461
471
  const snap = store.getSnapshot()
462
472
  if (!snap.coursePack || !snap.mission) {
@@ -479,16 +489,37 @@ function fullHandlers(
479
489
  text: env.locale === "zh-Hans" ? `开始:${next.title}` : `Starting: ${next.title}`,
480
490
  })
481
491
  },
482
- onMissionBack: () => store.update({ screen: { kind: "mission" } }),
483
- ...makeSetupHandlers(store, env),
484
492
  }
485
493
  }
486
494
 
487
- // ─── utilities ───────────────────────────────────────────────────────────
495
+ // ─── helpers ──────────────────────────────────────────────────────────────
496
+
497
+ function applyCoursePackContext(env: KidsClientEnv, store: Store): void {
498
+ const ctx = resolveContext(env.coursePack, env.mission)
499
+ if (ctx) {
500
+ store.update({
501
+ packTitle: ctx.packTitle,
502
+ missionTitle: ctx.missionTitle,
503
+ missionIndex: ctx.missionIndex,
504
+ missionTotal: ctx.missionTotal,
505
+ starsBudget: ctx.starsBudget,
506
+ starsBalance: ctx.starsBudget,
507
+ })
508
+ } else if (env.coursePack) {
509
+ store.update({
510
+ toast: {
511
+ kind: "warn",
512
+ text: env.locale === "zh-Hans"
513
+ ? `没找到 Course Pack: ${env.coursePack}(按 c 重新选)`
514
+ : `Course Pack not found: ${env.coursePack} (press c to pick)`,
515
+ },
516
+ })
517
+ }
518
+ }
488
519
 
489
520
  function renderApp(
490
521
  store: Store,
491
- env: ReturnType<typeof readEnv>,
522
+ env: KidsClientEnv,
492
523
  installedPacks: InstalledPack[],
493
524
  handlers: AppHandlers,
494
525
  ): void {
@@ -536,11 +567,6 @@ function errMessage(err: unknown): string {
536
567
  return String(err)
537
568
  }
538
569
 
539
- // Touch findMission so TS doesn't complain about the import being unused
540
- // when typecheck runs against the v0.0.1 SDK that doesn't expose v2 yet.
541
- void findMission
542
- void loadCoursePack
543
-
544
570
  void main().catch((err) => {
545
571
  console.error("kids-client: fatal startup error:", err)
546
572
  process.exit(1)
@@ -41,6 +41,7 @@ export interface AppDeps {
41
41
  onMissionNext: () => void
42
42
  onMissionBack: () => void
43
43
  onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
44
+ onSetupContinue: () => Promise<void>
44
45
  onSetupSkip: () => void
45
46
  }
46
47
 
@@ -74,7 +75,7 @@ export function App(deps: AppDeps): React.ReactElement {
74
75
  case "loading":
75
76
  return <LoadingScreen locale={deps.locale} message={state.screen.message} />
76
77
  case "setup":
77
- return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onSkip={deps.onSetupSkip} />
78
+ return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onContinue={deps.onSetupContinue} onSkip={deps.onSetupSkip} />
78
79
  case "startup":
79
80
  return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
80
81
  case "mission":
@@ -4,18 +4,17 @@
4
4
  *
5
5
  * Audience: a parent (the kid sees the intro and is told to grab a
6
6
  * grown-up). The wizard walks through:
7
+ * 0. engine_install (auto, only if upstream opencode CLI missing)
7
8
  * 1. Welcome / "this part needs a grown-up"
8
9
  * 2. Pick provider (Anthropic / OpenAI / DeepRouter)
9
10
  * 3. Paste API key (with link to where to get one)
10
- * 4. Save → re-validateroute to startup
11
- *
12
- * The choice is persisted via core/setup.ts (writes ~/.config/kids-opencode/env
13
- * + updates opencode.json provider section).
11
+ * 4. Save → continue inline (no re-exec)MissionScreen
14
12
  */
15
13
 
16
- import React, { useState } from "react"
14
+ import React, { useEffect, useState } from "react"
17
15
  import { Box, Text, useInput } from "ink"
18
16
  import TextInput from "ink-text-input"
17
+ import Spinner from "ink-spinner"
19
18
  import { getTheme } from "../theme.ts"
20
19
  import { KidsLogo } from "../components/KidsLogo.tsx"
21
20
  import {
@@ -24,25 +23,64 @@ import {
24
23
  PROVIDERS,
25
24
  type ProviderId,
26
25
  } from "../../../core/setup.ts"
26
+ import { hasOpencodeBinary, installOpencode } from "../../../core/opencode-installer.ts"
27
27
 
28
- type Step = "intro" | "provider" | "apikey" | "saving" | "done" | "error"
28
+ type Step =
29
+ | "engine_install"
30
+ | "engine_done"
31
+ | "intro"
32
+ | "provider"
33
+ | "apikey"
34
+ | "saving"
35
+ | "done"
36
+ | "error"
29
37
 
30
38
  interface SetupScreenProps {
31
39
  locale: "zh-Hans" | "en"
32
40
  onSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
41
+ /** After save, kicks off inline boot. Resolves when AI is ready. */
42
+ onContinue: () => Promise<void>
43
+ /** Skip key — useful for advanced users who set env vars themselves. */
33
44
  onSkip: () => void
34
45
  }
35
46
 
36
- export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React.ReactElement {
47
+ export function SetupScreen({ locale, onSave, onContinue, onSkip }: SetupScreenProps): React.ReactElement {
37
48
  const theme = getTheme()
38
49
  const t = STRINGS[locale]
39
- const [step, setStep] = useState<Step>("intro")
50
+ const initialStep: Step = hasOpencodeBinary() ? "intro" : "engine_install"
51
+ const [step, setStep] = useState<Step>(initialStep)
40
52
  const [providerIdx, setProviderIdx] = useState(0)
41
53
  const [apiKey, setApiKey] = useState("")
42
54
  const [errorMsg, setErrorMsg] = useState("")
55
+ const [engineLog, setEngineLog] = useState<string[]>([])
56
+ const [engineRunning, setEngineRunning] = useState(false)
57
+
58
+ // Auto-trigger engine install once on first render.
59
+ useEffect(() => {
60
+ if (initialStep === "engine_install" && !engineRunning) {
61
+ setEngineRunning(true)
62
+ void installOpencode((line) => {
63
+ setEngineLog((prev) => {
64
+ const next = [...prev, line]
65
+ return next.length > 8 ? next.slice(next.length - 8) : next
66
+ })
67
+ }).then((result) => {
68
+ setEngineRunning(false)
69
+ if (result.ok) {
70
+ setStep("engine_done")
71
+ } else {
72
+ setErrorMsg(result.error ?? "engine install failed")
73
+ setStep("error")
74
+ }
75
+ })
76
+ }
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [])
43
79
 
44
80
  useInput((input, key) => {
45
- if (step === "intro") {
81
+ if (step === "engine_done") {
82
+ if (key.return) setStep("intro")
83
+ } else if (step === "intro") {
46
84
  if (key.return) setStep("provider")
47
85
  else if (input === "s" || input === "S") onSkip()
48
86
  } else if (step === "provider") {
@@ -51,15 +89,77 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
51
89
  else if (key.return) setStep("apikey")
52
90
  else if (key.escape) setStep("intro")
53
91
  } else if (step === "done") {
54
- if (key.return) onSkip() // continue to startup
92
+ if (key.return) {
93
+ // Inline boot — no exit.
94
+ void onContinue()
95
+ }
55
96
  } else if (step === "error") {
56
- if (key.return) setStep("apikey")
97
+ if (key.return) {
98
+ // From any failure step, retry from apikey unless engine failed
99
+ setStep(engineLog.length > 0 && !hasOpencodeBinary() ? "engine_install" : "apikey")
100
+ if (engineLog.length > 0 && !hasOpencodeBinary()) {
101
+ // Restart engine install
102
+ setEngineLog([])
103
+ setEngineRunning(true)
104
+ void installOpencode((line) => {
105
+ setEngineLog((prev) => {
106
+ const next = [...prev, line]
107
+ return next.length > 8 ? next.slice(next.length - 8) : next
108
+ })
109
+ }).then((result) => {
110
+ setEngineRunning(false)
111
+ if (result.ok) setStep("engine_done")
112
+ else {
113
+ setErrorMsg(result.error ?? "engine install failed")
114
+ setStep("error")
115
+ }
116
+ })
117
+ }
118
+ }
57
119
  }
58
120
  })
59
121
 
60
122
  const provider = PROVIDERS[providerIdx]!
61
123
  const providerObj = findProvider(provider.id)
62
124
 
125
+ if (step === "engine_install") {
126
+ return (
127
+ <Box flexDirection="column" alignItems="center" paddingY={1}>
128
+ <KidsLogo />
129
+ <Box marginTop={2} borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1} flexDirection="column">
130
+ <Box>
131
+ <Text color={theme.accent}>{engineRunning ? <Spinner type="dots" /> : " "}</Text>
132
+ <Text color={theme.accent} bold> {t.engineInstalling}</Text>
133
+ </Box>
134
+ <Box marginTop={1}>
135
+ <Text color={theme.fgDim}>{t.engineHint}</Text>
136
+ </Box>
137
+ {engineLog.length > 0 && (
138
+ <Box marginTop={1} flexDirection="column">
139
+ {engineLog.map((line, i) => (
140
+ <Text key={i} color={theme.fgDim} dimColor> {line}</Text>
141
+ ))}
142
+ </Box>
143
+ )}
144
+ </Box>
145
+ </Box>
146
+ )
147
+ }
148
+
149
+ if (step === "engine_done") {
150
+ return (
151
+ <Box flexDirection="column" alignItems="center" paddingY={1}>
152
+ <KidsLogo />
153
+ <Box marginTop={2} borderStyle="round" borderColor={theme.success} paddingX={2} paddingY={1}>
154
+ <Text color={theme.success} bold>{t.engineDone}</Text>
155
+ </Box>
156
+ <Box marginTop={1}>
157
+ <Text color={theme.accent}>{t.continueHint}</Text>
158
+ </Box>
159
+ </Box>
160
+ )
161
+ }
162
+
63
163
  if (step === "intro") {
64
164
  return (
65
165
  <Box flexDirection="column" alignItems="center" paddingY={1}>
@@ -150,7 +250,10 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
150
250
  if (step === "saving") {
151
251
  return (
152
252
  <Box paddingY={1} paddingX={2}>
153
- <Text color={theme.accent}>{t.saving}</Text>
253
+ <Text color={theme.accent}>
254
+ <Spinner type="dots" />
255
+ </Text>
256
+ <Text color={theme.accent}> {t.saving}</Text>
154
257
  </Box>
155
258
  )
156
259
  }
@@ -188,6 +291,10 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
188
291
 
189
292
  const STRINGS = {
190
293
  "zh-Hans": {
294
+ engineInstalling: "正在安装 AI 引擎…",
295
+ engineHint: "从 opencode.ai 下载(大约 30 秒)。卡住的话按 Enter 重试。",
296
+ engineDone: "✓ AI 引擎安装完成",
297
+ continueHint: "[Enter] 下一步",
191
298
  introTitle: "👋 这一步需要家长帮忙",
192
299
  introLine1: "AI 老师要用一个 \"API key\" 才能工作 —— 就像给它一把钥匙。",
193
300
  introLine2: "家长打开账号给 AI 服务(Anthropic / OpenAI 等),拿到 key 粘进来就行。",
@@ -205,10 +312,14 @@ const STRINGS = {
205
312
  errTitle: "出了点问题",
206
313
  errRetry: "[Enter] 再试",
207
314
  doneTitle: "🎉 搞定!家长任务完成。",
208
- doneNext: "你可以让孩子继续了。下一屏是启动屏。",
209
- doneHint: "[Enter] 开始",
315
+ doneNext: "马上启动,让孩子继续。",
316
+ doneHint: "[Enter] 启动",
210
317
  },
211
318
  en: {
319
+ engineInstalling: "Setting up the AI engine…",
320
+ engineHint: "Downloading from opencode.ai (about 30 seconds). Stuck? Press Enter to retry.",
321
+ engineDone: "✓ AI engine ready",
322
+ continueHint: "[Enter] Next",
212
323
  introTitle: "👋 Grown-up help needed for this part",
213
324
  introLine1: "The AI teacher needs an \"API key\" to work — think of it as a password.",
214
325
  introLine2: "A parent opens an account with an AI service (Anthropic / OpenAI), copies the key, pastes it here.",
@@ -226,7 +337,7 @@ const STRINGS = {
226
337
  errTitle: "Something went wrong",
227
338
  errRetry: "[Enter] Try again",
228
339
  doneTitle: "🎉 All set! Grown-up step done.",
229
- doneNext: "You can hand it back to the kid now. Next screen is the welcome.",
340
+ doneNext: "Starting up now.",
230
341
  doneHint: "[Enter] Start",
231
342
  },
232
343
  } as const