@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 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.12",
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.1"
37
+ "@kidsinai/kids-opencode-plugin": "^0.0.17"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@opencode-ai/sdk": "^1.14.51",
@@ -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
- const dir = bundledCoursePacksDir()
64
- let entries: string[]
65
- try {
66
- entries = readdirSync(dir)
67
- } catch {
68
- return []
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 id of entries) {
79
+ for (const dir of dirs) {
80
+ let entries: string[]
72
81
  try {
73
- const full = join(dir, id)
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
- // skip malformed entry
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"),
@@ -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(). Shape varies
71
- // between SDK minor versions (some return async iterable, some a stream
72
- // helper). We use the duck-typed iterable path.
73
- const eventApi = (this.client as unknown as { global?: { event: () => AsyncIterable<unknown> } }).global
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 stream = eventApi.event()
78
- for await (const raw of stream) {
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
- const env = raw as { payload?: { type?: string } & Record<string, unknown> }
90
- const payload = env?.payload
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
- store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: reason } })
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" } })
@@ -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="round" borderColor={theme.border} paddingX={1} justifyContent="space-between">
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.1 [c] option list installed Course Packs and let the kid pick one.
3
- * On selection, the parent transitions to MissionScreen with that pack
4
- * loaded.
2
+ * Project-type pickerfirst impression for a kid with no `--course` flag.
5
3
  *
6
- * Up / Down to move, Enter to select, Esc to go back.
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(packs.length - 1, i + 1))
28
- else if (key.return && packs[idx]) onPick(packs[idx]!.id)
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 t = STRINGS[locale]
31
- if (packs.length === 0) {
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="round" borderColor={theme.warn} paddingX={2} paddingY={1}>
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="round" borderColor={theme.accent} paddingX={2} paddingY={1}>
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
- {packs.map((p, i) => {
66
+ {rows.map((row, i) => {
49
67
  const active = i === idx
50
68
  return (
51
- <Box key={p.id} marginBottom={i === packs.length - 1 ? 0 : 0}>
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}>{p.title}</Text>
55
- {p.shortDescription && (
56
- <Text color={theme.fgDim} dimColor={!active}> {p.shortDescription}</Text>
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: "选一个 Course Pack",
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: "Pick a Course Pack",
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 && state.thinking) onAbort()
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 → start a free-play session OR continue if a course pack is set
6
- * c → choose a Course Pack
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(coursePack ? "course" : "free")
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.startFree },
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
@@ -36,9 +36,13 @@ export interface Theme {
36
36
 
37
37
  /** Default — vibrant on a dark terminal. */
38
38
  const DARK: Theme = {
39
- fg: "white",
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: "gray",
45
+ fgDim: "white",
42
46
  accent: "yellow",
43
47
  warn: "yellow",
44
48
  danger: "red",