@kidsinai/kids-client 0.0.3 → 0.0.4
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/env-reload.ts +26 -0
- package/src/core/opencode-installer.ts +82 -0
- package/src/index.tsx +218 -192
- package/src/render/ink/App.tsx +2 -1
- package/src/render/ink/screens/SetupScreen.tsx +126 -15
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.4",
|
|
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",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-read ~/.config/kids-opencode/env after the setup wizard saves it,
|
|
3
|
+
* and inject the values into process.env. This lets the SAME process
|
|
4
|
+
* continue with the new LLM key — no `kids-opencode` re-run needed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
|
|
10
|
+
export function reloadEnvFile(configDir: string): Record<string, string> {
|
|
11
|
+
const path = join(configDir, "env")
|
|
12
|
+
if (!existsSync(path)) return {}
|
|
13
|
+
const out: Record<string, string> = {}
|
|
14
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
15
|
+
const line = raw.trim()
|
|
16
|
+
if (!line || line.startsWith("#")) continue
|
|
17
|
+
const eq = line.indexOf("=")
|
|
18
|
+
if (eq <= 0) continue
|
|
19
|
+
const key = line.slice(0, eq).trim()
|
|
20
|
+
let value = line.slice(eq + 1).trim()
|
|
21
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
|
|
22
|
+
out[key] = value
|
|
23
|
+
process.env[key] = value
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-TUI installer for the upstream `opencode` CLI binary.
|
|
3
|
+
*
|
|
4
|
+
* The kid (or their parent) should never have to drop back to a shell
|
|
5
|
+
* prompt and paste a `curl ... | sh` line. If the AI engine isn't
|
|
6
|
+
* installed when the wizard starts, we run the installer ourselves and
|
|
7
|
+
* stream its progress to the SetupScreen.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process"
|
|
11
|
+
import { existsSync } from "node:fs"
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
|
|
15
|
+
/** True if the upstream opencode binary is on PATH or in its standard install location. */
|
|
16
|
+
export function hasOpencodeBinary(): boolean {
|
|
17
|
+
// PATH lookup (POSIX). `which` and `command -v` aren't reliable as
|
|
18
|
+
// child_process commands without a shell, so just check $PATH dirs.
|
|
19
|
+
const pathDirs = (process.env.PATH ?? "").split(":")
|
|
20
|
+
const candidates = [
|
|
21
|
+
...pathDirs.map((d) => join(d, "opencode")),
|
|
22
|
+
join(homedir(), ".opencode", "bin", "opencode"),
|
|
23
|
+
"/usr/local/bin/opencode",
|
|
24
|
+
"/opt/homebrew/bin/opencode",
|
|
25
|
+
]
|
|
26
|
+
return candidates.some((p) => existsSync(p))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InstallResult {
|
|
30
|
+
ok: boolean
|
|
31
|
+
error?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run the upstream installer in a subprocess, streaming each output line
|
|
36
|
+
* to `onProgress`. Resolves when the subprocess exits.
|
|
37
|
+
*
|
|
38
|
+
* Uses /bin/sh + curl pipe to match upstream's official install command.
|
|
39
|
+
* The whole thing typically takes 15-45 seconds depending on network.
|
|
40
|
+
*/
|
|
41
|
+
export function installOpencode(onProgress: (line: string) => void): Promise<InstallResult> {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const child = spawn("sh", ["-c", "curl -fsSL https://opencode.ai/install | sh"], {
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
env: { ...process.env },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const handleStream = (stream: NodeJS.ReadableStream): void => {
|
|
49
|
+
let buf = ""
|
|
50
|
+
stream.on("data", (chunk: Buffer | string) => {
|
|
51
|
+
buf += chunk.toString()
|
|
52
|
+
let nl: number
|
|
53
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
54
|
+
const line = buf.slice(0, nl)
|
|
55
|
+
buf = buf.slice(nl + 1)
|
|
56
|
+
const trimmed = line.replace(/\r/g, "").trim()
|
|
57
|
+
if (trimmed) onProgress(trimmed)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
if (child.stdout) handleStream(child.stdout)
|
|
62
|
+
if (child.stderr) handleStream(child.stderr)
|
|
63
|
+
|
|
64
|
+
child.on("error", (err) => {
|
|
65
|
+
resolve({ ok: false, error: err.message })
|
|
66
|
+
})
|
|
67
|
+
child.on("close", (code) => {
|
|
68
|
+
if (code === 0) {
|
|
69
|
+
// Make sure the new bin dir is on PATH for the remainder of THIS run,
|
|
70
|
+
// so subsequent calls (postinstall plugin registration etc.) can find
|
|
71
|
+
// opencode without waiting for a shell restart.
|
|
72
|
+
const newBin = join(homedir(), ".opencode", "bin")
|
|
73
|
+
if (!process.env.PATH?.includes(newBin)) {
|
|
74
|
+
process.env.PATH = `${newBin}:${process.env.PATH ?? ""}`
|
|
75
|
+
}
|
|
76
|
+
resolve({ ok: true })
|
|
77
|
+
} else {
|
|
78
|
+
resolve({ ok: false, error: `installer exited with code ${code}` })
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* kids-client entry. Composes core/* and renders the Ink app.
|
|
3
3
|
*
|
|
4
|
-
* Boot
|
|
5
|
-
* 1. readEnv
|
|
6
|
-
* 2. validateEnv
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Boot orchestration (V0.0.3):
|
|
5
|
+
* 1. readEnv + initial render in "loading"
|
|
6
|
+
* 2. validateEnv:
|
|
7
|
+
* - "needs_setup" → render SetupScreen, await user completion,
|
|
8
|
+
* reload env from file, re-validate, continue inline
|
|
9
|
+
* - "config_missing" / "auth_failed" → error screen, exit
|
|
10
|
+
* - ok → fall through to bootServices
|
|
11
|
+
* 3. bootServices: audit pipeline + opencode serve subprocess +
|
|
12
|
+
* SDK v2 client + SSE subscriber + SIGINT/SIGTERM
|
|
13
|
+
* 4. Render startup screen; user picks a flow.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* Inline boot guarantee: the user never sees "run kids-opencode again".
|
|
16
|
+
* SetupScreen → save → reload env → boot serve → MissionScreen,
|
|
17
|
+
* all in the SAME process.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import React from "react"
|
|
18
21
|
import { render } from "ink"
|
|
19
22
|
import { join } from "node:path"
|
|
20
|
-
import { readEnv, validateEnv } from "./core/env.ts"
|
|
23
|
+
import { readEnv, validateEnv, type KidsClientEnv } from "./core/env.ts"
|
|
21
24
|
import { ServeManager } from "./core/serve-manager.ts"
|
|
22
25
|
import { createKidsClient, type OpencodeClient } from "./core/connection.ts"
|
|
23
26
|
import { SessionManager } from "./core/session.ts"
|
|
@@ -30,57 +33,176 @@ import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
|
|
|
30
33
|
import { App } from "./render/ink/App.tsx"
|
|
31
34
|
import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
|
|
32
35
|
import { saveSetup, type ProviderId } from "./core/setup.ts"
|
|
36
|
+
import { reloadEnvFile } from "./core/env-reload.ts"
|
|
33
37
|
import type { InstalledPack } from "./core/course-pack.ts"
|
|
34
|
-
import {
|
|
38
|
+
import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
|
|
39
|
+
|
|
40
|
+
interface ServiceSet {
|
|
41
|
+
audit: AuditPipeline
|
|
42
|
+
serve: ServeManager
|
|
43
|
+
client: OpencodeClient
|
|
44
|
+
session: SessionManager
|
|
45
|
+
subscriber: EventSubscriber
|
|
46
|
+
quit: () => Promise<void>
|
|
47
|
+
handlers: FullHandlers
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface FullHandlers {
|
|
51
|
+
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
52
|
+
onPrompt: (text: string) => Promise<void>
|
|
53
|
+
onPermissionReply: (decision: "allow" | "deny" | "edit") => Promise<void>
|
|
54
|
+
onAbort: () => Promise<void>
|
|
55
|
+
onErrorRetry: () => Promise<void>
|
|
56
|
+
onPickPack: (packId: string) => void
|
|
57
|
+
onMissionNext: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface AppHandlers {
|
|
61
|
+
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
62
|
+
onPrompt: (text: string) => void
|
|
63
|
+
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
64
|
+
onDangerousAcknowledge: () => void
|
|
65
|
+
onErrorRetry: () => void | Promise<void>
|
|
66
|
+
onQuit: () => void | Promise<void>
|
|
67
|
+
onAbort: () => void
|
|
68
|
+
onHelpBack: () => void
|
|
69
|
+
onPickPack: (packId: string) => void
|
|
70
|
+
onPickerBack: () => void
|
|
71
|
+
onMissionNext: () => void
|
|
72
|
+
onMissionBack: () => void
|
|
73
|
+
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
74
|
+
onSetupContinue: () => Promise<void>
|
|
75
|
+
onSetupSkip: () => void
|
|
76
|
+
}
|
|
35
77
|
|
|
36
78
|
async function main(): Promise<void> {
|
|
37
|
-
const env = readEnv()
|
|
79
|
+
const env: KidsClientEnv = readEnv()
|
|
38
80
|
const store = new Store()
|
|
39
81
|
const installedPacks = listInstalledPacks()
|
|
82
|
+
|
|
40
83
|
store.update({
|
|
41
84
|
coursePack: env.coursePack,
|
|
42
85
|
mission: env.mission,
|
|
43
86
|
screen: { kind: "loading", message: env.locale === "zh-Hans" ? "正在唤醒 AI 老师…" : "Waking up the AI teacher…" },
|
|
44
87
|
})
|
|
45
88
|
|
|
46
|
-
|
|
89
|
+
// Resolve course pack metadata upfront if available.
|
|
90
|
+
applyCoursePackContext(env, store)
|
|
91
|
+
|
|
92
|
+
// Mutable holder for service set; populated by bootServices().
|
|
93
|
+
const servicesHolder: { current: ServiceSet | null } = { current: null }
|
|
94
|
+
|
|
95
|
+
// Promise that the SetupScreen flow resolves when the user has completed
|
|
96
|
+
// (or chosen to skip) setup. main() awaits it before continuing.
|
|
97
|
+
let resolveSetup: (() => void) | null = null
|
|
98
|
+
const setupGate = new Promise<void>((r) => { resolveSetup = r })
|
|
99
|
+
|
|
100
|
+
const handlers: AppHandlers = makeHandlers(store, env, servicesHolder, resolveSetupFn => {
|
|
101
|
+
resolveSetup = resolveSetupFn
|
|
102
|
+
}, () => resolveSetup)
|
|
103
|
+
|
|
104
|
+
renderApp(store, env, installedPacks, handlers)
|
|
105
|
+
|
|
106
|
+
// First validation pass.
|
|
107
|
+
let check = validateEnv(env)
|
|
108
|
+
if (!check.ok && check.variant === "needs_setup") {
|
|
109
|
+
store.update({ screen: { kind: "setup" } })
|
|
110
|
+
await setupGate
|
|
111
|
+
|
|
112
|
+
// Re-source env file (the setup wizard wrote it).
|
|
113
|
+
reloadEnvFile(env.configDir)
|
|
114
|
+
Object.assign(env, readEnv())
|
|
115
|
+
check = validateEnv(env)
|
|
116
|
+
}
|
|
117
|
+
|
|
47
118
|
if (!check.ok) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// writes config + env file, then re-launches main() to pick up
|
|
51
|
-
// the new key.
|
|
52
|
-
store.update({ screen: { kind: "setup" } })
|
|
53
|
-
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
store.update({ screen: { kind: "error", variant: check.variant, detail: check.reason } })
|
|
57
|
-
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
|
|
119
|
+
const variant = check.variant === "needs_setup" ? "auth_failed" : check.variant
|
|
120
|
+
store.update({ screen: { kind: "error", variant, detail: check.reason } })
|
|
58
121
|
return
|
|
59
122
|
}
|
|
60
123
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
124
|
+
// Bootstrap services in-process. Loading screen is shown while we wait.
|
|
125
|
+
store.update({
|
|
126
|
+
screen: {
|
|
127
|
+
kind: "loading",
|
|
128
|
+
message: env.locale === "zh-Hans" ? "启动 AI 引擎…" : "Starting AI engine…",
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const services = await bootServices(env, store)
|
|
133
|
+
if (!services) {
|
|
134
|
+
// bootServices already updated the store with the failure screen.
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
servicesHolder.current = services
|
|
138
|
+
|
|
139
|
+
// SIGINT / SIGTERM cleanly tears down.
|
|
140
|
+
process.on("SIGINT", () => void services.quit())
|
|
141
|
+
process.on("SIGTERM", () => void services.quit())
|
|
142
|
+
|
|
143
|
+
// Land on startup screen.
|
|
144
|
+
store.update({ screen: { kind: "startup" } })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── handler factory ──────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function makeHandlers(
|
|
150
|
+
store: Store,
|
|
151
|
+
env: KidsClientEnv,
|
|
152
|
+
servicesHolder: { current: ServiceSet | null },
|
|
153
|
+
_setResolveSetup: (fn: (() => void) | null) => void,
|
|
154
|
+
getResolveSetup: () => (() => void) | null,
|
|
155
|
+
): AppHandlers {
|
|
156
|
+
const ifBooted = <A extends unknown[]>(fn: (s: ServiceSet, ...args: A) => unknown) => (...args: A) => {
|
|
157
|
+
const s = servicesHolder.current
|
|
158
|
+
if (s) return fn(s, ...args)
|
|
159
|
+
return undefined
|
|
82
160
|
}
|
|
83
161
|
|
|
162
|
+
return {
|
|
163
|
+
onStart: ifBooted((s, mode: "free" | "course" | "resume" | "help") => s.handlers.onStart(mode)),
|
|
164
|
+
onPrompt: ifBooted((s, text: string) => s.handlers.onPrompt(text)),
|
|
165
|
+
onPermissionReply: ifBooted((s, d: "allow" | "deny" | "edit") => s.handlers.onPermissionReply(d)),
|
|
166
|
+
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
167
|
+
onErrorRetry: async () => {
|
|
168
|
+
const s = servicesHolder.current
|
|
169
|
+
if (s) return s.handlers.onErrorRetry()
|
|
170
|
+
// Pre-boot error retry: re-run main isn't trivial; just exit.
|
|
171
|
+
process.exit(1)
|
|
172
|
+
},
|
|
173
|
+
onQuit: async () => {
|
|
174
|
+
const s = servicesHolder.current
|
|
175
|
+
if (s) return s.quit()
|
|
176
|
+
process.exit(0)
|
|
177
|
+
},
|
|
178
|
+
onAbort: ifBooted((s) => s.handlers.onAbort()),
|
|
179
|
+
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
180
|
+
onPickPack: ifBooted((s, id: string) => s.handlers.onPickPack(id)),
|
|
181
|
+
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
182
|
+
onMissionNext: ifBooted((s) => s.handlers.onMissionNext()),
|
|
183
|
+
onMissionBack: () => store.update({ screen: { kind: "mission" } }),
|
|
184
|
+
onSetupSave: async (provider, apiKey) => {
|
|
185
|
+
try {
|
|
186
|
+
saveSetup({ configDir: env.configDir, provider, apiKey })
|
|
187
|
+
return { ok: true }
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
onSetupContinue: async () => {
|
|
193
|
+
const r = getResolveSetup()
|
|
194
|
+
if (r) r()
|
|
195
|
+
},
|
|
196
|
+
onSetupSkip: () => {
|
|
197
|
+
const r = getResolveSetup()
|
|
198
|
+
if (r) r()
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── service bootstrap ────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSet | null> {
|
|
84
206
|
const audit = new AuditPipeline({
|
|
85
207
|
bufferPath: join(env.configDir, "audit-buffer.jsonl"),
|
|
86
208
|
})
|
|
@@ -100,8 +222,7 @@ async function main(): Promise<void> {
|
|
|
100
222
|
const readiness = await serve.ensureReady()
|
|
101
223
|
if (readiness.kind === "timeout") {
|
|
102
224
|
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
|
|
103
|
-
|
|
104
|
-
return
|
|
225
|
+
return null
|
|
105
226
|
}
|
|
106
227
|
|
|
107
228
|
const client = createKidsClient({
|
|
@@ -114,8 +235,8 @@ async function main(): Promise<void> {
|
|
|
114
235
|
onSessionCreated: (e) => {
|
|
115
236
|
store.update({ sessionId: e.sessionID })
|
|
116
237
|
writeLastSession(env.configDir, {
|
|
117
|
-
coursePack:
|
|
118
|
-
mission:
|
|
238
|
+
coursePack: store.getSnapshot().coursePack,
|
|
239
|
+
mission: store.getSnapshot().mission,
|
|
119
240
|
lastActiveAt: new Date().toISOString(),
|
|
120
241
|
projectDir: process.cwd(),
|
|
121
242
|
})
|
|
@@ -137,7 +258,6 @@ async function main(): Promise<void> {
|
|
|
137
258
|
},
|
|
138
259
|
onTextEnded: (e) => store.endStream(e.messageID),
|
|
139
260
|
onPermissionAsked: (e) => {
|
|
140
|
-
// pickup of stars_estimated from the latest plugin audit event.
|
|
141
261
|
const recentAudit = store.getSnapshot().auditBuffer.slice(-10).reverse() as Array<Record<string, unknown>>
|
|
142
262
|
const matching = recentAudit.find(
|
|
143
263
|
(a) => a && typeof a === "object" && a.event === "tool.execute.before" && a.tool === e.tool,
|
|
@@ -174,121 +294,25 @@ async function main(): Promise<void> {
|
|
|
174
294
|
})
|
|
175
295
|
void subscriber.run()
|
|
176
296
|
|
|
177
|
-
|
|
178
|
-
// jump straight in (Enter) or pick a different one (c).
|
|
179
|
-
store.update({ screen: { kind: "startup" } })
|
|
180
|
-
|
|
181
|
-
const handleQuit = async (): Promise<void> => {
|
|
297
|
+
const quit = async (): Promise<void> => {
|
|
182
298
|
subscriber.stop()
|
|
183
299
|
await audit.stop()
|
|
184
300
|
await serve.shutdown()
|
|
185
301
|
process.exit(0)
|
|
186
302
|
}
|
|
187
|
-
process.on("SIGINT", () => void handleQuit())
|
|
188
|
-
process.on("SIGTERM", () => void handleQuit())
|
|
189
|
-
|
|
190
|
-
const handlers = fullHandlers(store, env, session, client, serve, handleQuit)
|
|
191
|
-
renderApp(store, env, installedPacks, handlers)
|
|
192
|
-
}
|
|
193
303
|
|
|
194
|
-
|
|
304
|
+
const handlers = makeFullHandlers(store, env, session, client, serve)
|
|
195
305
|
|
|
196
|
-
|
|
197
|
-
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
198
|
-
onPrompt: (text: string) => void
|
|
199
|
-
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
200
|
-
onDangerousAcknowledge: () => void
|
|
201
|
-
onErrorRetry: () => void | Promise<void>
|
|
202
|
-
onQuit: () => void | Promise<void>
|
|
203
|
-
onAbort: () => void
|
|
204
|
-
onHelpBack: () => void
|
|
205
|
-
onPickPack: (packId: string) => void
|
|
206
|
-
onPickerBack: () => void
|
|
207
|
-
onMissionNext: () => void
|
|
208
|
-
onMissionBack: () => void
|
|
209
|
-
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
210
|
-
onSetupSkip: () => void
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function makeSetupHandlers(store: Store, env: ReturnType<typeof readEnv>): Pick<AppHandlers, "onSetupSave" | "onSetupSkip"> {
|
|
214
|
-
return {
|
|
215
|
-
onSetupSave: async (provider, apiKey) => {
|
|
216
|
-
try {
|
|
217
|
-
saveSetup({ configDir: env.configDir, provider, apiKey })
|
|
218
|
-
return { ok: true }
|
|
219
|
-
} catch (err) {
|
|
220
|
-
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
221
|
-
}
|
|
222
|
-
},
|
|
223
|
-
onSetupSkip: () => {
|
|
224
|
-
// After setup completes (or user skips), tell the user to restart so
|
|
225
|
-
// the wrapper picks up the new env file. Re-launching main() in-process
|
|
226
|
-
// would require tearing down Ink which is messy; a re-exec is cleaner.
|
|
227
|
-
process.stderr.write("\nKids OpenCode: setup saved. Please run `kids-opencode` again to start.\n")
|
|
228
|
-
process.exit(0)
|
|
229
|
-
},
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Minimal handlers for the pre-validation / pre-readiness error path.
|
|
235
|
-
* Many actions are no-ops because the app isn't fully booted; quit is
|
|
236
|
-
* the realistic action.
|
|
237
|
-
*/
|
|
238
|
-
function baseHandlers(
|
|
239
|
-
store: Store,
|
|
240
|
-
env: ReturnType<typeof readEnv>,
|
|
241
|
-
_session: SessionManager | null,
|
|
242
|
-
_client: OpencodeClient | null,
|
|
243
|
-
serve: ServeManager | null,
|
|
244
|
-
): AppHandlers {
|
|
245
|
-
const noop = (): void => {}
|
|
246
|
-
const setup = makeSetupHandlers(store, env)
|
|
247
|
-
return {
|
|
248
|
-
onStart: noop,
|
|
249
|
-
onPrompt: noop,
|
|
250
|
-
onPermissionReply: noop,
|
|
251
|
-
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
252
|
-
onErrorRetry: async () => {
|
|
253
|
-
if (!serve) {
|
|
254
|
-
process.exit(1)
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
store.update({
|
|
258
|
-
screen: {
|
|
259
|
-
kind: "loading",
|
|
260
|
-
message: env.locale === "zh-Hans" ? "再试一次…" : "Trying again…",
|
|
261
|
-
},
|
|
262
|
-
})
|
|
263
|
-
const again = await serve.ensureReady()
|
|
264
|
-
if (again.kind === "timeout") {
|
|
265
|
-
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
|
|
266
|
-
} else {
|
|
267
|
-
store.update({ screen: { kind: "startup" } })
|
|
268
|
-
}
|
|
269
|
-
},
|
|
270
|
-
onQuit: async () => {
|
|
271
|
-
if (serve) await serve.shutdown()
|
|
272
|
-
process.exit(0)
|
|
273
|
-
},
|
|
274
|
-
onAbort: noop,
|
|
275
|
-
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
276
|
-
onPickPack: noop,
|
|
277
|
-
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
278
|
-
onMissionNext: noop,
|
|
279
|
-
onMissionBack: noop,
|
|
280
|
-
...setup,
|
|
281
|
-
}
|
|
306
|
+
return { audit, serve, client, session, subscriber, quit, handlers }
|
|
282
307
|
}
|
|
283
308
|
|
|
284
|
-
function
|
|
309
|
+
function makeFullHandlers(
|
|
285
310
|
store: Store,
|
|
286
|
-
env:
|
|
311
|
+
env: KidsClientEnv,
|
|
287
312
|
session: SessionManager,
|
|
288
313
|
client: OpencodeClient,
|
|
289
314
|
serve: ServeManager,
|
|
290
|
-
|
|
291
|
-
): AppHandlers {
|
|
315
|
+
): FullHandlers {
|
|
292
316
|
const updateLastSession = (): void => {
|
|
293
317
|
writeLastSession(env.configDir, {
|
|
294
318
|
coursePack: store.getSnapshot().coursePack,
|
|
@@ -329,10 +353,9 @@ function fullHandlers(
|
|
|
329
353
|
refreshContext()
|
|
330
354
|
flashToast(store, {
|
|
331
355
|
kind: "info",
|
|
332
|
-
text:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
: `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
|
|
356
|
+
text: env.locale === "zh-Hans"
|
|
357
|
+
? `继续上次:${last.coursePack}${last.mission ? " · " + last.mission : ""}`
|
|
358
|
+
: `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
|
|
336
359
|
})
|
|
337
360
|
} else {
|
|
338
361
|
flashToast(store, {
|
|
@@ -343,14 +366,12 @@ function fullHandlers(
|
|
|
343
366
|
store.update({ screen: { kind: "mission" } })
|
|
344
367
|
return
|
|
345
368
|
}
|
|
346
|
-
// mode === "free" (or unrecognised) — enter MissionScreen.
|
|
347
369
|
store.update({ screen: { kind: "mission" } })
|
|
348
370
|
},
|
|
349
371
|
onPrompt: async (text) => {
|
|
350
372
|
const snap = store.getSnapshot()
|
|
351
373
|
store.appendMessage({ id: `kid-${Date.now()}`, actor: "kid", text, streaming: false, ts: Date.now() })
|
|
352
374
|
|
|
353
|
-
// In-TUI mission check intercept. Don't even hit the LLM.
|
|
354
375
|
if (snap.mission && isCompletionTrigger(text, env.locale)) {
|
|
355
376
|
const outcome = runCheck({
|
|
356
377
|
missionId: snap.mission,
|
|
@@ -384,14 +405,12 @@ function fullHandlers(
|
|
|
384
405
|
return
|
|
385
406
|
}
|
|
386
407
|
|
|
387
|
-
// Dangerous topic intercept on kid input.
|
|
388
408
|
const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(text) : detectDangerousTopicEn(text)
|
|
389
409
|
if (hit) {
|
|
390
410
|
store.update({ dangerousTopic: { category: hit, snippet: text } })
|
|
391
411
|
return
|
|
392
412
|
}
|
|
393
413
|
|
|
394
|
-
// Normal LLM prompt.
|
|
395
414
|
store.update({ thinking: true })
|
|
396
415
|
updateLastSession()
|
|
397
416
|
try {
|
|
@@ -412,17 +431,23 @@ function fullHandlers(
|
|
|
412
431
|
if (decision === "edit") {
|
|
413
432
|
flashToast(store, {
|
|
414
433
|
kind: "info",
|
|
415
|
-
text:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
: "You take this step — tell the AI what you'd prefer",
|
|
434
|
+
text: env.locale === "zh-Hans"
|
|
435
|
+
? "你来改这一步,告诉 AI 你想怎么做"
|
|
436
|
+
: "You take this step — tell the AI what you'd prefer",
|
|
419
437
|
})
|
|
420
438
|
}
|
|
421
|
-
} catch {
|
|
422
|
-
|
|
423
|
-
|
|
439
|
+
} catch { /* SSE surfaces errors */ }
|
|
440
|
+
},
|
|
441
|
+
onAbort: async () => {
|
|
442
|
+
try {
|
|
443
|
+
await session.abort()
|
|
444
|
+
store.update({ thinking: false })
|
|
445
|
+
flashToast(store, {
|
|
446
|
+
kind: "warn",
|
|
447
|
+
text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
|
|
448
|
+
})
|
|
449
|
+
} catch { /* ignore */ }
|
|
424
450
|
},
|
|
425
|
-
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
426
451
|
onErrorRetry: async () => {
|
|
427
452
|
store.update({
|
|
428
453
|
screen: {
|
|
@@ -437,26 +462,11 @@ function fullHandlers(
|
|
|
437
462
|
store.update({ screen: { kind: "startup" } })
|
|
438
463
|
}
|
|
439
464
|
},
|
|
440
|
-
onQuit: quit,
|
|
441
|
-
onAbort: async () => {
|
|
442
|
-
try {
|
|
443
|
-
await session.abort()
|
|
444
|
-
store.update({ thinking: false })
|
|
445
|
-
flashToast(store, {
|
|
446
|
-
kind: "warn",
|
|
447
|
-
text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
|
|
448
|
-
})
|
|
449
|
-
} catch {
|
|
450
|
-
// ignore
|
|
451
|
-
}
|
|
452
|
-
},
|
|
453
|
-
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
454
465
|
onPickPack: (packId) => {
|
|
455
466
|
store.update({ coursePack: packId, mission: null })
|
|
456
467
|
refreshContext()
|
|
457
468
|
store.update({ screen: { kind: "mission" } })
|
|
458
469
|
},
|
|
459
|
-
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
460
470
|
onMissionNext: () => {
|
|
461
471
|
const snap = store.getSnapshot()
|
|
462
472
|
if (!snap.coursePack || !snap.mission) {
|
|
@@ -479,16 +489,37 @@ function fullHandlers(
|
|
|
479
489
|
text: env.locale === "zh-Hans" ? `开始:${next.title}` : `Starting: ${next.title}`,
|
|
480
490
|
})
|
|
481
491
|
},
|
|
482
|
-
onMissionBack: () => store.update({ screen: { kind: "mission" } }),
|
|
483
|
-
...makeSetupHandlers(store, env),
|
|
484
492
|
}
|
|
485
493
|
}
|
|
486
494
|
|
|
487
|
-
// ───
|
|
495
|
+
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
function applyCoursePackContext(env: KidsClientEnv, store: Store): void {
|
|
498
|
+
const ctx = resolveContext(env.coursePack, env.mission)
|
|
499
|
+
if (ctx) {
|
|
500
|
+
store.update({
|
|
501
|
+
packTitle: ctx.packTitle,
|
|
502
|
+
missionTitle: ctx.missionTitle,
|
|
503
|
+
missionIndex: ctx.missionIndex,
|
|
504
|
+
missionTotal: ctx.missionTotal,
|
|
505
|
+
starsBudget: ctx.starsBudget,
|
|
506
|
+
starsBalance: ctx.starsBudget,
|
|
507
|
+
})
|
|
508
|
+
} else if (env.coursePack) {
|
|
509
|
+
store.update({
|
|
510
|
+
toast: {
|
|
511
|
+
kind: "warn",
|
|
512
|
+
text: env.locale === "zh-Hans"
|
|
513
|
+
? `没找到 Course Pack: ${env.coursePack}(按 c 重新选)`
|
|
514
|
+
: `Course Pack not found: ${env.coursePack} (press c to pick)`,
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
}
|
|
488
519
|
|
|
489
520
|
function renderApp(
|
|
490
521
|
store: Store,
|
|
491
|
-
env:
|
|
522
|
+
env: KidsClientEnv,
|
|
492
523
|
installedPacks: InstalledPack[],
|
|
493
524
|
handlers: AppHandlers,
|
|
494
525
|
): void {
|
|
@@ -536,11 +567,6 @@ function errMessage(err: unknown): string {
|
|
|
536
567
|
return String(err)
|
|
537
568
|
}
|
|
538
569
|
|
|
539
|
-
// Touch findMission so TS doesn't complain about the import being unused
|
|
540
|
-
// when typecheck runs against the v0.0.1 SDK that doesn't expose v2 yet.
|
|
541
|
-
void findMission
|
|
542
|
-
void loadCoursePack
|
|
543
|
-
|
|
544
570
|
void main().catch((err) => {
|
|
545
571
|
console.error("kids-client: fatal startup error:", err)
|
|
546
572
|
process.exit(1)
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -41,6 +41,7 @@ export interface AppDeps {
|
|
|
41
41
|
onMissionNext: () => void
|
|
42
42
|
onMissionBack: () => void
|
|
43
43
|
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
44
|
+
onSetupContinue: () => Promise<void>
|
|
44
45
|
onSetupSkip: () => void
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -74,7 +75,7 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
74
75
|
case "loading":
|
|
75
76
|
return <LoadingScreen locale={deps.locale} message={state.screen.message} />
|
|
76
77
|
case "setup":
|
|
77
|
-
return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onSkip={deps.onSetupSkip} />
|
|
78
|
+
return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onContinue={deps.onSetupContinue} onSkip={deps.onSetupSkip} />
|
|
78
79
|
case "startup":
|
|
79
80
|
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
|
|
80
81
|
case "mission":
|
|
@@ -4,18 +4,17 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Audience: a parent (the kid sees the intro and is told to grab a
|
|
6
6
|
* grown-up). The wizard walks through:
|
|
7
|
+
* 0. engine_install (auto, only if upstream opencode CLI missing)
|
|
7
8
|
* 1. Welcome / "this part needs a grown-up"
|
|
8
9
|
* 2. Pick provider (Anthropic / OpenAI / DeepRouter)
|
|
9
10
|
* 3. Paste API key (with link to where to get one)
|
|
10
|
-
* 4. Save → re-
|
|
11
|
-
*
|
|
12
|
-
* The choice is persisted via core/setup.ts (writes ~/.config/kids-opencode/env
|
|
13
|
-
* + updates opencode.json provider section).
|
|
11
|
+
* 4. Save → continue inline (no re-exec) → MissionScreen
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
|
-
import React, { useState } from "react"
|
|
14
|
+
import React, { useEffect, useState } from "react"
|
|
17
15
|
import { Box, Text, useInput } from "ink"
|
|
18
16
|
import TextInput from "ink-text-input"
|
|
17
|
+
import Spinner from "ink-spinner"
|
|
19
18
|
import { getTheme } from "../theme.ts"
|
|
20
19
|
import { KidsLogo } from "../components/KidsLogo.tsx"
|
|
21
20
|
import {
|
|
@@ -24,25 +23,64 @@ import {
|
|
|
24
23
|
PROVIDERS,
|
|
25
24
|
type ProviderId,
|
|
26
25
|
} from "../../../core/setup.ts"
|
|
26
|
+
import { hasOpencodeBinary, installOpencode } from "../../../core/opencode-installer.ts"
|
|
27
27
|
|
|
28
|
-
type Step =
|
|
28
|
+
type Step =
|
|
29
|
+
| "engine_install"
|
|
30
|
+
| "engine_done"
|
|
31
|
+
| "intro"
|
|
32
|
+
| "provider"
|
|
33
|
+
| "apikey"
|
|
34
|
+
| "saving"
|
|
35
|
+
| "done"
|
|
36
|
+
| "error"
|
|
29
37
|
|
|
30
38
|
interface SetupScreenProps {
|
|
31
39
|
locale: "zh-Hans" | "en"
|
|
32
40
|
onSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
41
|
+
/** After save, kicks off inline boot. Resolves when AI is ready. */
|
|
42
|
+
onContinue: () => Promise<void>
|
|
43
|
+
/** Skip key — useful for advanced users who set env vars themselves. */
|
|
33
44
|
onSkip: () => void
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React.ReactElement {
|
|
47
|
+
export function SetupScreen({ locale, onSave, onContinue, onSkip }: SetupScreenProps): React.ReactElement {
|
|
37
48
|
const theme = getTheme()
|
|
38
49
|
const t = STRINGS[locale]
|
|
39
|
-
const
|
|
50
|
+
const initialStep: Step = hasOpencodeBinary() ? "intro" : "engine_install"
|
|
51
|
+
const [step, setStep] = useState<Step>(initialStep)
|
|
40
52
|
const [providerIdx, setProviderIdx] = useState(0)
|
|
41
53
|
const [apiKey, setApiKey] = useState("")
|
|
42
54
|
const [errorMsg, setErrorMsg] = useState("")
|
|
55
|
+
const [engineLog, setEngineLog] = useState<string[]>([])
|
|
56
|
+
const [engineRunning, setEngineRunning] = useState(false)
|
|
57
|
+
|
|
58
|
+
// Auto-trigger engine install once on first render.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (initialStep === "engine_install" && !engineRunning) {
|
|
61
|
+
setEngineRunning(true)
|
|
62
|
+
void installOpencode((line) => {
|
|
63
|
+
setEngineLog((prev) => {
|
|
64
|
+
const next = [...prev, line]
|
|
65
|
+
return next.length > 8 ? next.slice(next.length - 8) : next
|
|
66
|
+
})
|
|
67
|
+
}).then((result) => {
|
|
68
|
+
setEngineRunning(false)
|
|
69
|
+
if (result.ok) {
|
|
70
|
+
setStep("engine_done")
|
|
71
|
+
} else {
|
|
72
|
+
setErrorMsg(result.error ?? "engine install failed")
|
|
73
|
+
setStep("error")
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [])
|
|
43
79
|
|
|
44
80
|
useInput((input, key) => {
|
|
45
|
-
if (step === "
|
|
81
|
+
if (step === "engine_done") {
|
|
82
|
+
if (key.return) setStep("intro")
|
|
83
|
+
} else if (step === "intro") {
|
|
46
84
|
if (key.return) setStep("provider")
|
|
47
85
|
else if (input === "s" || input === "S") onSkip()
|
|
48
86
|
} else if (step === "provider") {
|
|
@@ -51,15 +89,77 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
|
|
|
51
89
|
else if (key.return) setStep("apikey")
|
|
52
90
|
else if (key.escape) setStep("intro")
|
|
53
91
|
} else if (step === "done") {
|
|
54
|
-
if (key.return)
|
|
92
|
+
if (key.return) {
|
|
93
|
+
// Inline boot — no exit.
|
|
94
|
+
void onContinue()
|
|
95
|
+
}
|
|
55
96
|
} else if (step === "error") {
|
|
56
|
-
if (key.return)
|
|
97
|
+
if (key.return) {
|
|
98
|
+
// From any failure step, retry from apikey unless engine failed
|
|
99
|
+
setStep(engineLog.length > 0 && !hasOpencodeBinary() ? "engine_install" : "apikey")
|
|
100
|
+
if (engineLog.length > 0 && !hasOpencodeBinary()) {
|
|
101
|
+
// Restart engine install
|
|
102
|
+
setEngineLog([])
|
|
103
|
+
setEngineRunning(true)
|
|
104
|
+
void installOpencode((line) => {
|
|
105
|
+
setEngineLog((prev) => {
|
|
106
|
+
const next = [...prev, line]
|
|
107
|
+
return next.length > 8 ? next.slice(next.length - 8) : next
|
|
108
|
+
})
|
|
109
|
+
}).then((result) => {
|
|
110
|
+
setEngineRunning(false)
|
|
111
|
+
if (result.ok) setStep("engine_done")
|
|
112
|
+
else {
|
|
113
|
+
setErrorMsg(result.error ?? "engine install failed")
|
|
114
|
+
setStep("error")
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
57
119
|
}
|
|
58
120
|
})
|
|
59
121
|
|
|
60
122
|
const provider = PROVIDERS[providerIdx]!
|
|
61
123
|
const providerObj = findProvider(provider.id)
|
|
62
124
|
|
|
125
|
+
if (step === "engine_install") {
|
|
126
|
+
return (
|
|
127
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
128
|
+
<KidsLogo />
|
|
129
|
+
<Box marginTop={2} borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1} flexDirection="column">
|
|
130
|
+
<Box>
|
|
131
|
+
<Text color={theme.accent}>{engineRunning ? <Spinner type="dots" /> : " "}</Text>
|
|
132
|
+
<Text color={theme.accent} bold> {t.engineInstalling}</Text>
|
|
133
|
+
</Box>
|
|
134
|
+
<Box marginTop={1}>
|
|
135
|
+
<Text color={theme.fgDim}>{t.engineHint}</Text>
|
|
136
|
+
</Box>
|
|
137
|
+
{engineLog.length > 0 && (
|
|
138
|
+
<Box marginTop={1} flexDirection="column">
|
|
139
|
+
{engineLog.map((line, i) => (
|
|
140
|
+
<Text key={i} color={theme.fgDim} dimColor> {line}</Text>
|
|
141
|
+
))}
|
|
142
|
+
</Box>
|
|
143
|
+
)}
|
|
144
|
+
</Box>
|
|
145
|
+
</Box>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (step === "engine_done") {
|
|
150
|
+
return (
|
|
151
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
152
|
+
<KidsLogo />
|
|
153
|
+
<Box marginTop={2} borderStyle="round" borderColor={theme.success} paddingX={2} paddingY={1}>
|
|
154
|
+
<Text color={theme.success} bold>{t.engineDone}</Text>
|
|
155
|
+
</Box>
|
|
156
|
+
<Box marginTop={1}>
|
|
157
|
+
<Text color={theme.accent}>{t.continueHint}</Text>
|
|
158
|
+
</Box>
|
|
159
|
+
</Box>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
63
163
|
if (step === "intro") {
|
|
64
164
|
return (
|
|
65
165
|
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
@@ -150,7 +250,10 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
|
|
|
150
250
|
if (step === "saving") {
|
|
151
251
|
return (
|
|
152
252
|
<Box paddingY={1} paddingX={2}>
|
|
153
|
-
<Text color={theme.accent}>
|
|
253
|
+
<Text color={theme.accent}>
|
|
254
|
+
<Spinner type="dots" />
|
|
255
|
+
</Text>
|
|
256
|
+
<Text color={theme.accent}> {t.saving}</Text>
|
|
154
257
|
</Box>
|
|
155
258
|
)
|
|
156
259
|
}
|
|
@@ -188,6 +291,10 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
|
|
|
188
291
|
|
|
189
292
|
const STRINGS = {
|
|
190
293
|
"zh-Hans": {
|
|
294
|
+
engineInstalling: "正在安装 AI 引擎…",
|
|
295
|
+
engineHint: "从 opencode.ai 下载(大约 30 秒)。卡住的话按 Enter 重试。",
|
|
296
|
+
engineDone: "✓ AI 引擎安装完成",
|
|
297
|
+
continueHint: "[Enter] 下一步",
|
|
191
298
|
introTitle: "👋 这一步需要家长帮忙",
|
|
192
299
|
introLine1: "AI 老师要用一个 \"API key\" 才能工作 —— 就像给它一把钥匙。",
|
|
193
300
|
introLine2: "家长打开账号给 AI 服务(Anthropic / OpenAI 等),拿到 key 粘进来就行。",
|
|
@@ -205,10 +312,14 @@ const STRINGS = {
|
|
|
205
312
|
errTitle: "出了点问题",
|
|
206
313
|
errRetry: "[Enter] 再试",
|
|
207
314
|
doneTitle: "🎉 搞定!家长任务完成。",
|
|
208
|
-
doneNext: "
|
|
209
|
-
doneHint: "[Enter]
|
|
315
|
+
doneNext: "马上启动,让孩子继续。",
|
|
316
|
+
doneHint: "[Enter] 启动",
|
|
210
317
|
},
|
|
211
318
|
en: {
|
|
319
|
+
engineInstalling: "Setting up the AI engine…",
|
|
320
|
+
engineHint: "Downloading from opencode.ai (about 30 seconds). Stuck? Press Enter to retry.",
|
|
321
|
+
engineDone: "✓ AI engine ready",
|
|
322
|
+
continueHint: "[Enter] Next",
|
|
212
323
|
introTitle: "👋 Grown-up help needed for this part",
|
|
213
324
|
introLine1: "The AI teacher needs an \"API key\" to work — think of it as a password.",
|
|
214
325
|
introLine2: "A parent opens an account with an AI service (Anthropic / OpenAI), copies the key, pastes it here.",
|
|
@@ -226,7 +337,7 @@ const STRINGS = {
|
|
|
226
337
|
errTitle: "Something went wrong",
|
|
227
338
|
errRetry: "[Enter] Try again",
|
|
228
339
|
doneTitle: "🎉 All set! Grown-up step done.",
|
|
229
|
-
doneNext: "
|
|
340
|
+
doneNext: "Starting up now.",
|
|
230
341
|
doneHint: "[Enter] Start",
|
|
231
342
|
},
|
|
232
343
|
} as const
|