@kidsinai/kids-client 0.0.10 → 0.0.12

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.10",
4
+ "version": "0.0.12",
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",
@@ -13,10 +13,17 @@ export type OpencodeClient = ReturnType<typeof createOpencodeClient>
13
13
  export interface ConnectionOptions {
14
14
  baseUrl: string
15
15
  serverPassword: string
16
+ /**
17
+ * Must match upstream's `OPENCODE_SERVER_USERNAME` (default "opencode").
18
+ * Sending an empty username here yields a 401 against opencode ≥1.x —
19
+ * see opencode-kernel server/auth.ts `authorized()` which requires
20
+ * `credentials.username === config.username`.
21
+ */
22
+ serverUsername: string
16
23
  }
17
24
 
18
25
  export function createKidsClient(opts: ConnectionOptions): OpencodeClient {
19
- const authHeader = "Basic " + btoa(`:${opts.serverPassword}`)
26
+ const authHeader = buildAuthHeader(opts.serverUsername, opts.serverPassword)
20
27
  return createOpencodeClient({
21
28
  baseUrl: opts.baseUrl,
22
29
  headers: {
@@ -24,3 +31,13 @@ export function createKidsClient(opts: ConnectionOptions): OpencodeClient {
24
31
  },
25
32
  } as Parameters<typeof createOpencodeClient>[0])
26
33
  }
34
+
35
+ /**
36
+ * Construct the HTTP Basic-Auth header opencode serve expects.
37
+ * Exposed so serve-manager's probe + tests can share the exact format
38
+ * upstream `authorized()` checks against (username MUST match config,
39
+ * default "opencode" — empty username produces 401 on opencode ≥1.x).
40
+ */
41
+ export function buildAuthHeader(username: string, password: string): string {
42
+ return "Basic " + btoa(`${username}:${password}`)
43
+ }
package/src/core/env.ts CHANGED
@@ -14,6 +14,13 @@ export interface KidsClientEnv {
14
14
  opencodeBaseUrl: string
15
15
  /** HTTP Basic Auth password for serve. Mandatory. */
16
16
  opencodeServerPassword: string
17
+ /**
18
+ * HTTP Basic Auth *username* for serve. Defaults to "opencode" to match
19
+ * upstream's `OPENCODE_SERVER_USERNAME` default (opencode-kernel
20
+ * packages/opencode/src/server/auth.ts) — sending an empty username here
21
+ * causes a 401 against opencode ≥1.x even when the password is correct.
22
+ */
23
+ opencodeServerUsername: string
17
24
  /** DeepRouter tenant key. May be empty when using BYOK bypass. */
18
25
  deeprouterApiKey: string
19
26
  /** True if the wrapper set KIDS_LLM_BYPASS_GATEWAY=1 (BYOK dogfood mode). */
@@ -30,6 +37,12 @@ export interface KidsClientEnv {
30
37
  configDir: string
31
38
  /** When true, the client renders a "Tony banner" / suppresses interactive prompts (CI). */
32
39
  noBanner: boolean
40
+ /**
41
+ * Airbotix Portal base URL — used by the [w] Wallet / Top-up shortcut to
42
+ * deep-link parents into login + Airwallex top-up. Defaults to
43
+ * https://app.airbotix.ai; staging overrides via AIRBOTIX_PORTAL_URL.
44
+ */
45
+ portalBaseUrl: string
33
46
  }
34
47
 
35
48
  export function readEnv(): KidsClientEnv {
@@ -39,6 +52,7 @@ export function readEnv(): KidsClientEnv {
39
52
  return {
40
53
  opencodeBaseUrl: process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096",
41
54
  opencodeServerPassword: password,
55
+ opencodeServerUsername: process.env.OPENCODE_SERVER_USERNAME || "opencode",
42
56
  deeprouterApiKey: process.env.DEEPROUTER_API_KEY ?? "",
43
57
  bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
44
58
  coursePack: process.env.KIDS_COURSE_PACK || null,
@@ -47,6 +61,7 @@ export function readEnv(): KidsClientEnv {
47
61
  opencodeBin: process.env.OPENCODE_BIN ?? "opencode",
48
62
  configDir: process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"),
49
63
  noBanner: process.env.KIDS_OPENCODE_NO_BANNER === "1",
64
+ portalBaseUrl: process.env.AIRBOTIX_PORTAL_URL || "https://app.airbotix.ai",
50
65
  }
51
66
  }
52
67
 
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { spawn, type Subprocess } from "bun"
13
+ import { buildAuthHeader } from "./connection.ts"
13
14
 
14
15
  export type ServeReadiness =
15
16
  | { kind: "already_running" }
@@ -32,6 +33,13 @@ export type ProbeResult = "ok" | "auth_mismatch" | "offline"
32
33
  export interface ServeManagerOptions {
33
34
  baseUrl: string
34
35
  serverPassword: string
36
+ /**
37
+ * Must match upstream's `OPENCODE_SERVER_USERNAME` (default "opencode").
38
+ * The probe sends this as the Basic-auth username — upstream's
39
+ * authorized() requires an exact match, so an empty username produces
40
+ * a 401 even when the password is correct.
41
+ */
42
+ serverUsername: string
35
43
  opencodeBin: string
36
44
  /** Max wait for readiness probe in ms. Default 10s. */
37
45
  readyTimeoutMs?: number
@@ -75,6 +83,7 @@ export class ServeManager {
75
83
  env: {
76
84
  ...process.env,
77
85
  OPENCODE_SERVER_PASSWORD: this.opts.serverPassword,
86
+ OPENCODE_SERVER_USERNAME: this.opts.serverUsername,
78
87
  },
79
88
  stdout: "pipe",
80
89
  stderr: "pipe",
@@ -121,7 +130,7 @@ export class ServeManager {
121
130
  try {
122
131
  const res = await fetch(`${this.opts.baseUrl}/app`, {
123
132
  headers: {
124
- authorization: "Basic " + btoa(`:${this.opts.serverPassword}`),
133
+ authorization: buildAuthHeader(this.opts.serverUsername, this.opts.serverPassword),
125
134
  },
126
135
  })
127
136
  return classifyProbeStatus(res.status)
@@ -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
@@ -37,6 +37,7 @@ import { reloadEnvFile } from "./core/env-reload.ts"
37
37
  import { hasSeenTour, markTourSeen } from "./core/tour-marker.ts"
38
38
  import type { InstalledPack } from "./core/course-pack.ts"
39
39
  import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
40
+ import { buildWalletUrl, getOrCreateDeviceId, openInBrowser } from "./core/wallet-link.ts"
40
41
 
41
42
  interface ServiceSet {
42
43
  audit: AuditPipeline
@@ -64,6 +65,7 @@ interface AppHandlers {
64
65
  onPermissionReply: (decision: "allow" | "deny" | "edit") => void
65
66
  onDangerousAcknowledge: () => void
66
67
  onErrorRetry: () => void | Promise<void>
68
+ onReconfigure: () => void
67
69
  onQuit: () => void | Promise<void>
68
70
  onAbort: () => void
69
71
  onHelpBack: () => void
@@ -76,6 +78,7 @@ interface AppHandlers {
76
78
  onSetupSkip: () => void
77
79
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
78
80
  onTourDone: () => void
81
+ onOpenWallet: () => void
79
82
  }
80
83
 
81
84
  async function main(): Promise<void> {
@@ -189,6 +192,12 @@ function makeHandlers(
189
192
  // Pre-boot error retry: re-run main isn't trivial; just exit.
190
193
  process.exit(1)
191
194
  },
195
+ onReconfigure: () => {
196
+ // From an error screen, jump into the setup wizard so the parent can
197
+ // change provider / paste a new API key. onSetupContinue knows whether
198
+ // we're in first-run (resolve gate) or post-boot (reload env + retry).
199
+ store.update({ screen: { kind: "setup" } })
200
+ },
192
201
  onQuit: async () => {
193
202
  const s = servicesHolder.current
194
203
  if (s) return s.quit()
@@ -211,6 +220,16 @@ function makeHandlers(
211
220
  onSetupContinue: async () => {
212
221
  const r = getResolveSetup()
213
222
  if (r) r()
223
+ // Post-boot reconfigure path: services are already up but the env they
224
+ // were booted with is stale. Re-source the env file (the wizard wrote
225
+ // it) and replay the same recovery as [Enter] Retry on the error
226
+ // screen — push the loading screen and re-run readiness probe.
227
+ const s = servicesHolder.current
228
+ if (s) {
229
+ reloadEnvFile(env.configDir)
230
+ Object.assign(env, readEnv())
231
+ await s.handlers.onErrorRetry()
232
+ }
214
233
  },
215
234
  onSetupSkip: () => {
216
235
  const r = getResolveSetup()
@@ -231,6 +250,25 @@ function makeHandlers(
231
250
  const r = getResolveTour()
232
251
  if (r) r()
233
252
  },
253
+ onOpenWallet: () => {
254
+ const deviceId = getOrCreateDeviceId(env.configDir)
255
+ const url = buildWalletUrl({
256
+ portalBaseUrl: env.portalBaseUrl,
257
+ deviceId,
258
+ locale: env.locale,
259
+ })
260
+ const result = openInBrowser(url)
261
+ const okText = env.locale === "zh-Hans"
262
+ ? `已在浏览器打开:${url}`
263
+ : `Opened in your browser: ${url}`
264
+ const failText = env.locale === "zh-Hans"
265
+ ? `没办法自动开浏览器。请手动打开:${url}`
266
+ : `Couldn't auto-open the browser. Open manually: ${url}`
267
+ flashToast(store, {
268
+ kind: result.ok ? "success" : "warn",
269
+ text: result.ok ? okText : failText,
270
+ })
271
+ },
234
272
  }
235
273
  }
236
274
 
@@ -245,6 +283,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
245
283
  const serve = new ServeManager({
246
284
  baseUrl: env.opencodeBaseUrl,
247
285
  serverPassword: env.opencodeServerPassword,
286
+ serverUsername: env.opencodeServerUsername,
248
287
  opencodeBin: env.opencodeBin,
249
288
  onAuditLine: (event) => {
250
289
  audit.push(event)
@@ -282,6 +321,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
282
321
  const client = createKidsClient({
283
322
  baseUrl: env.opencodeBaseUrl,
284
323
  serverPassword: env.opencodeServerPassword,
324
+ serverUsername: env.opencodeServerUsername,
285
325
  })
286
326
  const session = new SessionManager(client)
287
327
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  import React, { useSyncExternalStore } from "react"
13
13
  import type { InstalledPack } from "../../core/course-pack.ts"
14
- import type { Store } from "../../core/store.ts"
14
+ import type { ErrorVariant, Store } from "../../core/store.ts"
15
15
  import { StartupScreen } from "./screens/StartupScreen.tsx"
16
16
  import { MissionScreen } from "./screens/MissionScreen.tsx"
17
17
  import { PermissionModal } from "./screens/PermissionModal.tsx"
@@ -25,6 +25,17 @@ import { SetupScreen } from "./screens/SetupScreen.tsx"
25
25
  import { TourScreen } from "./screens/TourScreen.tsx"
26
26
  import type { ProviderId } from "../../core/setup.ts"
27
27
 
28
+ // Variants where the root cause may be a stale / wrong API key or a missing
29
+ // provider config — showing a [c] Change settings option that opens the setup
30
+ // wizard actually helps. Pure runtime/network problems (network_down,
31
+ // stars_exhausted, ai_hung) are excluded; they need a different recovery.
32
+ const RECONFIGURABLE_VARIANTS: ReadonlySet<ErrorVariant> = new Set([
33
+ "serve_unreachable",
34
+ "port_taken",
35
+ "auth_failed",
36
+ "config_missing",
37
+ ])
38
+
28
39
  export interface AppDeps {
29
40
  store: Store
30
41
  locale: "zh-Hans" | "en"
@@ -34,6 +45,11 @@ export interface AppDeps {
34
45
  onPermissionReply: (decision: "allow" | "deny" | "edit") => void
35
46
  onDangerousAcknowledge: () => void
36
47
  onErrorRetry: () => void
48
+ /**
49
+ * Jump from the error screen into the setup wizard. Only wired for
50
+ * config-related variants — see RECONFIGURABLE_VARIANTS below.
51
+ */
52
+ onReconfigure: () => void
37
53
  onQuit: () => void
38
54
  onAbort: () => void
39
55
  onHelpBack: () => void
@@ -46,6 +62,13 @@ export interface AppDeps {
46
62
  onSetupSkip: () => void
47
63
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
48
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
49
72
  }
50
73
 
51
74
  export function App(deps: AppDeps): React.ReactElement {
@@ -82,7 +105,7 @@ export function App(deps: AppDeps): React.ReactElement {
82
105
  case "tour":
83
106
  return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
84
107
  case "startup":
85
- 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} />
86
109
  case "mission":
87
110
  return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
88
111
  case "help":
@@ -116,7 +139,10 @@ export function App(deps: AppDeps): React.ReactElement {
116
139
  variant={state.screen.variant}
117
140
  detail={state.screen.detail}
118
141
  locale={deps.locale}
142
+ toast={state.toast}
119
143
  onRetry={deps.onErrorRetry}
144
+ onReconfigure={RECONFIGURABLE_VARIANTS.has(state.screen.variant) ? deps.onReconfigure : undefined}
145
+ onOpenWallet={state.screen.variant === "stars_exhausted" ? deps.onOpenWallet : undefined}
120
146
  onQuit={deps.onQuit}
121
147
  />
122
148
  )
@@ -14,19 +14,36 @@ 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
26
+ /**
27
+ * Open the setup wizard so the parent can change provider / paste a new
28
+ * API key. Wired by AppDeps only for config-related variants
29
+ * (serve_unreachable / port_taken / auth_failed / config_missing) — retry
30
+ * alone won't fix a wrong key.
31
+ */
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
24
39
  }
25
40
 
26
- export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorScreenProps): React.ReactElement {
41
+ export function ErrorScreen({ variant, locale, detail, toast, onRetry, onQuit, onReconfigure, onOpenWallet }: ErrorScreenProps): React.ReactElement {
27
42
  const theme = getTheme()
28
43
  useInput((input, key) => {
29
44
  if (key.return && onRetry) onRetry()
45
+ else if ((input === "c" || input === "C") && onReconfigure) onReconfigure()
46
+ else if ((input === "w" || input === "W") && onOpenWallet) onOpenWallet()
30
47
  else if (input === "q" && onQuit) onQuit()
31
48
  })
32
49
  const t = STRINGS[locale][variant]
@@ -52,6 +69,18 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
52
69
  <Text color={theme.fg}> {t.retry}</Text>
53
70
  </Box>
54
71
  )}
72
+ {onReconfigure && (
73
+ <Box marginRight={2}>
74
+ <Text color={theme.accent}>[c]</Text>
75
+ <Text color={theme.fg}> {STRINGS[locale].reconfigure}</Text>
76
+ </Box>
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
+ )}
55
84
  {onQuit && (
56
85
  <Box>
57
86
  <Text color={theme.accent}>[q]</Text>
@@ -59,6 +88,11 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
59
88
  </Box>
60
89
  )}
61
90
  </Box>
91
+ {toast && (
92
+ <Box marginTop={1}>
93
+ <Toast toast={toast} />
94
+ </Box>
95
+ )}
62
96
  </Box>
63
97
  )
64
98
  }
@@ -66,6 +100,8 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
66
100
  const STRINGS = {
67
101
  "zh-Hans": {
68
102
  quit: "退出",
103
+ reconfigure: "改设置(换 key / 换 provider)",
104
+ topUp: "去充值(开浏览器)",
69
105
  serve_unreachable: {
70
106
  title: "AI 老师还没起来",
71
107
  body: "后台 AI 服务好像没启动。要不要再试一次?",
@@ -83,7 +119,7 @@ const STRINGS = {
83
119
  },
84
120
  stars_exhausted: {
85
121
  title: "今天的 ⭐ 用完了",
86
- body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者找家长打开 airbotix.ai/portal/wallet 多充一点 ⭐,然后按 Enter 接着做。",
122
+ body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者按 [w] 让家长去充值,回来按 Enter 接着做。",
87
123
  retry: "找完家长了,再试一次",
88
124
  },
89
125
  auth_failed: {
@@ -104,6 +140,8 @@ const STRINGS = {
104
140
  },
105
141
  en: {
106
142
  quit: "Quit",
143
+ reconfigure: "Change settings (switch key / provider)",
144
+ topUp: "Top up (opens browser)",
107
145
  serve_unreachable: {
108
146
  title: "AI teacher didn't start",
109
147
  body: "The background AI service isn't running. Try again?",
@@ -121,7 +159,7 @@ const STRINGS = {
121
159
  },
122
160
  stars_exhausted: {
123
161
  title: "Out of ⭐ for today",
124
- 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.",
125
163
  retry: "Asked a parent — try again",
126
164
  },
127
165
  auth_failed: {
@@ -5,6 +5,7 @@
5
5
  * Enter → start a free-play session OR continue if a course pack is set
6
6
  * c → choose a Course Pack
7
7
  * r → resume the last session
8
+ * w → open Airbotix Portal wallet / login in the parent's browser
8
9
  * h → show kid-friendly help
9
10
  */
10
11
 
@@ -13,19 +14,23 @@ import { Box, Text, useInput } from "ink"
13
14
  import { getTheme } from "../theme.ts"
14
15
  import { KidsLogo } from "../components/KidsLogo.tsx"
15
16
  import { KeyHints } from "../components/KeyHints.tsx"
17
+ import { Toast, type ToastState } from "../components/Toast.tsx"
16
18
 
17
19
  interface StartupScreenProps {
18
20
  locale: "zh-Hans" | "en"
19
21
  coursePack: string | null
22
+ toast: ToastState | null
20
23
  onStart: (mode: "free" | "course" | "resume" | "help") => void
24
+ onOpenWallet: () => void
21
25
  }
22
26
 
23
- export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProps): React.ReactElement {
27
+ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet }: StartupScreenProps): React.ReactElement {
24
28
  const theme = getTheme()
25
29
  useInput((input, key) => {
26
30
  if (key.return) onStart(coursePack ? "course" : "free")
27
31
  else if (input === "c") onStart("course")
28
32
  else if (input === "r") onStart("resume")
33
+ else if (input === "w" || input === "W") onOpenWallet()
29
34
  else if (input === "h") onStart("help")
30
35
  })
31
36
  const t = STRINGS[locale]
@@ -51,9 +56,15 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
51
56
  { key: "Enter", label: coursePack ? t.startCourse : t.startFree },
52
57
  { key: "c", label: t.pickCourse },
53
58
  { key: "r", label: t.resume },
59
+ { key: "w", label: t.wallet },
54
60
  { key: "h", label: t.help },
55
61
  ]} />
56
62
  </Box>
63
+ {toast && (
64
+ <Box marginTop={1}>
65
+ <Toast toast={toast} />
66
+ </Box>
67
+ )}
57
68
  </Box>
58
69
  )
59
70
  }
@@ -70,6 +81,7 @@ const STRINGS = {
70
81
  startCourse: "继续 Course Pack",
71
82
  pickCourse: "选 Course Pack",
72
83
  resume: "继续上次",
84
+ wallet: "钱包 / 充值(开浏览器)",
73
85
  help: "帮助",
74
86
  },
75
87
  en: {
@@ -83,6 +95,7 @@ const STRINGS = {
83
95
  startCourse: "Continue Course Pack",
84
96
  pickCourse: "Pick a Course Pack",
85
97
  resume: "Resume last session",
98
+ wallet: "Wallet / Top up (opens browser)",
86
99
  help: "Help",
87
100
  },
88
101
  } as const