@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.
package/src/index.tsx ADDED
@@ -0,0 +1,513 @@
1
+ /**
2
+ * kids-client entry. Composes core/* and renders the Ink app.
3
+ *
4
+ * Boot sequence:
5
+ * 1. readEnv() — pull KIDS_* + OPENCODE_* from process.env
6
+ * 2. validateEnv() — hard-fail with ErrorScreen if password/key missing
7
+ * 3. ServeManager.ensureReady() — spawn `opencode serve` if down, poll /app
8
+ * 4. createKidsClient() — instantiate SDK v2 client w/ Basic Auth
9
+ * 5. SessionManager — ready to prompt
10
+ * 6. EventSubscriber.run() — SSE loop dispatches to store
11
+ * 7. Ink render(<App />) — kid sees Startup screen, picks a flow
12
+ *
13
+ * Cleanup on SIGINT / SIGTERM: stop subscriber, stop audit pipeline,
14
+ * kill serve child, exit. (V0 MVP: client crash takes serve with it.)
15
+ */
16
+
17
+ import React from "react"
18
+ import { render } from "ink"
19
+ import { join } from "node:path"
20
+ import { readEnv, validateEnv } from "./core/env.ts"
21
+ import { ServeManager } from "./core/serve-manager.ts"
22
+ import { createKidsClient, type OpencodeClient } from "./core/connection.ts"
23
+ import { SessionManager } from "./core/session.ts"
24
+ import { EventSubscriber } from "./core/events.ts"
25
+ import { AuditPipeline } from "./core/audit-pipeline.ts"
26
+ import { Store } from "./core/store.ts"
27
+ import { listInstalledPacks, resolveContext } from "./core/course-pack.ts"
28
+ import { readLastSession, writeLastSession } from "./core/last-session.ts"
29
+ import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
30
+ import { App } from "./render/ink/App.tsx"
31
+ import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
32
+ import type { InstalledPack } from "./core/course-pack.ts"
33
+ import { findMission, loadCoursePack } from "@kidsinai/kids-opencode-plugin"
34
+
35
+ async function main(): Promise<void> {
36
+ const env = readEnv()
37
+ const store = new Store()
38
+ const installedPacks = listInstalledPacks()
39
+ store.update({
40
+ coursePack: env.coursePack,
41
+ mission: env.mission,
42
+ screen: { kind: "loading", message: env.locale === "zh-Hans" ? "正在唤醒 AI 老师…" : "Waking up the AI teacher…" },
43
+ })
44
+
45
+ const check = validateEnv(env)
46
+ if (!check.ok) {
47
+ store.update({ screen: { kind: "error", variant: check.variant, detail: check.reason } })
48
+ renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
49
+ return
50
+ }
51
+
52
+ // Resolve course pack context up front (free-play if coursePack is null).
53
+ const ctx = resolveContext(env.coursePack, env.mission)
54
+ if (ctx) {
55
+ store.update({
56
+ packTitle: ctx.packTitle,
57
+ missionTitle: ctx.missionTitle,
58
+ missionIndex: ctx.missionIndex,
59
+ missionTotal: ctx.missionTotal,
60
+ starsBudget: ctx.starsBudget,
61
+ starsBalance: ctx.starsBudget, // start full; charges deduct via audit hook
62
+ })
63
+ } else if (env.coursePack) {
64
+ // Pack id provided but not found — surface as a toast on the startup screen.
65
+ store.update({
66
+ toast: {
67
+ kind: "warn",
68
+ text: env.locale === "zh-Hans"
69
+ ? `没找到 Course Pack: ${env.coursePack}(按 c 重新选)`
70
+ : `Course Pack not found: ${env.coursePack} (press c to pick)`,
71
+ },
72
+ })
73
+ }
74
+
75
+ const audit = new AuditPipeline({
76
+ bufferPath: join(env.configDir, "audit-buffer.jsonl"),
77
+ })
78
+ audit.start()
79
+
80
+ const serve = new ServeManager({
81
+ baseUrl: env.opencodeBaseUrl,
82
+ serverPassword: env.opencodeServerPassword,
83
+ opencodeBin: env.opencodeBin,
84
+ onAuditLine: (event) => {
85
+ audit.push(event)
86
+ store.pushAudit(event)
87
+ handlePluginAudit(event, store)
88
+ },
89
+ })
90
+
91
+ const readiness = await serve.ensureReady()
92
+ if (readiness.kind === "timeout") {
93
+ store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
94
+ renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, serve))
95
+ return
96
+ }
97
+
98
+ const client = createKidsClient({
99
+ baseUrl: env.opencodeBaseUrl,
100
+ serverPassword: env.opencodeServerPassword,
101
+ })
102
+ const session = new SessionManager(client)
103
+
104
+ const subscriber = new EventSubscriber(client, {
105
+ onSessionCreated: (e) => {
106
+ store.update({ sessionId: e.sessionID })
107
+ writeLastSession(env.configDir, {
108
+ coursePack: env.coursePack,
109
+ mission: env.mission,
110
+ lastActiveAt: new Date().toISOString(),
111
+ projectDir: process.cwd(),
112
+ })
113
+ },
114
+ onMessagePartDelta: (e) => {
115
+ const snap = store.getSnapshot()
116
+ const active = snap.messages.find((m) => m.streaming && m.actor === "agent" && m.id === e.messageID)
117
+ if (!active) {
118
+ store.appendMessage({ id: e.messageID, actor: "agent", text: "", streaming: true, ts: Date.now() })
119
+ }
120
+ store.appendDelta(e.messageID, e.delta)
121
+ const message = store.getSnapshot().messages.find((m) => m.id === e.messageID)
122
+ if (message) {
123
+ const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(message.text) : detectDangerousTopicEn(message.text)
124
+ if (hit && !store.getSnapshot().dangerousTopic) {
125
+ store.update({ dangerousTopic: { category: hit, snippet: message.text.slice(-200) } })
126
+ }
127
+ }
128
+ },
129
+ onTextEnded: (e) => store.endStream(e.messageID),
130
+ onPermissionAsked: (e) => {
131
+ // pickup of stars_estimated from the latest plugin audit event.
132
+ const recentAudit = store.getSnapshot().auditBuffer.slice(-10).reverse() as Array<Record<string, unknown>>
133
+ const matching = recentAudit.find(
134
+ (a) => a && typeof a === "object" && a.event === "tool.execute.before" && a.tool === e.tool,
135
+ )
136
+ const starsEstimated = typeof matching?.stars_estimated === "number" ? (matching.stars_estimated as number) : undefined
137
+ store.update({
138
+ pendingPermission: {
139
+ requestID: e.requestID,
140
+ tool: e.tool,
141
+ summary: summarisePermission(e, env.locale),
142
+ metadata: e.metadata ?? {},
143
+ starsEstimated,
144
+ },
145
+ })
146
+ },
147
+ onLlmError: (e) => {
148
+ store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: e.message } })
149
+ },
150
+ onCompactionEnded: () => {
151
+ flashToast(store, {
152
+ kind: "info",
153
+ text: env.locale === "zh-Hans" ? "上下文压缩完成 ✓" : "Context compacted ✓",
154
+ })
155
+ },
156
+ onDisconnected: (reason) => {
157
+ store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: reason } })
158
+ },
159
+ onReconnected: () => {
160
+ flashToast(store, {
161
+ kind: "success",
162
+ text: env.locale === "zh-Hans" ? "重新连上了 ✓" : "Reconnected ✓",
163
+ })
164
+ },
165
+ })
166
+ void subscriber.run()
167
+
168
+ // First screen: startup. If env already had a course pack, kid can either
169
+ // jump straight in (Enter) or pick a different one (c).
170
+ store.update({ screen: { kind: "startup" } })
171
+
172
+ const handleQuit = async (): Promise<void> => {
173
+ subscriber.stop()
174
+ await audit.stop()
175
+ await serve.shutdown()
176
+ process.exit(0)
177
+ }
178
+ process.on("SIGINT", () => void handleQuit())
179
+ process.on("SIGTERM", () => void handleQuit())
180
+
181
+ const handlers = fullHandlers(store, env, session, client, serve, handleQuit)
182
+ renderApp(store, env, installedPacks, handlers)
183
+ }
184
+
185
+ // ─── handler factories ───────────────────────────────────────────────────
186
+
187
+ interface AppHandlers {
188
+ onStart: (mode: "free" | "course" | "resume" | "help") => void
189
+ onPrompt: (text: string) => void
190
+ onPermissionReply: (decision: "allow" | "deny" | "edit") => void
191
+ onDangerousAcknowledge: () => void
192
+ onErrorRetry: () => void | Promise<void>
193
+ onQuit: () => void | Promise<void>
194
+ onAbort: () => void
195
+ onHelpBack: () => void
196
+ onPickPack: (packId: string) => void
197
+ onPickerBack: () => void
198
+ onMissionNext: () => void
199
+ onMissionBack: () => void
200
+ }
201
+
202
+ /**
203
+ * Minimal handlers for the pre-validation / pre-readiness error path.
204
+ * Many actions are no-ops because the app isn't fully booted; quit is
205
+ * the realistic action.
206
+ */
207
+ function baseHandlers(
208
+ store: Store,
209
+ env: ReturnType<typeof readEnv>,
210
+ _session: SessionManager | null,
211
+ _client: OpencodeClient | null,
212
+ serve: ServeManager | null,
213
+ ): AppHandlers {
214
+ const noop = (): void => {}
215
+ return {
216
+ onStart: noop,
217
+ onPrompt: noop,
218
+ onPermissionReply: noop,
219
+ onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
220
+ onErrorRetry: async () => {
221
+ if (!serve) {
222
+ process.exit(1)
223
+ return
224
+ }
225
+ store.update({
226
+ screen: {
227
+ kind: "loading",
228
+ message: env.locale === "zh-Hans" ? "再试一次…" : "Trying again…",
229
+ },
230
+ })
231
+ const again = await serve.ensureReady()
232
+ if (again.kind === "timeout") {
233
+ store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
234
+ } else {
235
+ store.update({ screen: { kind: "startup" } })
236
+ }
237
+ },
238
+ onQuit: async () => {
239
+ if (serve) await serve.shutdown()
240
+ process.exit(0)
241
+ },
242
+ onAbort: noop,
243
+ onHelpBack: () => store.update({ screen: { kind: "startup" } }),
244
+ onPickPack: noop,
245
+ onPickerBack: () => store.update({ screen: { kind: "startup" } }),
246
+ onMissionNext: noop,
247
+ onMissionBack: noop,
248
+ }
249
+ }
250
+
251
+ function fullHandlers(
252
+ store: Store,
253
+ env: ReturnType<typeof readEnv>,
254
+ session: SessionManager,
255
+ client: OpencodeClient,
256
+ serve: ServeManager,
257
+ quit: () => Promise<void>,
258
+ ): AppHandlers {
259
+ const updateLastSession = (): void => {
260
+ writeLastSession(env.configDir, {
261
+ coursePack: store.getSnapshot().coursePack,
262
+ mission: store.getSnapshot().mission,
263
+ lastActiveAt: new Date().toISOString(),
264
+ projectDir: process.cwd(),
265
+ })
266
+ }
267
+ const refreshContext = (): void => {
268
+ const snap = store.getSnapshot()
269
+ const ctx = resolveContext(snap.coursePack, snap.mission)
270
+ if (ctx) {
271
+ store.update({
272
+ packTitle: ctx.packTitle,
273
+ missionTitle: ctx.missionTitle,
274
+ missionIndex: ctx.missionIndex,
275
+ missionTotal: ctx.missionTotal,
276
+ starsBudget: ctx.starsBudget,
277
+ starsBalance: ctx.starsBudget,
278
+ })
279
+ }
280
+ }
281
+
282
+ return {
283
+ onStart: (mode) => {
284
+ if (mode === "help") {
285
+ store.update({ screen: { kind: "help" } })
286
+ return
287
+ }
288
+ if (mode === "course") {
289
+ store.update({ screen: { kind: "course_picker" } })
290
+ return
291
+ }
292
+ if (mode === "resume") {
293
+ const last = readLastSession(env.configDir)
294
+ if (last && last.coursePack) {
295
+ store.update({ coursePack: last.coursePack, mission: last.mission })
296
+ refreshContext()
297
+ flashToast(store, {
298
+ kind: "info",
299
+ text:
300
+ env.locale === "zh-Hans"
301
+ ? `继续上次:${last.coursePack}${last.mission ? " · " + last.mission : ""}`
302
+ : `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
303
+ })
304
+ } else {
305
+ flashToast(store, {
306
+ kind: "warn",
307
+ text: env.locale === "zh-Hans" ? "没找到上次的项目,先开始一个新的" : "No previous project found — starting fresh",
308
+ })
309
+ }
310
+ store.update({ screen: { kind: "mission" } })
311
+ return
312
+ }
313
+ // mode === "free" (or unrecognised) — enter MissionScreen.
314
+ store.update({ screen: { kind: "mission" } })
315
+ },
316
+ onPrompt: async (text) => {
317
+ const snap = store.getSnapshot()
318
+ store.appendMessage({ id: `kid-${Date.now()}`, actor: "kid", text, streaming: false, ts: Date.now() })
319
+
320
+ // In-TUI mission check intercept. Don't even hit the LLM.
321
+ if (snap.mission && isCompletionTrigger(text, env.locale)) {
322
+ const outcome = runCheck({
323
+ missionId: snap.mission,
324
+ packId: snap.coursePack ?? "",
325
+ locale: env.locale,
326
+ })
327
+ store.appendMessage({
328
+ id: `sys-${Date.now()}`,
329
+ actor: "system",
330
+ text: outcome.message + (outcome.details.length ? "\n" + outcome.details.join("\n") : ""),
331
+ streaming: false,
332
+ ts: Date.now(),
333
+ })
334
+ if (outcome.kind === "pass" && snap.coursePack && snap.mission) {
335
+ const pack = loadCoursePack(snap.coursePack)
336
+ const missions = pack?.missions ?? []
337
+ const idx = missions.findIndex((m) => m.id === snap.mission)
338
+ const hasNext = idx >= 0 && idx + 1 < missions.length
339
+ store.update({
340
+ screen: {
341
+ kind: "mission_complete",
342
+ missionId: snap.mission,
343
+ missionTitle: snap.missionTitle,
344
+ passed: outcome.result?.passed ?? 0,
345
+ total: outcome.result?.total ?? 0,
346
+ completionMessage: outcome.result?.completion_message ?? outcome.message,
347
+ hasNextMission: hasNext,
348
+ },
349
+ })
350
+ }
351
+ return
352
+ }
353
+
354
+ // Dangerous topic intercept on kid input.
355
+ const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(text) : detectDangerousTopicEn(text)
356
+ if (hit) {
357
+ store.update({ dangerousTopic: { category: hit, snippet: text } })
358
+ return
359
+ }
360
+
361
+ // Normal LLM prompt.
362
+ store.update({ thinking: true })
363
+ updateLastSession()
364
+ try {
365
+ await session.prompt(text)
366
+ } catch (err) {
367
+ store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: errMessage(err) } })
368
+ }
369
+ },
370
+ onPermissionReply: async (decision) => {
371
+ const snap = store.getSnapshot()
372
+ const pending = snap.pendingPermission
373
+ if (!pending) return
374
+ store.update({ pendingPermission: null })
375
+ try {
376
+ const reply = decision === "allow" ? "once" : "reject"
377
+ const api = (client as unknown as { permission?: { reply: (id: string, body: unknown) => Promise<unknown> } }).permission
378
+ await api?.reply(pending.requestID, { reply })
379
+ if (decision === "edit") {
380
+ flashToast(store, {
381
+ kind: "info",
382
+ text:
383
+ env.locale === "zh-Hans"
384
+ ? "你来改这一步,告诉 AI 你想怎么做"
385
+ : "You take this step — tell the AI what you'd prefer",
386
+ })
387
+ }
388
+ } catch {
389
+ // SSE will surface the timeout / error via onLlmError.
390
+ }
391
+ },
392
+ onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
393
+ onErrorRetry: async () => {
394
+ store.update({
395
+ screen: {
396
+ kind: "loading",
397
+ message: env.locale === "zh-Hans" ? "再试一次…" : "Trying again…",
398
+ },
399
+ })
400
+ const again = await serve.ensureReady()
401
+ if (again.kind === "timeout") {
402
+ store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
403
+ } else {
404
+ store.update({ screen: { kind: "startup" } })
405
+ }
406
+ },
407
+ onQuit: quit,
408
+ onAbort: async () => {
409
+ try {
410
+ await session.abort()
411
+ store.update({ thinking: false })
412
+ flashToast(store, {
413
+ kind: "warn",
414
+ text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
415
+ })
416
+ } catch {
417
+ // ignore
418
+ }
419
+ },
420
+ onHelpBack: () => store.update({ screen: { kind: "startup" } }),
421
+ onPickPack: (packId) => {
422
+ store.update({ coursePack: packId, mission: null })
423
+ refreshContext()
424
+ store.update({ screen: { kind: "mission" } })
425
+ },
426
+ onPickerBack: () => store.update({ screen: { kind: "startup" } }),
427
+ onMissionNext: () => {
428
+ const snap = store.getSnapshot()
429
+ if (!snap.coursePack || !snap.mission) {
430
+ store.update({ screen: { kind: "mission" } })
431
+ return
432
+ }
433
+ const pack = loadCoursePack(snap.coursePack)
434
+ const missions = pack?.missions ?? []
435
+ const idx = missions.findIndex((m) => m.id === snap.mission)
436
+ const next = idx >= 0 && idx + 1 < missions.length ? missions[idx + 1] : null
437
+ if (!next) {
438
+ store.update({ screen: { kind: "mission" } })
439
+ return
440
+ }
441
+ store.update({ mission: next.id })
442
+ refreshContext()
443
+ store.update({ screen: { kind: "mission" } })
444
+ flashToast(store, {
445
+ kind: "success",
446
+ text: env.locale === "zh-Hans" ? `开始:${next.title}` : `Starting: ${next.title}`,
447
+ })
448
+ },
449
+ onMissionBack: () => store.update({ screen: { kind: "mission" } }),
450
+ }
451
+ }
452
+
453
+ // ─── utilities ───────────────────────────────────────────────────────────
454
+
455
+ function renderApp(
456
+ store: Store,
457
+ env: ReturnType<typeof readEnv>,
458
+ installedPacks: InstalledPack[],
459
+ handlers: AppHandlers,
460
+ ): void {
461
+ render(
462
+ React.createElement(App, {
463
+ store,
464
+ locale: env.locale,
465
+ installedPacks,
466
+ ...handlers,
467
+ }),
468
+ )
469
+ }
470
+
471
+ function summarisePermission(
472
+ e: { tool?: string; metadata?: Record<string, unknown> },
473
+ locale: "zh-Hans" | "en",
474
+ ): string {
475
+ if (locale === "zh-Hans") return `AI 想用「${e.tool ?? "工具"}」做下一步`
476
+ return `The AI wants to use "${e.tool ?? "a tool"}"`
477
+ }
478
+
479
+ function handlePluginAudit(event: unknown, store: Store): void {
480
+ const e = event as { event?: string; stars_charged?: number; stars_estimated?: number }
481
+ if (!e || typeof e.event !== "string") return
482
+ if (e.event === "tool.execute.after" && typeof e.stars_charged === "number") {
483
+ const snap = store.getSnapshot()
484
+ const newBalance = Math.max(0, snap.starsBalance - e.stars_charged)
485
+ store.update({ starsBalance: newBalance })
486
+ }
487
+ }
488
+
489
+ const TOAST_TTL_MS = 3500
490
+ function flashToast(store: Store, toast: { kind: "info" | "warn" | "success"; text: string }): void {
491
+ store.update({ toast })
492
+ setTimeout(() => {
493
+ const snap = store.getSnapshot()
494
+ if (snap.toast?.text === toast.text) {
495
+ store.update({ toast: null })
496
+ }
497
+ }, TOAST_TTL_MS)
498
+ }
499
+
500
+ function errMessage(err: unknown): string {
501
+ if (err instanceof Error) return err.message
502
+ return String(err)
503
+ }
504
+
505
+ // Touch findMission so TS doesn't complain about the import being unused
506
+ // when typecheck runs against the v0.0.1 SDK that doesn't expose v2 yet.
507
+ void findMission
508
+ void loadCoursePack
509
+
510
+ void main().catch((err) => {
511
+ console.error("kids-client: fatal startup error:", err)
512
+ process.exit(1)
513
+ })
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Top-level router. Reads store snapshot → picks which screen to render.
3
+ *
4
+ * The router is intentionally thin. All state mutation happens in the
5
+ * core/ layer; this component only translates state.screen → JSX.
6
+ *
7
+ * Modal overlays (dangerous-topic, pending-permission) preempt the
8
+ * routed screen — they're absolute-priority overlays that the kid must
9
+ * dispatch before the underlying screen can interact again.
10
+ */
11
+
12
+ import React, { useSyncExternalStore } from "react"
13
+ import type { InstalledPack } from "../../core/course-pack.ts"
14
+ import type { Store } from "../../core/store.ts"
15
+ import { StartupScreen } from "./screens/StartupScreen.tsx"
16
+ import { MissionScreen } from "./screens/MissionScreen.tsx"
17
+ import { PermissionModal } from "./screens/PermissionModal.tsx"
18
+ import { DangerousTopicModal } from "./screens/DangerousTopicModal.tsx"
19
+ import { ErrorScreen } from "./screens/ErrorScreen.tsx"
20
+ import { HelpScreen } from "./screens/HelpScreen.tsx"
21
+ import { CoursePackPicker } from "./screens/CoursePackPicker.tsx"
22
+ import { MissionCompleteScreen } from "./screens/MissionCompleteScreen.tsx"
23
+ import { LoadingScreen } from "./screens/LoadingScreen.tsx"
24
+
25
+ export interface AppDeps {
26
+ store: Store
27
+ locale: "zh-Hans" | "en"
28
+ installedPacks: InstalledPack[]
29
+ onStart: (mode: "free" | "course" | "resume" | "help") => void
30
+ onPrompt: (text: string) => void
31
+ onPermissionReply: (decision: "allow" | "deny" | "edit") => void
32
+ onDangerousAcknowledge: () => void
33
+ onErrorRetry: () => void
34
+ onQuit: () => void
35
+ onAbort: () => void
36
+ onHelpBack: () => void
37
+ onPickPack: (packId: string) => void
38
+ onPickerBack: () => void
39
+ onMissionNext: () => void
40
+ onMissionBack: () => void
41
+ }
42
+
43
+ export function App(deps: AppDeps): React.ReactElement {
44
+ const state = useSyncExternalStore(
45
+ (cb) => deps.store.subscribe(cb),
46
+ () => deps.store.getSnapshot(),
47
+ () => deps.store.getSnapshot(),
48
+ )
49
+
50
+ // Dangerous-topic overlay takes absolute priority — it has to be the
51
+ // first thing on screen the moment a pattern hits, even mid-stream.
52
+ if (state.dangerousTopic) {
53
+ return <DangerousTopicModal topic={state.dangerousTopic} locale={deps.locale} onAcknowledge={deps.onDangerousAcknowledge} />
54
+ }
55
+
56
+ // Permission modal is the next-highest priority.
57
+ if (state.pendingPermission) {
58
+ return (
59
+ <PermissionModal
60
+ permission={state.pendingPermission}
61
+ locale={deps.locale}
62
+ onAllow={() => deps.onPermissionReply("allow")}
63
+ onDeny={() => deps.onPermissionReply("deny")}
64
+ onEdit={() => deps.onPermissionReply("edit")}
65
+ />
66
+ )
67
+ }
68
+
69
+ switch (state.screen.kind) {
70
+ case "loading":
71
+ return <LoadingScreen locale={deps.locale} message={state.screen.message} />
72
+ case "startup":
73
+ return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
74
+ case "mission":
75
+ return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
76
+ case "help":
77
+ return <HelpScreen locale={deps.locale} onBack={deps.onHelpBack} />
78
+ case "course_picker":
79
+ return (
80
+ <CoursePackPicker
81
+ locale={deps.locale}
82
+ packs={deps.installedPacks}
83
+ onPick={deps.onPickPack}
84
+ onBack={deps.onPickerBack}
85
+ />
86
+ )
87
+ case "mission_complete":
88
+ return (
89
+ <MissionCompleteScreen
90
+ locale={deps.locale}
91
+ missionId={state.screen.missionId}
92
+ missionTitle={state.screen.missionTitle}
93
+ passed={state.screen.passed}
94
+ total={state.screen.total}
95
+ completionMessage={state.screen.completionMessage}
96
+ hasNextMission={state.screen.hasNextMission}
97
+ onNext={deps.onMissionNext}
98
+ onBack={deps.onMissionBack}
99
+ />
100
+ )
101
+ case "error":
102
+ return (
103
+ <ErrorScreen
104
+ variant={state.screen.variant}
105
+ detail={state.screen.detail}
106
+ locale={deps.locale}
107
+ onRetry={deps.onErrorRetry}
108
+ onQuit={deps.onQuit}
109
+ />
110
+ )
111
+ }
112
+ }
@@ -0,0 +1,62 @@
1
+ import React from "react"
2
+ import { Box, Static, Text } from "ink"
3
+ import { getTheme } from "../theme.ts"
4
+ import type { ChatMessage } from "../../../core/store.ts"
5
+
6
+ interface ChatStreamProps {
7
+ messages: ChatMessage[]
8
+ }
9
+
10
+ export function ChatStream({ messages }: ChatStreamProps): React.ReactElement {
11
+ const theme = getTheme()
12
+ if (messages.length === 0) return <Box />
13
+ // Settled messages go into <Static> so Ink doesn't re-render the entire
14
+ // history every delta. The active streaming message (if any) renders
15
+ // below in the live region.
16
+ const finished = messages.filter((m) => !m.streaming)
17
+ const active = messages.find((m) => m.streaming)
18
+ return (
19
+ <Box flexDirection="column">
20
+ <Static items={finished}>
21
+ {(m) => (
22
+ <Box key={m.id} flexDirection="row" marginBottom={1}>
23
+ <ActorBadge actor={m.actor} />
24
+ <Box flexDirection="column" flexGrow={1}>
25
+ <Text color={colorFor(m.actor, theme)}>{m.text || " "}</Text>
26
+ </Box>
27
+ </Box>
28
+ )}
29
+ </Static>
30
+ {active && (
31
+ <Box flexDirection="row" marginBottom={1}>
32
+ <ActorBadge actor={active.actor} />
33
+ <Box flexDirection="column" flexGrow={1}>
34
+ <Text color={colorFor(active.actor, theme)}>{active.text || " "}</Text>
35
+ </Box>
36
+ </Box>
37
+ )}
38
+ </Box>
39
+ )
40
+ }
41
+
42
+ function ActorBadge({ actor }: { actor: ChatMessage["actor"] }): React.ReactElement {
43
+ const theme = getTheme()
44
+ const emoji = actor === "kid" ? "👦" : actor === "agent" ? "🤖" : "⚙️"
45
+ const color = colorFor(actor, theme)
46
+ return (
47
+ <Box marginRight={1}>
48
+ <Text color={color}>{emoji}</Text>
49
+ </Box>
50
+ )
51
+ }
52
+
53
+ function colorFor(actor: ChatMessage["actor"], theme: ReturnType<typeof getTheme>): string {
54
+ switch (actor) {
55
+ case "kid":
56
+ return theme.kid
57
+ case "agent":
58
+ return theme.agent
59
+ case "system":
60
+ return theme.system
61
+ }
62
+ }