@kidsinai/kids-client 0.0.5 → 0.0.6
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 +1 -1
- package/src/core/store.ts +1 -0
- package/src/core/tour-marker.ts +27 -0
- package/src/index.tsx +56 -2
- package/src/render/ink/App.tsx +4 -0
- package/src/render/ink/screens/ErrorScreen.tsx +4 -4
- package/src/render/ink/screens/PermissionModal.tsx +106 -0
- package/src/render/ink/screens/TourScreen.tsx +155 -0
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.6",
|
|
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/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -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: "
|
|
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: "
|
|
115
|
-
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
|