@kidsinai/kids-client 0.0.5 → 0.0.7

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.5",
4
+ "version": "0.0.7",
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",
package/src/core/env.ts CHANGED
@@ -60,14 +60,17 @@ export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; rea
60
60
  }
61
61
  // Accept any supported provider's API key, not just DeepRouter. The
62
62
  // setup wizard writes whatever the parent picked into ~/.config/kids-opencode/env
63
- // which the wrapper sources before exec. KIDS_OAUTH_PROVIDER marks an
64
- // OAuth flow that opencode handles via its own auth.json store —
65
- // we trust opencode to gate on actual token validity at serve time.
63
+ // which the wrapper sources before exec.
64
+ //
65
+ // KIDS_OAUTH_PROVIDER was previously trusted as a credential signal
66
+ // that was wrong: upstream opencode dropped Anthropic Pro/Max OAuth in
67
+ // 1.3.0, so the marker existed without a real credential and opencode
68
+ // silently fell back to its own api-key picker. Users now get re-routed
69
+ // through SetupScreen until they paste an actual API key.
66
70
  const hasAnyKey =
67
71
  env.deeprouterApiKey
68
72
  || process.env.ANTHROPIC_API_KEY
69
73
  || process.env.OPENAI_API_KEY
70
- || process.env.KIDS_OAUTH_PROVIDER
71
74
  if (!env.bypassGateway && !hasAnyKey) {
72
75
  return {
73
76
  ok: false,
package/src/core/setup.ts CHANGED
@@ -24,8 +24,23 @@ export type ProviderId = "anthropic" | "openai" | "deeprouter"
24
24
  */
25
25
  export const OAUTH_HANDOFF_EXIT_CODE = 123
26
26
 
27
- /** Providers that support OAuth login via the upstream opencode kernel. */
28
- export const OAUTH_PROVIDERS: ReadonlyArray<ProviderId> = ["anthropic"] as const
27
+ /**
28
+ * Providers for which upstream opencode currently ships an OAuth/subscription
29
+ * login method. Anthropic Pro/Max was REMOVED in opencode 1.3.0 — Anthropic's
30
+ * ToS prohibits using consumer Claude Pro/Max subscriptions through coding
31
+ * agents like opencode. See:
32
+ * ~/Documents/sites/kidsinai/opencode-kernel/packages/web/src/content/docs/providers.mdx
33
+ *
34
+ * OpenAI ChatGPT Plus IS supported by upstream and is the obvious next
35
+ * candidate to wire here — but until that's done, no provider triggers the
36
+ * auth_choice step and every kid lands directly on the api-key screen.
37
+ *
38
+ * The OAuth handoff infrastructure below (OAUTH_HANDOFF_EXIT_CODE,
39
+ * saveSetupOauth, the wrapper's exit-123 loop, the auth_choice screen) is
40
+ * intentionally kept inert rather than deleted — flipping OPENAI back on
41
+ * here when ChatGPT Plus is wired is a one-line revival.
42
+ */
43
+ export const OAUTH_PROVIDERS: ReadonlyArray<ProviderId> = [] as const
29
44
 
30
45
  export interface ProviderChoice {
31
46
  id: ProviderId
@@ -173,16 +188,16 @@ function writeOpencodeConfig(
173
188
  export function hasAnyProviderKey(configDir: string): boolean {
174
189
  const env = readEnvFile(join(configDir, "env"))
175
190
  if (env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.DEEPROUTER_API_KEY) return true
176
- // OAuth handoff completed earlier? The marker means opencode has its own
177
- // auth.json credentials; opencode itself will gate on actual token validity.
178
- if (env.KIDS_OAUTH_PROVIDER) return true
179
191
  // Also accept keys present in the parent shell env (advanced users).
180
192
  return !!(
181
193
  process.env.ANTHROPIC_API_KEY
182
194
  || process.env.OPENAI_API_KEY
183
195
  || process.env.DEEPROUTER_API_KEY
184
- || process.env.KIDS_OAUTH_PROVIDER
185
196
  )
197
+ // Note: KIDS_OAUTH_PROVIDER marker is intentionally NOT accepted anymore.
198
+ // Users who got stuck with the marker but no actual OAuth credential
199
+ // (because opencode never had an Anthropic Pro/Max method to begin with)
200
+ // will be routed back through SetupScreen to pick an API-key flow.
186
201
  }
187
202
 
188
203
  /** Crude check of API key shape — refuses obvious typos. */
package/src/core/store.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  export type Screen =
11
11
  | { kind: "loading"; message?: string }
12
12
  | { kind: "setup" }
13
+ | { kind: "tour" }
13
14
  | { kind: "startup" }
14
15
  | { kind: "mission" }
15
16
  | { kind: "help" }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * First-run welcome-tour marker. File existence at
3
+ * `<configDir>/tour-seen` means the kid has been through the intro at
4
+ * least once; the next launch goes straight to StartupScreen.
5
+ *
6
+ * The tour only fires when (a) marker absent AND (b) the SetupScreen
7
+ * just ran. Returning users who inherited env vars skip it.
8
+ */
9
+
10
+ import { existsSync, writeFileSync, chmodSync } from "node:fs"
11
+ import { join } from "node:path"
12
+
13
+ const FILE_NAME = "tour-seen"
14
+
15
+ export function hasSeenTour(configDir: string): boolean {
16
+ return existsSync(join(configDir, FILE_NAME))
17
+ }
18
+
19
+ export function markTourSeen(configDir: string): void {
20
+ const path = join(configDir, FILE_NAME)
21
+ try {
22
+ writeFileSync(path, new Date().toISOString() + "\n", "utf8")
23
+ chmodSync(path, 0o600)
24
+ } catch {
25
+ /* non-fatal — re-show is a feature, not a bug */
26
+ }
27
+ }
package/src/index.tsx CHANGED
@@ -34,6 +34,7 @@ import { App } from "./render/ink/App.tsx"
34
34
  import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
35
35
  import { OAUTH_HANDOFF_EXIT_CODE, saveSetup, saveSetupOauth, type ProviderId } from "./core/setup.ts"
36
36
  import { reloadEnvFile } from "./core/env-reload.ts"
37
+ import { hasSeenTour, markTourSeen } from "./core/tour-marker.ts"
37
38
  import type { InstalledPack } from "./core/course-pack.ts"
38
39
  import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
39
40
 
@@ -74,6 +75,7 @@ interface AppHandlers {
74
75
  onSetupContinue: () => Promise<void>
75
76
  onSetupSkip: () => void
76
77
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
78
+ onTourDone: () => void
77
79
  }
78
80
 
79
81
  async function main(): Promise<void> {
@@ -98,17 +100,24 @@ async function main(): Promise<void> {
98
100
  let resolveSetup: (() => void) | null = null
99
101
  const setupGate = new Promise<void>((r) => { resolveSetup = r })
100
102
 
103
+ // Same pattern for the first-run welcome tour. Only awaited if we actually
104
+ // route to it (returning users with valid env skip both setup and tour).
105
+ let resolveTour: (() => void) | null = null
106
+ const tourGate = new Promise<void>((r) => { resolveTour = r })
107
+
101
108
  const handlers: AppHandlers = makeHandlers(store, env, servicesHolder, resolveSetupFn => {
102
109
  resolveSetup = resolveSetupFn
103
- }, () => resolveSetup)
110
+ }, () => resolveSetup, () => resolveTour)
104
111
 
105
112
  renderApp(store, env, installedPacks, handlers)
106
113
 
107
114
  // First validation pass.
108
115
  let check = validateEnv(env)
116
+ let didSetup = false
109
117
  if (!check.ok && check.variant === "needs_setup") {
110
118
  store.update({ screen: { kind: "setup" } })
111
119
  await setupGate
120
+ didSetup = true
112
121
 
113
122
  // Re-source env file (the setup wizard wrote it).
114
123
  reloadEnvFile(env.configDir)
@@ -122,6 +131,14 @@ async function main(): Promise<void> {
122
131
  return
123
132
  }
124
133
 
134
+ // First-run tour: only fires if the kid just went through setup AND hasn't
135
+ // seen the tour. Returning users with inherited env vars skip it entirely.
136
+ if (didSetup && !hasSeenTour(env.configDir)) {
137
+ store.update({ screen: { kind: "tour" } })
138
+ await tourGate
139
+ markTourSeen(env.configDir)
140
+ }
141
+
125
142
  // Bootstrap services in-process. Loading screen is shown while we wait.
126
143
  store.update({
127
144
  screen: {
@@ -153,6 +170,7 @@ function makeHandlers(
153
170
  servicesHolder: { current: ServiceSet | null },
154
171
  _setResolveSetup: (fn: (() => void) | null) => void,
155
172
  getResolveSetup: () => (() => void) | null,
173
+ getResolveTour: () => (() => void) | null,
156
174
  ): AppHandlers {
157
175
  const ifBooted = <A extends unknown[]>(fn: (s: ServiceSet, ...args: A) => unknown) => (...args: A) => {
158
176
  const s = servicesHolder.current
@@ -209,6 +227,10 @@ function makeHandlers(
209
227
  // `opencode auth login --provider <p>` interactively, then re-exec us.
210
228
  process.exit(OAUTH_HANDOFF_EXIT_CODE)
211
229
  },
230
+ onTourDone: () => {
231
+ const r = getResolveTour()
232
+ if (r) r()
233
+ },
212
234
  }
213
235
  }
214
236
 
@@ -286,7 +308,8 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
286
308
  })
287
309
  },
288
310
  onLlmError: (e) => {
289
- store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: e.message } })
311
+ const variant = classifyLlmError(e.message)
312
+ store.update({ thinking: false, screen: { kind: "error", variant, detail: e.message } })
290
313
  },
291
314
  onCompactionEnded: () => {
292
315
  flashToast(store, {
@@ -560,7 +583,38 @@ function handlePluginAudit(event: unknown, store: Store): void {
560
583
  const snap = store.getSnapshot()
561
584
  const newBalance = Math.max(0, snap.starsBalance - e.stars_charged)
562
585
  store.update({ starsBalance: newBalance })
586
+ // Preemptive switch the moment the balance lands at zero — kid sees the
587
+ // friendly "out of stars" screen before the next tool call fails server-side.
588
+ if (newBalance === 0 && snap.starsBudget > 0 && snap.screen.kind === "mission") {
589
+ store.update({
590
+ screen: { kind: "error", variant: "stars_exhausted" },
591
+ thinking: false,
592
+ })
593
+ }
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Pattern-match the LLM/plugin error message against known billing failures
599
+ * so the kid lands on the friendly "out of stars" screen instead of the
600
+ * generic "network down" one. The plugin / DeepRouter wraps these with
601
+ * codes like WALLET_INSUFFICIENT / FAMILY_PAUSED (platform-backend §7) or
602
+ * plain English ("insufficient credits", "rate limit", "402").
603
+ */
604
+ function classifyLlmError(msg: string): "stars_exhausted" | "network_down" {
605
+ const m = msg.toLowerCase()
606
+ if (
607
+ m.includes("wallet_insufficient")
608
+ || m.includes("family_paused")
609
+ || m.includes("insufficient")
610
+ || m.includes("out of credit")
611
+ || m.includes("out of stars")
612
+ || m.includes("quota")
613
+ || m.includes("402")
614
+ ) {
615
+ return "stars_exhausted"
563
616
  }
617
+ return "network_down"
564
618
  }
565
619
 
566
620
  const TOAST_TTL_MS = 3500
@@ -22,6 +22,7 @@ import { CoursePackPicker } from "./screens/CoursePackPicker.tsx"
22
22
  import { MissionCompleteScreen } from "./screens/MissionCompleteScreen.tsx"
23
23
  import { LoadingScreen } from "./screens/LoadingScreen.tsx"
24
24
  import { SetupScreen } from "./screens/SetupScreen.tsx"
25
+ import { TourScreen } from "./screens/TourScreen.tsx"
25
26
  import type { ProviderId } from "../../core/setup.ts"
26
27
 
27
28
  export interface AppDeps {
@@ -44,6 +45,7 @@ export interface AppDeps {
44
45
  onSetupContinue: () => Promise<void>
45
46
  onSetupSkip: () => void
46
47
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
48
+ onTourDone: () => void
47
49
  }
48
50
 
49
51
  export function App(deps: AppDeps): React.ReactElement {
@@ -77,6 +79,8 @@ export function App(deps: AppDeps): React.ReactElement {
77
79
  return <LoadingScreen locale={deps.locale} message={state.screen.message} />
78
80
  case "setup":
79
81
  return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onContinue={deps.onSetupContinue} onSkip={deps.onSetupSkip} onOAuthHandoff={deps.onSetupOAuthHandoff} />
82
+ case "tour":
83
+ return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
80
84
  case "startup":
81
85
  return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
82
86
  case "mission":
@@ -78,8 +78,8 @@ const STRINGS = {
78
78
  },
79
79
  stars_exhausted: {
80
80
  title: "今天的 ⭐ 用完了",
81
- body: "明天再来,或者请家长在 airbotix.ai/portal/wallet 充值。",
82
- retry: "重试",
81
+ body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者找家长打开 airbotix.ai/portal/wallet 多充一点 ⭐,然后按 Enter 接着做。",
82
+ retry: "找完家长了,再试一次",
83
83
  },
84
84
  auth_failed: {
85
85
  title: "AI 老师认不出你",
@@ -111,8 +111,8 @@ const STRINGS = {
111
111
  },
112
112
  stars_exhausted: {
113
113
  title: "Out of ⭐ for today",
114
- body: "Come back tomorrow, or ask a parent to top up at airbotix.ai/portal/wallet.",
115
- retry: "Retry",
114
+ 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.",
115
+ retry: "Asked a parent — try again",
116
116
  },
117
117
  auth_failed: {
118
118
  title: "AI doesn't recognise you",
@@ -7,6 +7,11 @@
7
7
  * e → close modal, send a fresh kid prompt of the form "no, do it differently"
8
8
  *
9
9
  * The PRD uses "y/n/e" to avoid the engineering-vibes of allow/deny.
10
+ *
11
+ * For write/edit tools we now render a short preview of the exact file
12
+ * change the kid is about to approve. Black-box `y` was a trust-without-
13
+ * verify hole — this closes it AND doubles as a learning surface (the kid
14
+ * sees what code the AI is producing).
10
15
  */
11
16
 
12
17
  import React from "react"
@@ -22,6 +27,8 @@ interface PermissionModalProps {
22
27
  onEdit: () => void
23
28
  }
24
29
 
30
+ const DIFF_MAX_LINES = 14
31
+
25
32
  export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }: PermissionModalProps): React.ReactElement {
26
33
  const theme = getTheme()
27
34
  useInput((input) => {
@@ -31,6 +38,7 @@ export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }:
31
38
  else if (ch === "e") onEdit()
32
39
  })
33
40
  const t = STRINGS[locale]
41
+ const preview = extractFilePreview(permission)
34
42
  return (
35
43
  <Box flexDirection="column" borderStyle="double" borderColor={theme.warn} paddingX={2} paddingY={1}>
36
44
  <Text color={theme.warn} bold>{t.title}</Text>
@@ -42,6 +50,22 @@ export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }:
42
50
  <Text color={theme.fgDim}>tool: {permission.tool}</Text>
43
51
  </Box>
44
52
  )}
53
+ {preview && (
54
+ <Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={theme.fgDim} paddingX={1}>
55
+ <Box>
56
+ <Text color={theme.accent} bold>{preview.kind === "edit" ? t.editingFile : t.writingFile}: </Text>
57
+ <Text color={theme.fg}>{preview.path}</Text>
58
+ </Box>
59
+ <Box marginTop={1} flexDirection="column">
60
+ {renderDiffLines(preview, theme)}
61
+ </Box>
62
+ {preview.truncated > 0 && (
63
+ <Box marginTop={1}>
64
+ <Text color={theme.fgDim} dimColor>{t.truncated(preview.truncated)}</Text>
65
+ </Box>
66
+ )}
67
+ </Box>
68
+ )}
45
69
  {permission.starsEstimated && permission.starsEstimated > 0 && (
46
70
  <Box marginTop={1}>
47
71
  <Text color={theme.stars}>{t.starsCost(permission.starsEstimated)}</Text>
@@ -59,6 +83,82 @@ export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }:
59
83
  )
60
84
  }
61
85
 
86
+ type FilePreview =
87
+ | {
88
+ kind: "write"
89
+ path: string
90
+ lines: string[]
91
+ truncated: number
92
+ }
93
+ | {
94
+ kind: "edit"
95
+ path: string
96
+ removed: string[]
97
+ added: string[]
98
+ truncated: number
99
+ }
100
+
101
+ /**
102
+ * opencode write tool metadata: { filePath/path, content }
103
+ * opencode edit tool metadata: { filePath/path, oldString/old_text, newString/new_text }
104
+ * Defensive shape extraction — the SDK exposes these as Record<string, unknown>.
105
+ */
106
+ function extractFilePreview(permission: PendingPermission): FilePreview | null {
107
+ if (!permission.tool) return null
108
+ const tool = permission.tool.toLowerCase()
109
+ const m = permission.metadata ?? {}
110
+ const path = pickString(m, ["filePath", "path", "file_path", "filename"])
111
+ if (!path) return null
112
+
113
+ if (tool === "write") {
114
+ const content = pickString(m, ["content", "text", "newContent", "new_content"])
115
+ if (content === null) return null
116
+ const all = content.split("\n")
117
+ const shown = all.slice(0, DIFF_MAX_LINES)
118
+ return { kind: "write", path, lines: shown, truncated: Math.max(0, all.length - shown.length) }
119
+ }
120
+
121
+ if (tool === "edit") {
122
+ const oldStr = pickString(m, ["oldString", "old_string", "oldText", "old_text"]) ?? ""
123
+ const newStr = pickString(m, ["newString", "new_string", "newText", "new_text"]) ?? ""
124
+ const removed = oldStr.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""))
125
+ const added = newStr.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""))
126
+ const totalLines = removed.length + added.length
127
+ if (totalLines === 0) return null
128
+ const half = Math.max(1, Math.floor(DIFF_MAX_LINES / 2))
129
+ const removedShown = removed.slice(0, half)
130
+ const addedShown = added.slice(0, DIFF_MAX_LINES - removedShown.length)
131
+ const truncated = (removed.length - removedShown.length) + (added.length - addedShown.length)
132
+ return { kind: "edit", path, removed: removedShown, added: addedShown, truncated }
133
+ }
134
+
135
+ return null
136
+ }
137
+
138
+ function renderDiffLines(preview: FilePreview, theme: ReturnType<typeof getTheme>): React.ReactElement[] {
139
+ if (preview.kind === "write") {
140
+ return preview.lines.map((line, i) => (
141
+ <Text key={`w-${i}`} color={theme.success}>{`+ ${line}`}</Text>
142
+ ))
143
+ }
144
+ const out: React.ReactElement[] = []
145
+ preview.removed.forEach((line, i) => out.push(
146
+ <Text key={`r-${i}`} color={theme.danger}>{`- ${line}`}</Text>
147
+ ))
148
+ preview.added.forEach((line, i) => out.push(
149
+ <Text key={`a-${i}`} color={theme.success}>{`+ ${line}`}</Text>
150
+ ))
151
+ return out
152
+ }
153
+
154
+ function pickString(obj: Record<string, unknown>, keys: string[]): string | null {
155
+ for (const k of keys) {
156
+ const v = obj[k]
157
+ if (typeof v === "string") return v
158
+ }
159
+ return null
160
+ }
161
+
62
162
  const STRINGS = {
63
163
  "zh-Hans": {
64
164
  title: "AI 想做这件事",
@@ -66,6 +166,9 @@ const STRINGS = {
66
166
  no: "不要",
67
167
  edit: "我来改",
68
168
  starsCost: (n: number) => `预估消耗 ${n}⭐`,
169
+ writingFile: "新建文件",
170
+ editingFile: "改这个文件",
171
+ truncated: (n: number) => `… 还有 ${n} 行没显示。完整改动会在文件里。`,
69
172
  },
70
173
  en: {
71
174
  title: "The AI wants to do this",
@@ -73,5 +176,8 @@ const STRINGS = {
73
176
  no: "Stop",
74
177
  edit: "I'll do it",
75
178
  starsCost: (n: number) => `Estimated cost: ${n}⭐`,
179
+ writingFile: "Creating file",
180
+ editingFile: "Editing file",
181
+ truncated: (n: number) => `… ${n} more lines not shown. Full change lands in the file.`,
76
182
  },
77
183
  } as const
@@ -0,0 +1,155 @@
1
+ /**
2
+ * First-run welcome tour — 3 steps between SetupScreen and StartupScreen.
3
+ *
4
+ * Triggered exactly once per install (gated by ~/.config/kids-opencode/tour-seen).
5
+ * A returning kid who already has env vars set skips this entirely; only
6
+ * fires when the wizard just ran. Skip ([s]) marks-seen the same as Done.
7
+ *
8
+ * Step content maps to the three things a 12-yo first-timer needs to know
9
+ * before they look at the StartupScreen and freeze:
10
+ * 1. What is a Course Pack?
11
+ * 2. How do I talk to the AI?
12
+ * 3. What do y / n / e mean when the AI asks permission?
13
+ */
14
+
15
+ import React, { useState } from "react"
16
+ import { Box, Text, useInput } from "ink"
17
+ import { getTheme } from "../theme.ts"
18
+ import { KidsLogo } from "../components/KidsLogo.tsx"
19
+
20
+ interface TourScreenProps {
21
+ locale: "zh-Hans" | "en"
22
+ onDone: () => void
23
+ }
24
+
25
+ export function TourScreen({ locale, onDone }: TourScreenProps): React.ReactElement {
26
+ const theme = getTheme()
27
+ const t = STRINGS[locale]
28
+ const [step, setStep] = useState(0)
29
+ const last = t.steps.length - 1
30
+
31
+ useInput((input, key) => {
32
+ if (input === "s" || input === "S") {
33
+ onDone()
34
+ return
35
+ }
36
+ if (key.return || key.rightArrow) {
37
+ if (step >= last) onDone()
38
+ else setStep((s) => Math.min(last, s + 1))
39
+ return
40
+ }
41
+ if (key.leftArrow) {
42
+ setStep((s) => Math.max(0, s - 1))
43
+ }
44
+ })
45
+
46
+ const current = t.steps[step]!
47
+ const isLast = step === last
48
+
49
+ return (
50
+ <Box flexDirection="column" alignItems="center" paddingY={1}>
51
+ <KidsLogo />
52
+ <Box marginTop={1}>
53
+ <Text color={theme.fgDim}>{t.progress(step + 1, t.steps.length)}</Text>
54
+ </Box>
55
+ <Box marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={3} paddingY={1} flexDirection="column" width={68}>
56
+ <Text color={theme.accent} bold>{current.title}</Text>
57
+ <Box marginTop={1} flexDirection="column">
58
+ {current.body.map((line, i) => (
59
+ <Text key={i} color={theme.fg}>{line}</Text>
60
+ ))}
61
+ </Box>
62
+ </Box>
63
+ <Box marginTop={2}>
64
+ <Text color={theme.accent}>{isLast ? t.startNow : t.next}</Text>
65
+ <Text color={theme.fg}> · </Text>
66
+ <Text color={theme.fgDim}>{t.skip}</Text>
67
+ </Box>
68
+ </Box>
69
+ )
70
+ }
71
+
72
+ const STRINGS = {
73
+ "zh-Hans": {
74
+ progress: (cur: number, total: number) => `${cur} / ${total}`,
75
+ next: "[Enter] 下一步 [←] 上一步",
76
+ startNow: "[Enter] 开始玩 [←] 上一步",
77
+ skip: "[s] 跳过引导",
78
+ steps: [
79
+ {
80
+ title: "📦 什么是 Course Pack?",
81
+ body: [
82
+ "Course Pack 是老师为你设计好的一组小项目。",
83
+ "比如:「做一个网站介绍你的小狗」、「画一只 AI 恐龙」。",
84
+ "",
85
+ "每个 Pack 里有几个 Mission,一步一步带你完成。",
86
+ "做完一个 Mission,✨ 就解锁下一个。",
87
+ ],
88
+ },
89
+ {
90
+ title: "💬 怎么跟我说话?",
91
+ body: [
92
+ "直接打中文或英文,按 Enter 发给我。比如:",
93
+ " 「帮我做一个红色按钮」",
94
+ " 「换一个更可爱的字体」",
95
+ "",
96
+ "想验收一关:打 「我做完了」 或 /check",
97
+ "想叫我停下来:按 Esc",
98
+ ],
99
+ },
100
+ {
101
+ title: "🔐 y / n / e — 你说了算",
102
+ body: [
103
+ "我要动你的文件之前,会先问你。三个按键:",
104
+ "",
105
+ " [y] 可以做 — 我一次操作",
106
+ " [n] 不要 — 我会停下来,换个办法",
107
+ " [e] 我来改 — 你自己写这一步,告诉我你想怎么做",
108
+ "",
109
+ "我永远不会绕过这一步偷偷改文件。",
110
+ ],
111
+ },
112
+ ],
113
+ },
114
+ en: {
115
+ progress: (cur: number, total: number) => `${cur} / ${total}`,
116
+ next: "[Enter] next [←] back",
117
+ startNow: "[Enter] start playing [←] back",
118
+ skip: "[s] skip tour",
119
+ steps: [
120
+ {
121
+ title: "📦 What's a Course Pack?",
122
+ body: [
123
+ "A Course Pack is a set of small projects a teacher made for you.",
124
+ "Like: \"Build a website about your dog\" or \"Draw an AI dinosaur\".",
125
+ "",
126
+ "Each Pack has a few Missions, walking you through step by step.",
127
+ "Finish one Mission, ✨ unlock the next.",
128
+ ],
129
+ },
130
+ {
131
+ title: "💬 How do I talk to you?",
132
+ body: [
133
+ "Just type in English or Chinese and press Enter. For example:",
134
+ " \"Make me a red button\"",
135
+ " \"Use a cuter font\"",
136
+ "",
137
+ "To check off a mission: type \"I'm done\" or /check",
138
+ "To stop me mid-reply: press Esc",
139
+ ],
140
+ },
141
+ {
142
+ title: "🔐 y / n / e — you're in charge",
143
+ body: [
144
+ "Before I touch your files I'll ask you. Three keys:",
145
+ "",
146
+ " [y] go ahead — I'll do it once",
147
+ " [n] stop — I'll back off and try another way",
148
+ " [e] I'll do it — you write this step and tell me what you want",
149
+ "",
150
+ "I'll never sneak past this to change files on my own.",
151
+ ],
152
+ },
153
+ ],
154
+ },
155
+ } as const