@kidsinai/kids-client 0.0.1

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.
@@ -0,0 +1,35 @@
1
+ import React from "react"
2
+ import { Box, Text } from "ink"
3
+ import { getTheme } from "../theme.ts"
4
+
5
+ interface HeaderProps {
6
+ packTitle: string | null
7
+ missionTitle: string | null
8
+ missionIndex: number | null
9
+ missionTotal: number | null
10
+ starsBalance: number
11
+ starsBudget: number
12
+ }
13
+
14
+ export function Header({ packTitle, missionTitle, missionIndex, missionTotal, starsBalance, starsBudget }: HeaderProps): React.ReactElement {
15
+ const theme = getTheme()
16
+ // Per PRD §3.2 mockup: "Mission 1/3 · 项目设置 + 第一个 HTML 页面 · ⭐ 余 36/40"
17
+ let left: string
18
+ if (missionIndex && missionTotal && missionTitle) {
19
+ left = `Mission ${missionIndex}/${missionTotal} · ${missionTitle}`
20
+ } else if (packTitle) {
21
+ left = packTitle
22
+ } else {
23
+ left = "Free play"
24
+ }
25
+ const stars =
26
+ starsBudget > 0
27
+ ? `⭐ ${starsBalance}/${starsBudget}`
28
+ : `⭐ ${starsBalance}`
29
+ return (
30
+ <Box borderStyle="round" borderColor={theme.border} paddingX={1} justifyContent="space-between">
31
+ <Text color={theme.accent}>{left}</Text>
32
+ <Text color={theme.stars}>{stars}</Text>
33
+ </Box>
34
+ )
35
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react"
2
+ import { Box, Text } from "ink"
3
+ import TextInput from "ink-text-input"
4
+ import { getTheme } from "../theme.ts"
5
+
6
+ interface InputProps {
7
+ value: string
8
+ onChange: (v: string) => void
9
+ onSubmit: (v: string) => void
10
+ placeholder: string
11
+ disabled?: boolean
12
+ }
13
+
14
+ export function Input({ value, onChange, onSubmit, placeholder, disabled }: InputProps): React.ReactElement {
15
+ const theme = getTheme()
16
+ return (
17
+ <Box borderStyle="single" borderColor={theme.fgDim} paddingX={1}>
18
+ <Text color={theme.kid}>💬 </Text>
19
+ {disabled ? (
20
+ <Text color={theme.fgDim} dimColor>
21
+ {value || placeholder}
22
+ </Text>
23
+ ) : (
24
+ <TextInput value={value} onChange={onChange} onSubmit={onSubmit} placeholder={placeholder} />
25
+ )}
26
+ </Box>
27
+ )
28
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react"
2
+ import { Box, Text } from "ink"
3
+ import { getTheme } from "../theme.ts"
4
+
5
+ interface KeyHintsProps {
6
+ hints: Array<{ key: string; label: string }>
7
+ }
8
+
9
+ export function KeyHints({ hints }: KeyHintsProps): React.ReactElement {
10
+ const theme = getTheme()
11
+ return (
12
+ <Box paddingX={1}>
13
+ {hints.map((h, i) => (
14
+ <Box key={h.key} marginRight={i < hints.length - 1 ? 2 : 0}>
15
+ <Text color={theme.accent}>[{h.key}]</Text>
16
+ <Text color={theme.fg}> {h.label}</Text>
17
+ </Box>
18
+ ))}
19
+ </Box>
20
+ )
21
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react"
2
+ import { Box, Text } from "ink"
3
+ import Spinner from "ink-spinner"
4
+ import { getTheme } from "../theme.ts"
5
+
6
+ interface ThinkingProps {
7
+ locale: "zh-Hans" | "en"
8
+ }
9
+
10
+ export function Thinking({ locale }: ThinkingProps): React.ReactElement {
11
+ const theme = getTheme()
12
+ const label = locale === "zh-Hans" ? "AI 老师在想…" : "Your AI teacher is thinking…"
13
+ return (
14
+ <Box>
15
+ <Text color={theme.agent}>
16
+ <Spinner type="dots" />
17
+ </Text>
18
+ <Text color={theme.agent}> {label}</Text>
19
+ </Box>
20
+ )
21
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Transient toast renderer for non-blocking feedback. Used for "已停止"
3
+ * after Esc abort, "上下文压缩中" during compaction, etc.
4
+ *
5
+ * The store holds the toast; App.tsx overlays it as the last row of the
6
+ * mission screen.
7
+ */
8
+
9
+ import React from "react"
10
+ import { Box, Text } from "ink"
11
+ import { getTheme } from "../theme.ts"
12
+
13
+ export interface ToastState {
14
+ kind: "info" | "warn" | "success"
15
+ text: string
16
+ }
17
+
18
+ export function Toast({ toast }: { toast: ToastState }): React.ReactElement {
19
+ const theme = getTheme()
20
+ const color = toast.kind === "warn" ? theme.warn : toast.kind === "success" ? theme.success : theme.accent
21
+ const icon = toast.kind === "warn" ? "⚠️" : toast.kind === "success" ? "✓" : "ℹ"
22
+ return (
23
+ <Box paddingX={1}>
24
+ <Text color={color}>
25
+ {icon} {toast.text}
26
+ </Text>
27
+ </Box>
28
+ )
29
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * §3.1 [c] option — list installed Course Packs and let the kid pick one.
3
+ * On selection, the parent transitions to MissionScreen with that pack
4
+ * loaded.
5
+ *
6
+ * Up / Down to move, Enter to select, Esc to go back.
7
+ */
8
+
9
+ import React, { useState } from "react"
10
+ import { Box, Text, useInput } from "ink"
11
+ import { getTheme } from "../theme.ts"
12
+ import type { InstalledPack } from "../../../core/course-pack.ts"
13
+
14
+ interface CoursePackPickerProps {
15
+ locale: "zh-Hans" | "en"
16
+ packs: InstalledPack[]
17
+ onPick: (packId: string) => void
18
+ onBack: () => void
19
+ }
20
+
21
+ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPickerProps): React.ReactElement {
22
+ const theme = getTheme()
23
+ const [idx, setIdx] = useState(0)
24
+ useInput((_, key) => {
25
+ if (key.escape) onBack()
26
+ else if (key.upArrow) setIdx((i) => Math.max(0, i - 1))
27
+ else if (key.downArrow) setIdx((i) => Math.min(packs.length - 1, i + 1))
28
+ else if (key.return && packs[idx]) onPick(packs[idx]!.id)
29
+ })
30
+ const t = STRINGS[locale]
31
+ if (packs.length === 0) {
32
+ return (
33
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.warn} paddingX={2} paddingY={1}>
34
+ <Text color={theme.warn} bold>{t.empty}</Text>
35
+ <Box marginTop={1}>
36
+ <Text color={theme.fgDim}>{t.emptyHint}</Text>
37
+ </Box>
38
+ <Box marginTop={1}>
39
+ <Text color={theme.accent}>{t.backHint}</Text>
40
+ </Box>
41
+ </Box>
42
+ )
43
+ }
44
+ return (
45
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1}>
46
+ <Text color={theme.accent} bold>{t.title}</Text>
47
+ <Box marginTop={1} flexDirection="column">
48
+ {packs.map((p, i) => {
49
+ const active = i === idx
50
+ return (
51
+ <Box key={p.id} marginBottom={i === packs.length - 1 ? 0 : 0}>
52
+ <Text color={active ? theme.kid : theme.fg}>{active ? "▶ " : " "}</Text>
53
+ <Box flexDirection="column" flexGrow={1}>
54
+ <Text color={active ? theme.accent : theme.fg} bold={active}>{p.title}</Text>
55
+ {p.shortDescription && (
56
+ <Text color={theme.fgDim} dimColor={!active}> {p.shortDescription}</Text>
57
+ )}
58
+ <Text color={theme.fgDim} dimColor> {t.meta(p.missionCount, p.starsBudget)}</Text>
59
+ </Box>
60
+ </Box>
61
+ )
62
+ })}
63
+ </Box>
64
+ <Box marginTop={1}>
65
+ <Text color={theme.accent}>{t.hints}</Text>
66
+ </Box>
67
+ </Box>
68
+ )
69
+ }
70
+
71
+ const STRINGS = {
72
+ "zh-Hans": {
73
+ title: "选一个 Course Pack",
74
+ empty: "还没装任何 Course Pack",
75
+ emptyHint: "Course Pack 是 Airbotix 老师做的引导式项目。请家长重新装 kids-opencode 把它带回来。",
76
+ backHint: "[Esc / Enter] 返回",
77
+ hints: "[↑↓] 选 · [Enter] 确认 · [Esc] 返回",
78
+ meta: (missions: number, stars: number) =>
79
+ stars > 0 ? `${missions} 个 Mission · 预算 ${stars}⭐` : `${missions} 个 Mission`,
80
+ },
81
+ en: {
82
+ title: "Pick a Course Pack",
83
+ empty: "No Course Packs installed yet",
84
+ emptyHint: "Course Packs are guided projects from Airbotix. Ask a grown-up to reinstall kids-opencode.",
85
+ backHint: "[Esc / Enter] Back",
86
+ hints: "[↑↓] move · [Enter] choose · [Esc] back",
87
+ meta: (missions: number, stars: number) =>
88
+ stars > 0 ? `${missions} missions · budget ${stars}⭐` : `${missions} missions`,
89
+ },
90
+ } as const
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Hard overlay shown when the kid types or the AI emits content matching
3
+ * one of the patterns in kids-tui-plugin/src/dangerous-topic.ts. Blocks
4
+ * chat until kid presses Enter to acknowledge.
5
+ *
6
+ * The point isn't to be punitive — it's to make sure the Kids Helpline
7
+ * number is on-screen at the moment a kid might need it.
8
+ */
9
+
10
+ import React from "react"
11
+ import { Box, Text, useInput } from "ink"
12
+ import { getTheme } from "../theme.ts"
13
+ import type { DangerousTopic } from "../../../core/store.ts"
14
+
15
+ interface DangerousTopicModalProps {
16
+ topic: DangerousTopic
17
+ locale: "zh-Hans" | "en"
18
+ onAcknowledge: () => void
19
+ }
20
+
21
+ export function DangerousTopicModal({ locale, onAcknowledge }: DangerousTopicModalProps): React.ReactElement {
22
+ const theme = getTheme()
23
+ useInput((_, key) => {
24
+ if (key.return) onAcknowledge()
25
+ })
26
+ const t = STRINGS[locale]
27
+ return (
28
+ <Box flexDirection="column" borderStyle="double" borderColor={theme.danger} paddingX={2} paddingY={1}>
29
+ <Text color={theme.danger} bold>
30
+ {t.title}
31
+ </Text>
32
+ <Box marginTop={1} flexDirection="column">
33
+ <Text color={theme.fg}>{t.body1}</Text>
34
+ <Text color={theme.fg}>{t.body2}</Text>
35
+ </Box>
36
+ <Box marginTop={1}>
37
+ <Text color={theme.warn} bold>
38
+ {t.helpline}
39
+ </Text>
40
+ </Box>
41
+ <Box marginTop={1}>
42
+ <Text color={theme.fgDim}>{t.dismiss}</Text>
43
+ </Box>
44
+ </Box>
45
+ )
46
+ }
47
+
48
+ const STRINGS = {
49
+ "zh-Hans": {
50
+ title: "想跟你说一件重要的事",
51
+ body1: "AI 不是真人,没办法帮你处理特别难受或者特别紧急的事。",
52
+ body2: "请马上找你信任的大人(家长、老师、亲戚),或者拨打求助热线。",
53
+ helpline: "Kids Helpline 澳大利亚 1800 55 1800(免费 24 小时)",
54
+ dismiss: "[Enter] 我知道了",
55
+ },
56
+ en: {
57
+ title: "Something important to tell you",
58
+ body1: "I'm an AI, not a person. I'm not the right help for something hard or urgent.",
59
+ body2: "Please tell an adult you trust right now (a parent, teacher, or relative), or call the helpline below.",
60
+ helpline: "Kids Helpline AU 1800 55 1800 — free, 24/7",
61
+ dismiss: "[Enter] I understand",
62
+ },
63
+ } as const
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Friendly error screens — 6 variants per PRD §3.4 / plan Day 9.
3
+ *
4
+ * Variants:
5
+ * serve_unreachable | network_down | stars_exhausted |
6
+ * auth_failed | config_missing | ai_hung
7
+ *
8
+ * Each variant has a short title + a one-sentence action + an optional
9
+ * recovery key hint. Detail text (technical) is shown dimmed in case
10
+ * a parent / engineer needs to debug.
11
+ */
12
+
13
+ import React from "react"
14
+ import { Box, Text, useInput } from "ink"
15
+ import { getTheme } from "../theme.ts"
16
+ import type { ErrorVariant } from "../../../core/store.ts"
17
+
18
+ interface ErrorScreenProps {
19
+ variant: ErrorVariant
20
+ locale: "zh-Hans" | "en"
21
+ detail?: string
22
+ onRetry?: () => void
23
+ onQuit?: () => void
24
+ }
25
+
26
+ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorScreenProps): React.ReactElement {
27
+ const theme = getTheme()
28
+ useInput((input, key) => {
29
+ if (key.return && onRetry) onRetry()
30
+ else if (input === "q" && onQuit) onQuit()
31
+ })
32
+ const t = STRINGS[locale][variant]
33
+ return (
34
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.danger} paddingX={2} paddingY={1}>
35
+ <Text color={theme.danger} bold>
36
+ {t.title}
37
+ </Text>
38
+ <Box marginTop={1}>
39
+ <Text color={theme.fg}>{t.body}</Text>
40
+ </Box>
41
+ {detail && (
42
+ <Box marginTop={1}>
43
+ <Text color={theme.fgDim} dimColor>
44
+ {detail}
45
+ </Text>
46
+ </Box>
47
+ )}
48
+ <Box marginTop={1}>
49
+ {onRetry && (
50
+ <Box marginRight={2}>
51
+ <Text color={theme.accent}>[Enter]</Text>
52
+ <Text color={theme.fg}> {t.retry}</Text>
53
+ </Box>
54
+ )}
55
+ {onQuit && (
56
+ <Box>
57
+ <Text color={theme.accent}>[q]</Text>
58
+ <Text color={theme.fg}> {STRINGS[locale].quit}</Text>
59
+ </Box>
60
+ )}
61
+ </Box>
62
+ </Box>
63
+ )
64
+ }
65
+
66
+ const STRINGS = {
67
+ "zh-Hans": {
68
+ quit: "退出",
69
+ serve_unreachable: {
70
+ title: "AI 老师还没起来",
71
+ body: "后台 AI 服务好像没启动。要不要再试一次?",
72
+ retry: "重试",
73
+ },
74
+ network_down: {
75
+ title: "网络有点问题",
76
+ body: "我没办法连上 AI。等会儿再来,或者问家长检查网络。",
77
+ retry: "重试",
78
+ },
79
+ stars_exhausted: {
80
+ title: "今天的 ⭐ 用完了",
81
+ body: "明天再来,或者请家长在 airbotix.ai/portal/wallet 充值。",
82
+ retry: "重试",
83
+ },
84
+ auth_failed: {
85
+ title: "AI 老师认不出你",
86
+ body: "请家长重新跑一下 `kids-opencode register`。",
87
+ retry: "重试",
88
+ },
89
+ config_missing: {
90
+ title: "配置丢了",
91
+ body: "重新装一下 kids-opencode 就好。",
92
+ retry: "重试",
93
+ },
94
+ ai_hung: {
95
+ title: "AI 老师好像睡着了",
96
+ body: "30 秒没回应。按 Enter 重来一遍。",
97
+ retry: "重试",
98
+ },
99
+ },
100
+ en: {
101
+ quit: "Quit",
102
+ serve_unreachable: {
103
+ title: "AI teacher didn't start",
104
+ body: "The background AI service isn't running. Try again?",
105
+ retry: "Retry",
106
+ },
107
+ network_down: {
108
+ title: "Network trouble",
109
+ body: "I can't reach the AI. Try later, or ask an adult to check the connection.",
110
+ retry: "Retry",
111
+ },
112
+ stars_exhausted: {
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",
116
+ },
117
+ auth_failed: {
118
+ title: "AI doesn't recognise you",
119
+ body: "Ask a parent to run `kids-opencode register` again.",
120
+ retry: "Retry",
121
+ },
122
+ config_missing: {
123
+ title: "Config is missing",
124
+ body: "Reinstall kids-opencode to fix this.",
125
+ retry: "Retry",
126
+ },
127
+ ai_hung: {
128
+ title: "AI seems to be asleep",
129
+ body: "30 seconds without a reply. Press Enter to try again.",
130
+ retry: "Retry",
131
+ },
132
+ },
133
+ } as const
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Kid-friendly help. Reached via [h] on the Startup screen, or [?] from
3
+ * inside MissionScreen later.
4
+ *
5
+ * Year-6 reading level; key bindings spelled in plain language; emergency
6
+ * helpline pinned.
7
+ */
8
+
9
+ import React from "react"
10
+ import { Box, Text, useInput } from "ink"
11
+ import { getTheme } from "../theme.ts"
12
+
13
+ interface HelpScreenProps {
14
+ locale: "zh-Hans" | "en"
15
+ onBack: () => void
16
+ }
17
+
18
+ export function HelpScreen({ locale, onBack }: HelpScreenProps): React.ReactElement {
19
+ const theme = getTheme()
20
+ useInput((input, key) => {
21
+ if (key.escape || key.return || input === "b" || input === "q") onBack()
22
+ })
23
+ const t = STRINGS[locale]
24
+ return (
25
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1}>
26
+ <Text color={theme.accent} bold>{t.title}</Text>
27
+ <Box marginTop={1} flexDirection="column">
28
+ <Text color={theme.fg}>{t.intro1}</Text>
29
+ <Text color={theme.fg}>{t.intro2}</Text>
30
+ </Box>
31
+ <Box marginTop={1} flexDirection="column">
32
+ <Text color={theme.warn} bold>{t.howAsk}</Text>
33
+ <Text color={theme.fg}>{t.how1}</Text>
34
+ <Text color={theme.fg}>{t.how2}</Text>
35
+ <Text color={theme.fg}>{t.how3}</Text>
36
+ </Box>
37
+ <Box marginTop={1} flexDirection="column">
38
+ <Text color={theme.warn} bold>{t.keys}</Text>
39
+ {t.keymap.map((line, i) => (
40
+ <Text key={i} color={theme.fg}>{line}</Text>
41
+ ))}
42
+ </Box>
43
+ <Box marginTop={1} flexDirection="column">
44
+ <Text color={theme.warn} bold>{t.safetyTitle}</Text>
45
+ <Text color={theme.fg}>{t.safety1}</Text>
46
+ <Text color={theme.danger}>{t.helpline}</Text>
47
+ </Box>
48
+ <Box marginTop={1}>
49
+ <Text color={theme.accent}>{t.backHint}</Text>
50
+ </Box>
51
+ </Box>
52
+ )
53
+ }
54
+
55
+ const STRINGS = {
56
+ "zh-Hans": {
57
+ title: "怎么用 Kids OpenCode",
58
+ intro1: "我是你的 AI 老师,可以帮你一起做编程项目。",
59
+ intro2: "你告诉我你想做什么,我会一步一步引导你做出来。",
60
+ howAsk: "怎么跟我说话",
61
+ how1: "· 直接打字。中文英文都行。",
62
+ how2: "· 想做完一关时打 /check 或者「我做完了」,我就帮你验收。",
63
+ how3: "· 我每次要动你电脑上的东西前都会问你,按 y 同意。",
64
+ keys: "按键提示",
65
+ keymap: [
66
+ "· Enter 送出消息",
67
+ "· y / n / e 答 AI 的请求(同意 / 不要 / 我自己来)",
68
+ "· Esc AI 在说话时按一下可以打断它",
69
+ "· Ctrl+C 完全退出(serve 也会停)",
70
+ ],
71
+ safetyTitle: "安全",
72
+ safety1: "我不是真人,有时候会答错。遇到不懂的,问家长或老师。",
73
+ helpline: "澳大利亚紧急求助:Kids Helpline 1800 55 1800",
74
+ backHint: "[Enter / Esc / b] 回上一页",
75
+ },
76
+ en: {
77
+ title: "How to use Kids OpenCode",
78
+ intro1: "I'm your AI teacher. I can help you build coding projects.",
79
+ intro2: "You tell me what you want to make, and I'll walk you through it step by step.",
80
+ howAsk: "How to talk to me",
81
+ how1: "· Just type. English or Chinese both work.",
82
+ how2: "· When you think you're done, type /check or 'I'm done' and I'll check your work.",
83
+ how3: "· Before I touch anything on your computer I'll ask. Press y to allow.",
84
+ keys: "Keys",
85
+ keymap: [
86
+ "· Enter Send a message",
87
+ "· y / n / e Reply to my requests (allow / stop / I'll do it)",
88
+ "· Esc Stop me while I'm talking",
89
+ "· Ctrl+C Quit (serve stops too)",
90
+ ],
91
+ safetyTitle: "Safety",
92
+ safety1: "I'm not a real person and I can be wrong. Ask a parent or teacher if you're unsure.",
93
+ helpline: "Australia emergency help: Kids Helpline 1800 55 1800",
94
+ backHint: "[Enter / Esc / b] Back",
95
+ },
96
+ } as const
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Visible during ServeManager.ensureReady() (~1-5s window depending on
3
+ * whether opencode serve is already running). Without this, the kid sees
4
+ * a black terminal while the wrapper polls /app.
5
+ */
6
+
7
+ import React from "react"
8
+ import { Box, Text } from "ink"
9
+ import Spinner from "ink-spinner"
10
+ import { getTheme } from "../theme.ts"
11
+
12
+ interface LoadingScreenProps {
13
+ locale: "zh-Hans" | "en"
14
+ message?: string
15
+ }
16
+
17
+ export function LoadingScreen({ locale, message }: LoadingScreenProps): React.ReactElement {
18
+ const theme = getTheme()
19
+ const label = message ?? (locale === "zh-Hans" ? "AI 老师正在准备…" : "Your AI teacher is getting ready…")
20
+ return (
21
+ <Box flexDirection="column" paddingY={1} paddingX={2}>
22
+ <Box>
23
+ <Text color={theme.accent}>
24
+ <Spinner type="dots" />
25
+ </Text>
26
+ <Text color={theme.accent}> {label}</Text>
27
+ </Box>
28
+ <Box marginTop={1}>
29
+ <Text color={theme.fgDim}>{locale === "zh-Hans" ? "几秒后就好" : "Just a few seconds"}</Text>
30
+ </Box>
31
+ </Box>
32
+ )
33
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * PRD §3.3 "Mission 完成有 ASCII 烟花". Renders when the in-TUI check
3
+ * runner reports `ok: true`. Includes the friendly completion message
4
+ * from the pack's acceptance.yml and a celebration animation.
5
+ *
6
+ * Esc / Enter to return to the Mission screen (for free play after) or
7
+ * pick the next Mission.
8
+ */
9
+
10
+ import React from "react"
11
+ import { Box, Text, useInput } from "ink"
12
+ import { getTheme } from "../theme.ts"
13
+
14
+ interface MissionCompleteScreenProps {
15
+ locale: "zh-Hans" | "en"
16
+ missionId: string
17
+ missionTitle: string | null
18
+ passed: number
19
+ total: number
20
+ completionMessage: string
21
+ hasNextMission: boolean
22
+ onNext: () => void
23
+ onBack: () => void
24
+ }
25
+
26
+ export function MissionCompleteScreen({
27
+ locale,
28
+ missionId,
29
+ missionTitle,
30
+ passed,
31
+ total,
32
+ completionMessage,
33
+ hasNextMission,
34
+ onNext,
35
+ onBack,
36
+ }: MissionCompleteScreenProps): React.ReactElement {
37
+ const theme = getTheme()
38
+ useInput((input, key) => {
39
+ if (key.return && hasNextMission) onNext()
40
+ else if (key.escape || input === "b" || input === "q") onBack()
41
+ else if (input === "n" && hasNextMission) onNext()
42
+ })
43
+ const t = STRINGS[locale]
44
+ return (
45
+ <Box flexDirection="column" borderStyle="double" borderColor={theme.success} paddingX={2} paddingY={1}>
46
+ <Box flexDirection="column">
47
+ {FIREWORKS.map((line, i) => (
48
+ <Text key={i} color={pickColor(theme, i)}>{line}</Text>
49
+ ))}
50
+ </Box>
51
+ <Box marginTop={1}>
52
+ <Text color={theme.success} bold>
53
+ {t.headline}
54
+ </Text>
55
+ </Box>
56
+ <Box marginTop={1} flexDirection="column">
57
+ <Text color={theme.fg}>{t.detail(missionTitle ?? missionId, passed, total)}</Text>
58
+ <Text color={theme.fgDim}>{completionMessage}</Text>
59
+ </Box>
60
+ <Box marginTop={1}>
61
+ {hasNextMission ? (
62
+ <>
63
+ <Box marginRight={2}>
64
+ <Text color={theme.accent}>[Enter / n]</Text>
65
+ <Text color={theme.fg}> {t.next}</Text>
66
+ </Box>
67
+ <Box>
68
+ <Text color={theme.accent}>[Esc / b]</Text>
69
+ <Text color={theme.fg}> {t.back}</Text>
70
+ </Box>
71
+ </>
72
+ ) : (
73
+ <Box>
74
+ <Text color={theme.accent}>[Enter / Esc]</Text>
75
+ <Text color={theme.fg}> {t.back}</Text>
76
+ </Box>
77
+ )}
78
+ </Box>
79
+ </Box>
80
+ )
81
+ }
82
+
83
+ const FIREWORKS = [
84
+ " * . *",
85
+ " . * . *",
86
+ " * . * . ",
87
+ " 🎆 🎉 🎆 ",
88
+ " . * . * ",
89
+ " * . * .",
90
+ ]
91
+
92
+ function pickColor(theme: ReturnType<typeof getTheme>, i: number): string {
93
+ const palette = [theme.accent, theme.success, theme.stars, theme.kid, theme.agent]
94
+ return palette[i % palette.length] ?? theme.fg
95
+ }
96
+
97
+ const STRINGS = {
98
+ "zh-Hans": {
99
+ headline: "Mission 完成!",
100
+ detail: (title: string, p: number, t: number) =>
101
+ `「${title}」 · ${p}/${t} 项检查全部通过 ✓`,
102
+ next: "下一个 Mission",
103
+ back: "回到项目",
104
+ },
105
+ en: {
106
+ headline: "Mission complete!",
107
+ detail: (title: string, p: number, t: number) =>
108
+ `"${title}" · ${p}/${t} checks passed ✓`,
109
+ next: "Next Mission",
110
+ back: "Back to project",
111
+ },
112
+ } as const