@kidsinai/kids-client 0.0.11 → 0.0.16

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.11",
4
+ "version": "0.0.16",
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.16"
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`. */
@@ -37,6 +41,12 @@ export interface KidsClientEnv {
37
41
  configDir: string
38
42
  /** When true, the client renders a "Tony banner" / suppresses interactive prompts (CI). */
39
43
  noBanner: boolean
44
+ /**
45
+ * Airbotix Portal base URL — used by the [w] Wallet / Top-up shortcut to
46
+ * deep-link parents into login + Airwallex top-up. Defaults to
47
+ * https://app.airbotix.ai; staging overrides via AIRBOTIX_PORTAL_URL.
48
+ */
49
+ portalBaseUrl: string
40
50
  }
41
51
 
42
52
  export function readEnv(): KidsClientEnv {
@@ -51,10 +61,13 @@ export function readEnv(): KidsClientEnv {
51
61
  bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
52
62
  coursePack: process.env.KIDS_COURSE_PACK || null,
53
63
  mission: process.env.KIDS_MISSION || null,
64
+ vibeId: process.env.KIDS_VIBE_ID || null,
65
+ projectName: process.env.KIDS_PROJECT_NAME || null,
54
66
  locale,
55
67
  opencodeBin: process.env.OPENCODE_BIN ?? "opencode",
56
68
  configDir: process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"),
57
69
  noBanner: process.env.KIDS_OPENCODE_NO_BANNER === "1",
70
+ portalBaseUrl: process.env.AIRBOTIX_PORTAL_URL || "https://app.airbotix.ai",
58
71
  }
59
72
  }
60
73
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Wallet / login deep-link to Airbotix Portal.
3
+ *
4
+ * V0 strategy: open the parent's default browser to portal/wallet?from=cli.
5
+ * Portal handles auth (login first if no session) and Airwallex hosted card
6
+ * entry. TUI does not touch card data — PCI scope stays in the browser.
7
+ *
8
+ * A stable device-id (random UUID, persisted under configDir) is included
9
+ * so platform-backend can later correlate top-ups with the local install
10
+ * for V1 device-link polling. Today portal just logs it.
11
+ */
12
+
13
+ import { spawn } from "node:child_process"
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { randomUUID } from "node:crypto"
17
+
18
+ export const DEFAULT_PORTAL_BASE_URL = "https://app.airbotix.ai"
19
+
20
+ export function getOrCreateDeviceId(configDir: string): string {
21
+ const p = join(configDir, "device-id")
22
+ if (existsSync(p)) {
23
+ const v = readFileSync(p, "utf8").trim()
24
+ if (v) return v
25
+ }
26
+ mkdirSync(configDir, { recursive: true })
27
+ const id = randomUUID()
28
+ writeFileSync(p, id + "\n", { mode: 0o600 })
29
+ return id
30
+ }
31
+
32
+ export interface WalletUrlOpts {
33
+ portalBaseUrl?: string
34
+ deviceId: string
35
+ locale?: "zh-Hans" | "en"
36
+ }
37
+
38
+ export function buildWalletUrl(opts: WalletUrlOpts): string {
39
+ const base = (opts.portalBaseUrl || DEFAULT_PORTAL_BASE_URL).replace(/\/+$/, "")
40
+ const params = new URLSearchParams({
41
+ from: "cli",
42
+ device: opts.deviceId,
43
+ })
44
+ if (opts.locale) params.set("lang", opts.locale)
45
+ return `${base}/portal/wallet?${params.toString()}`
46
+ }
47
+
48
+ export type OpenResult = { ok: true } | { ok: false; reason: string }
49
+
50
+ export function openInBrowser(url: string): OpenResult {
51
+ const platform = process.platform
52
+ let cmd: string
53
+ let args: string[]
54
+ if (platform === "darwin") {
55
+ cmd = "open"
56
+ args = [url]
57
+ } else if (platform === "win32") {
58
+ // `start` is a cmd.exe builtin; the empty "" is the window title slot.
59
+ cmd = "cmd"
60
+ args = ["/c", "start", "", url]
61
+ } else {
62
+ cmd = "xdg-open"
63
+ args = [url]
64
+ }
65
+ try {
66
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" })
67
+ child.on("error", () => {
68
+ // Swallow async spawn errors — TUI already showed a toast saying we
69
+ // tried, and the parent can copy the URL from the toast as fallback.
70
+ })
71
+ child.unref()
72
+ return { ok: true }
73
+ } catch (err) {
74
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
75
+ }
76
+ }
package/src/index.tsx CHANGED
@@ -31,12 +31,14 @@ 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"
37
38
  import { hasSeenTour, markTourSeen } from "./core/tour-marker.ts"
38
39
  import type { InstalledPack } from "./core/course-pack.ts"
39
40
  import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
41
+ import { buildWalletUrl, getOrCreateDeviceId, openInBrowser } from "./core/wallet-link.ts"
40
42
 
41
43
  interface ServiceSet {
42
44
  audit: AuditPipeline
@@ -77,6 +79,7 @@ interface AppHandlers {
77
79
  onSetupSkip: () => void
78
80
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
79
81
  onTourDone: () => void
82
+ onOpenWallet: () => void
80
83
  }
81
84
 
82
85
  async function main(): Promise<void> {
@@ -248,6 +251,25 @@ function makeHandlers(
248
251
  const r = getResolveTour()
249
252
  if (r) r()
250
253
  },
254
+ onOpenWallet: () => {
255
+ const deviceId = getOrCreateDeviceId(env.configDir)
256
+ const url = buildWalletUrl({
257
+ portalBaseUrl: env.portalBaseUrl,
258
+ deviceId,
259
+ locale: env.locale,
260
+ })
261
+ const result = openInBrowser(url)
262
+ const okText = env.locale === "zh-Hans"
263
+ ? `已在浏览器打开:${url}`
264
+ : `Opened in your browser: ${url}`
265
+ const failText = env.locale === "zh-Hans"
266
+ ? `没办法自动开浏览器。请手动打开:${url}`
267
+ : `Couldn't auto-open the browser. Open manually: ${url}`
268
+ flashToast(store, {
269
+ kind: result.ok ? "success" : "warn",
270
+ text: result.ok ? okText : failText,
271
+ })
272
+ },
251
273
  }
252
274
  }
253
275
 
@@ -553,6 +575,12 @@ function makeFullHandlers(
553
575
  }
554
576
  },
555
577
  onPickPack: (packId) => {
578
+ if (packId === FREE_PLAY_PACK_ID) {
579
+ // Synthetic "I don't know yet — just chat" entry → free-play.
580
+ store.update({ coursePack: null, mission: null, packTitle: null, missionTitle: null, missionIndex: null, missionTotal: null })
581
+ store.update({ screen: { kind: "mission" } })
582
+ return
583
+ }
556
584
  store.update({ coursePack: packId, mission: null })
557
585
  refreshContext()
558
586
  store.update({ screen: { kind: "mission" } })
@@ -62,6 +62,13 @@ export interface AppDeps {
62
62
  onSetupSkip: () => void
63
63
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
64
64
  onTourDone: () => void
65
+ /**
66
+ * Open the Airbotix Portal wallet/login page in the parent's default
67
+ * browser. Wired into [w] on StartupScreen and into the
68
+ * `stars_exhausted` ErrorScreen so parents can top up without
69
+ * remembering the URL.
70
+ */
71
+ onOpenWallet: () => void
65
72
  }
66
73
 
67
74
  export function App(deps: AppDeps): React.ReactElement {
@@ -98,7 +105,7 @@ export function App(deps: AppDeps): React.ReactElement {
98
105
  case "tour":
99
106
  return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
100
107
  case "startup":
101
- return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
108
+ return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} />
102
109
  case "mission":
103
110
  return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
104
111
  case "help":
@@ -132,8 +139,10 @@ export function App(deps: AppDeps): React.ReactElement {
132
139
  variant={state.screen.variant}
133
140
  detail={state.screen.detail}
134
141
  locale={deps.locale}
142
+ toast={state.toast}
135
143
  onRetry={deps.onErrorRetry}
136
144
  onReconfigure={RECONFIGURABLE_VARIANTS.has(state.screen.variant) ? deps.onReconfigure : undefined}
145
+ onOpenWallet={state.screen.variant === "stars_exhausted" ? deps.onOpenWallet : undefined}
137
146
  onQuit={deps.onQuit}
138
147
  />
139
148
  )
@@ -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,17 +23,30 @@ 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
51
  <Box flexDirection="column" borderStyle="round" borderColor={theme.warn} paddingX={2} paddingY={1}>
34
52
  <Text color={theme.warn} bold>{t.empty}</Text>
@@ -45,17 +63,20 @@ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPi
45
63
  <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1}>
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
  },
@@ -14,11 +14,13 @@ import React from "react"
14
14
  import { Box, Text, useInput } from "ink"
15
15
  import { getTheme } from "../theme.ts"
16
16
  import type { ErrorVariant } from "../../../core/store.ts"
17
+ import { Toast, type ToastState } from "../components/Toast.tsx"
17
18
 
18
19
  interface ErrorScreenProps {
19
20
  variant: ErrorVariant
20
21
  locale: "zh-Hans" | "en"
21
22
  detail?: string
23
+ toast?: ToastState | null
22
24
  onRetry?: () => void
23
25
  onQuit?: () => void
24
26
  /**
@@ -28,13 +30,20 @@ interface ErrorScreenProps {
28
30
  * alone won't fix a wrong key.
29
31
  */
30
32
  onReconfigure?: () => void
33
+ /**
34
+ * Open the Airbotix Portal wallet page in the parent's default browser.
35
+ * Wired only for `stars_exhausted` so retry-alone (which won't change the
36
+ * balance) is not the only option.
37
+ */
38
+ onOpenWallet?: () => void
31
39
  }
32
40
 
33
- export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconfigure }: ErrorScreenProps): React.ReactElement {
41
+ export function ErrorScreen({ variant, locale, detail, toast, onRetry, onQuit, onReconfigure, onOpenWallet }: ErrorScreenProps): React.ReactElement {
34
42
  const theme = getTheme()
35
43
  useInput((input, key) => {
36
44
  if (key.return && onRetry) onRetry()
37
45
  else if ((input === "c" || input === "C") && onReconfigure) onReconfigure()
46
+ else if ((input === "w" || input === "W") && onOpenWallet) onOpenWallet()
38
47
  else if (input === "q" && onQuit) onQuit()
39
48
  })
40
49
  const t = STRINGS[locale][variant]
@@ -66,6 +75,12 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconf
66
75
  <Text color={theme.fg}> {STRINGS[locale].reconfigure}</Text>
67
76
  </Box>
68
77
  )}
78
+ {onOpenWallet && (
79
+ <Box marginRight={2}>
80
+ <Text color={theme.accent}>[w]</Text>
81
+ <Text color={theme.fg}> {STRINGS[locale].topUp}</Text>
82
+ </Box>
83
+ )}
69
84
  {onQuit && (
70
85
  <Box>
71
86
  <Text color={theme.accent}>[q]</Text>
@@ -73,6 +88,11 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconf
73
88
  </Box>
74
89
  )}
75
90
  </Box>
91
+ {toast && (
92
+ <Box marginTop={1}>
93
+ <Toast toast={toast} />
94
+ </Box>
95
+ )}
76
96
  </Box>
77
97
  )
78
98
  }
@@ -81,6 +101,7 @@ const STRINGS = {
81
101
  "zh-Hans": {
82
102
  quit: "退出",
83
103
  reconfigure: "改设置(换 key / 换 provider)",
104
+ topUp: "去充值(开浏览器)",
84
105
  serve_unreachable: {
85
106
  title: "AI 老师还没起来",
86
107
  body: "后台 AI 服务好像没启动。要不要再试一次?",
@@ -98,7 +119,7 @@ const STRINGS = {
98
119
  },
99
120
  stars_exhausted: {
100
121
  title: "今天的 ⭐ 用完了",
101
- body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者找家长打开 airbotix.ai/portal/wallet 多充一点 ⭐,然后按 Enter 接着做。",
122
+ body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者按 [w] 让家长去充值,回来按 Enter 接着做。",
102
123
  retry: "找完家长了,再试一次",
103
124
  },
104
125
  auth_failed: {
@@ -120,6 +141,7 @@ const STRINGS = {
120
141
  en: {
121
142
  quit: "Quit",
122
143
  reconfigure: "Change settings (switch key / provider)",
144
+ topUp: "Top up (opens browser)",
123
145
  serve_unreachable: {
124
146
  title: "AI teacher didn't start",
125
147
  body: "The background AI service isn't running. Try again?",
@@ -137,7 +159,7 @@ const STRINGS = {
137
159
  },
138
160
  stars_exhausted: {
139
161
  title: "Out of ⭐ for today",
140
- body: "Great work today!\nWe'll pick this up tomorrow.\nOr ask a parent to top up at airbotix.ai/portal/wallet, then press Enter to keep going.",
162
+ body: "Great work today!\nWe'll pick this up tomorrow.\nOr press [w] so a parent can top up, then press Enter to keep going.",
141
163
  retry: "Asked a parent — try again",
142
164
  },
143
165
  auth_failed: {
@@ -2,9 +2,12 @@
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
10
+ * w → open Airbotix Portal wallet / login in the parent's browser
8
11
  * h → show kid-friendly help
9
12
  */
10
13
 
@@ -13,19 +16,24 @@ import { Box, Text, useInput } from "ink"
13
16
  import { getTheme } from "../theme.ts"
14
17
  import { KidsLogo } from "../components/KidsLogo.tsx"
15
18
  import { KeyHints } from "../components/KeyHints.tsx"
19
+ import { Toast, type ToastState } from "../components/Toast.tsx"
16
20
 
17
21
  interface StartupScreenProps {
18
22
  locale: "zh-Hans" | "en"
19
23
  coursePack: string | null
24
+ toast: ToastState | null
20
25
  onStart: (mode: "free" | "course" | "resume" | "help") => void
26
+ onOpenWallet: () => void
21
27
  }
22
28
 
23
- export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProps): React.ReactElement {
29
+ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet }: StartupScreenProps): React.ReactElement {
24
30
  const theme = getTheme()
25
31
  useInput((input, key) => {
26
- if (key.return) onStart(coursePack ? "course" : "free")
32
+ if (key.return) onStart("course")
27
33
  else if (input === "c") onStart("course")
34
+ else if (input === "f") onStart("free")
28
35
  else if (input === "r") onStart("resume")
36
+ else if (input === "w" || input === "W") onOpenWallet()
29
37
  else if (input === "h") onStart("help")
30
38
  })
31
39
  const t = STRINGS[locale]
@@ -48,12 +56,19 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
48
56
  </Box>
49
57
  <Box marginTop={2}>
50
58
  <KeyHints hints={[
51
- { key: "Enter", label: coursePack ? t.startCourse : t.startFree },
52
- { key: "c", label: t.pickCourse },
59
+ { key: "Enter", label: coursePack ? t.startCourse : t.pickCourse },
60
+ ...(coursePack ? [{ key: "c", label: t.pickCourse }] : []),
61
+ { key: "f", label: t.startFree },
53
62
  { key: "r", label: t.resume },
63
+ { key: "w", label: t.wallet },
54
64
  { key: "h", label: t.help },
55
65
  ]} />
56
66
  </Box>
67
+ {toast && (
68
+ <Box marginTop={1}>
69
+ <Toast toast={toast} />
70
+ </Box>
71
+ )}
57
72
  </Box>
58
73
  )
59
74
  }
@@ -70,6 +85,7 @@ const STRINGS = {
70
85
  startCourse: "继续 Course Pack",
71
86
  pickCourse: "选 Course Pack",
72
87
  resume: "继续上次",
88
+ wallet: "钱包 / 充值(开浏览器)",
73
89
  help: "帮助",
74
90
  },
75
91
  en: {
@@ -83,6 +99,7 @@ const STRINGS = {
83
99
  startCourse: "Continue Course Pack",
84
100
  pickCourse: "Pick a Course Pack",
85
101
  resume: "Resume last session",
102
+ wallet: "Wallet / Top up (opens browser)",
86
103
  help: "Help",
87
104
  },
88
105
  } as const