@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 +2 -2
- package/src/core/course-pack.ts +45 -21
- package/src/core/env.ts +13 -0
- package/src/core/wallet-link.ts +76 -0
- package/src/index.tsx +28 -0
- package/src/render/ink/App.tsx +10 -1
- package/src/render/ink/screens/CoursePackPicker.tsx +66 -17
- package/src/render/ink/screens/ErrorScreen.tsx +25 -3
- package/src/render/ink/screens/StartupScreen.tsx +23 -6
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.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.
|
|
37
|
+
"@kidsinai/kids-opencode-plugin": "^0.0.16"
|
|
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`. */
|
|
@@ -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" } })
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -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
|
-
* 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,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(
|
|
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
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
|
-
{
|
|
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
|
},
|
|
@@ -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
|
|
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
|
|
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 →
|
|
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
|
|
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(
|
|
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.
|
|
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
|