@kidsinai/kids-client 0.0.12 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@kidsinai/kids-client",
4
- "version": "0.0.12",
4
+ "version": "0.0.16",
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",
@@ -34,7 +34,7 @@
34
34
  "ink-spinner": "^5.0.0",
35
35
  "ink-text-input": "^6.0.0",
36
36
  "react": "^18.3.1",
37
- "@kidsinai/kids-opencode-plugin": "^0.0.1"
37
+ "@kidsinai/kids-opencode-plugin": "^0.0.16"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@opencode-ai/sdk": "^1.14.51",
@@ -7,7 +7,7 @@
7
7
  * — used by CoursePackPicker.
8
8
  */
9
9
 
10
- import { readdirSync, statSync } from "node:fs"
10
+ import { existsSync, readdirSync, statSync } from "node:fs"
11
11
  import { join } from "node:path"
12
12
  import {
13
13
  bundledCoursePacksDir,
@@ -52,38 +52,62 @@ export interface InstalledPack {
52
52
  shortDescription: string | null
53
53
  missionCount: number
54
54
  starsBudget: number
55
+ /** Project-type metadata for the picker, surfaced from pack.yml. */
56
+ icon: string | null
57
+ pickerLabel: string | null
58
+ typeCategory: "game" | "website" | "slides" | "video" | null
59
+ pickerOrder: number
55
60
  }
56
61
 
57
62
  /**
58
63
  * Enumerate packs available in the bundled course-packs/ directory. Used
59
64
  * by the picker screen. Tolerant of malformed packs (skips, doesn't
60
65
  * throw).
66
+ *
67
+ * Packs whose folder name starts with `_` (e.g. `_stub`) are hidden from
68
+ * the picker but remain loadable by id — they're CI fixtures, not kid content.
61
69
  */
62
70
  export function listInstalledPacks(): InstalledPack[] {
63
- const dir = bundledCoursePacksDir()
64
- let entries: string[]
65
- try {
66
- entries = readdirSync(dir)
67
- } catch {
68
- return []
69
- }
71
+ // Walk both the public dir and the private submodule dir (if mounted).
72
+ // Same pack id appearing in both is deduped — private wins because the
73
+ // plugin's packDir() resolves private-first; listing follows the same order.
74
+ const publicDir = bundledCoursePacksDir()
75
+ const privateDir = join(publicDir, "private")
76
+ const dirs = [privateDir, publicDir].filter((d) => existsSync(d))
77
+ const seen = new Set<string>()
70
78
  const out: InstalledPack[] = []
71
- for (const id of entries) {
79
+ for (const dir of dirs) {
80
+ let entries: string[]
72
81
  try {
73
- const full = join(dir, id)
74
- if (!statSync(full).isDirectory()) continue
75
- const pack = loadCoursePack(id)
76
- if (!pack) continue
77
- out.push({
78
- id: pack.id,
79
- title: pack.title,
80
- shortDescription: pack.short_description ?? null,
81
- missionCount: pack.missions?.length ?? 0,
82
- starsBudget: pack.estimated_stars_budget ?? 0,
83
- })
82
+ entries = readdirSync(dir)
84
83
  } catch {
85
- // skip malformed entry
84
+ continue
85
+ }
86
+ for (const id of entries) {
87
+ if (id.startsWith("_") || id.startsWith(".")) continue
88
+ if (seen.has(id)) continue
89
+ try {
90
+ const full = join(dir, id)
91
+ if (!statSync(full).isDirectory()) continue
92
+ const pack = loadCoursePack(id)
93
+ if (!pack) continue
94
+ seen.add(id)
95
+ out.push({
96
+ id: pack.id,
97
+ title: pack.title,
98
+ shortDescription: pack.short_description ?? null,
99
+ missionCount: pack.missions?.length ?? 0,
100
+ starsBudget: pack.estimated_stars_budget ?? 0,
101
+ icon: pack.icon ?? null,
102
+ pickerLabel: pack.picker_label ?? null,
103
+ typeCategory: pack.type_category ?? null,
104
+ pickerOrder: pack.picker_order ?? Number.MAX_SAFE_INTEGER,
105
+ })
106
+ } catch {
107
+ // skip malformed entry
108
+ }
86
109
  }
87
110
  }
111
+ out.sort((a, b) => a.pickerOrder - b.pickerOrder)
88
112
  return out
89
113
  }
package/src/core/env.ts CHANGED
@@ -29,6 +29,10 @@ export interface KidsClientEnv {
29
29
  coursePack: string | null
30
30
  /** Optional mission id (e.g. "mission-1"). */
31
31
  mission: string | null
32
+ /** Optional guided-flow vibe id picked by the kid (e.g. "space"). */
33
+ vibeId: string | null
34
+ /** Optional kid-chosen project name. Surfaced in scaffold template vars. */
35
+ projectName: string | null
32
36
  /** Locale hint ("zh-Hans" / "en"). Picked from KIDS_LOCALE or $LANG. */
33
37
  locale: "zh-Hans" | "en"
34
38
  /** Path to opencode binary so client can spawn `opencode serve`. */
@@ -57,6 +61,8 @@ export function readEnv(): KidsClientEnv {
57
61
  bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
58
62
  coursePack: process.env.KIDS_COURSE_PACK || null,
59
63
  mission: process.env.KIDS_MISSION || null,
64
+ vibeId: process.env.KIDS_VIBE_ID || null,
65
+ projectName: process.env.KIDS_PROJECT_NAME || null,
60
66
  locale,
61
67
  opencodeBin: process.env.OPENCODE_BIN ?? "opencode",
62
68
  configDir: process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"),
package/src/index.tsx CHANGED
@@ -31,6 +31,7 @@ import { listInstalledPacks, resolveContext } from "./core/course-pack.ts"
31
31
  import { readLastSession, writeLastSession } from "./core/last-session.ts"
32
32
  import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
33
33
  import { App } from "./render/ink/App.tsx"
34
+ import { FREE_PLAY_PACK_ID } from "./render/ink/screens/CoursePackPicker.tsx"
34
35
  import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
35
36
  import { OAUTH_HANDOFF_EXIT_CODE, saveSetup, saveSetupOauth, type ProviderId } from "./core/setup.ts"
36
37
  import { reloadEnvFile } from "./core/env-reload.ts"
@@ -574,6 +575,12 @@ function makeFullHandlers(
574
575
  }
575
576
  },
576
577
  onPickPack: (packId) => {
578
+ if (packId === FREE_PLAY_PACK_ID) {
579
+ // Synthetic "I don't know yet — just chat" entry → free-play.
580
+ store.update({ coursePack: null, mission: null, packTitle: null, missionTitle: null, missionIndex: null, missionTotal: null })
581
+ store.update({ screen: { kind: "mission" } })
582
+ return
583
+ }
577
584
  store.update({ coursePack: packId, mission: null })
578
585
  refreshContext()
579
586
  store.update({ screen: { kind: "mission" } })
@@ -1,16 +1,21 @@
1
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.
2
+ * Project-type pickerfirst impression for a kid with no `--course` flag.
5
3
  *
6
- * Up / Down to move, Enter to select, Esc to go back.
4
+ * Lists installed packs (Game / Website / …) plus a synthetic "I don't know
5
+ * yet — just chat" entry that drops into free-play. Selection routes via the
6
+ * orchestrator's onPick callback; the magic `_free` id triggers free-play.
7
+ *
8
+ * Up / Down to move, Enter to select, Esc to go back to StartupScreen.
7
9
  */
8
10
 
9
- import React, { useState } from "react"
11
+ import React, { useMemo, useState } from "react"
10
12
  import { Box, Text, useInput } from "ink"
11
13
  import { getTheme } from "../theme.ts"
12
14
  import type { InstalledPack } from "../../../core/course-pack.ts"
13
15
 
16
+ /** Synthetic id reserved for the "just chat" entry; orchestrator maps this to free-play. */
17
+ export const FREE_PLAY_PACK_ID = "_free"
18
+
14
19
  interface CoursePackPickerProps {
15
20
  locale: "zh-Hans" | "en"
16
21
  packs: InstalledPack[]
@@ -18,17 +23,30 @@ interface CoursePackPickerProps {
18
23
  onBack: () => void
19
24
  }
20
25
 
26
+ interface PickerRow {
27
+ id: string
28
+ icon: string
29
+ label: string
30
+ description: string | null
31
+ /** Optional meta line ("3 missions · budget 40⭐") — null hides it. */
32
+ meta: string | null
33
+ }
34
+
21
35
  export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPickerProps): React.ReactElement {
22
36
  const theme = getTheme()
37
+ const t = STRINGS[locale]
38
+ const rows: PickerRow[] = useMemo(() => buildRows(packs, t), [packs, t])
23
39
  const [idx, setIdx] = useState(0)
24
40
  useInput((_, key) => {
25
41
  if (key.escape) onBack()
26
42
  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)
43
+ else if (key.downArrow) setIdx((i) => Math.min(rows.length - 1, i + 1))
44
+ else if (key.return && rows[idx]) onPick(rows[idx]!.id)
29
45
  })
30
- const t = STRINGS[locale]
31
- if (packs.length === 0) {
46
+ const noRealPacks = packs.length === 0
47
+ if (noRealPacks) {
48
+ // Course Pack install is broken — surface it loudly, but still let the kid
49
+ // drop into free-play via the synthetic entry.
32
50
  return (
33
51
  <Box flexDirection="column" borderStyle="round" borderColor={theme.warn} paddingX={2} paddingY={1}>
34
52
  <Text color={theme.warn} bold>{t.empty}</Text>
@@ -45,17 +63,20 @@ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPi
45
63
  <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1}>
46
64
  <Text color={theme.accent} bold>{t.title}</Text>
47
65
  <Box marginTop={1} flexDirection="column">
48
- {packs.map((p, i) => {
66
+ {rows.map((row, i) => {
49
67
  const active = i === idx
50
68
  return (
51
- <Box key={p.id} marginBottom={i === packs.length - 1 ? 0 : 0}>
69
+ <Box key={row.id}>
52
70
  <Text color={active ? theme.kid : theme.fg}>{active ? "▶ " : " "}</Text>
71
+ <Text>{row.icon}{row.icon ? " " : ""}</Text>
53
72
  <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>
73
+ <Text color={active ? theme.accent : theme.fg} bold={active}>{row.label}</Text>
74
+ {row.description && (
75
+ <Text color={theme.fgDim} dimColor={!active}> {row.description}</Text>
76
+ )}
77
+ {row.meta && (
78
+ <Text color={theme.fgDim} dimColor> {row.meta}</Text>
57
79
  )}
58
- <Text color={theme.fgDim} dimColor> {t.meta(p.missionCount, p.starsBudget)}</Text>
59
80
  </Box>
60
81
  </Box>
61
82
  )
@@ -68,22 +89,50 @@ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPi
68
89
  )
69
90
  }
70
91
 
92
+ interface PickerStrings {
93
+ freePlayLabel: string
94
+ freePlayDescription: string
95
+ meta: (missions: number, stars: number) => string
96
+ }
97
+
98
+ function buildRows(packs: InstalledPack[], t: PickerStrings): PickerRow[] {
99
+ const rows: PickerRow[] = packs.map((p) => ({
100
+ id: p.id,
101
+ icon: p.icon ?? "📦",
102
+ label: p.pickerLabel ?? p.title,
103
+ description: p.shortDescription,
104
+ meta: p.missionCount > 0 ? t.meta(p.missionCount, p.starsBudget) : null,
105
+ }))
106
+ rows.push({
107
+ id: FREE_PLAY_PACK_ID,
108
+ icon: "🤔",
109
+ label: t.freePlayLabel,
110
+ description: t.freePlayDescription,
111
+ meta: null,
112
+ })
113
+ return rows
114
+ }
115
+
71
116
  const STRINGS = {
72
117
  "zh-Hans": {
73
- title: "选一个 Course Pack",
118
+ title: "你好呀. 今天想做点啥?",
74
119
  empty: "还没装任何 Course Pack",
75
120
  emptyHint: "Course Pack 是 Airbotix 老师做的引导式项目。请家长重新装 kids-opencode 把它带回来。",
76
121
  backHint: "[Esc / Enter] 返回",
77
122
  hints: "[↑↓] 选 · [Enter] 确认 · [Esc] 返回",
123
+ freePlayLabel: "还没想好 — 聊聊看",
124
+ freePlayDescription: "先跟 AI 聊一聊,再决定做啥也行。",
78
125
  meta: (missions: number, stars: number) =>
79
126
  stars > 0 ? `${missions} 个 Mission · 预算 ${stars}⭐` : `${missions} 个 Mission`,
80
127
  },
81
128
  en: {
82
- title: "Pick a Course Pack",
129
+ title: "Welcome, friend. What do you want to make today?",
83
130
  empty: "No Course Packs installed yet",
84
131
  emptyHint: "Course Packs are guided projects from Airbotix. Ask a grown-up to reinstall kids-opencode.",
85
132
  backHint: "[Esc / Enter] Back",
86
133
  hints: "[↑↓] move · [Enter] choose · [Esc] back",
134
+ freePlayLabel: "I don't know yet — just chat",
135
+ freePlayDescription: "Talk to the AI first, then decide what to make.",
87
136
  meta: (missions: number, stars: number) =>
88
137
  stars > 0 ? `${missions} missions · budget ${stars}⭐` : `${missions} missions`,
89
138
  },
@@ -2,8 +2,10 @@
2
2
  * §3.1 Startup screen — first impression. Must paint within 5s.
3
3
  *
4
4
  * Quick keys:
5
- * Enter → start a free-play session OR continue if a course pack is set
6
- * c → choose a Course Pack
5
+ * Enter → if a Course Pack is preselected (via --course): continue.
6
+ * Otherwise: open the project-type picker.
7
+ * c → open the project-type picker explicitly
8
+ * f → start a free-play session (no Course Pack)
7
9
  * r → resume the last session
8
10
  * w → open Airbotix Portal wallet / login in the parent's browser
9
11
  * h → show kid-friendly help
@@ -27,8 +29,9 @@ interface StartupScreenProps {
27
29
  export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet }: StartupScreenProps): React.ReactElement {
28
30
  const theme = getTheme()
29
31
  useInput((input, key) => {
30
- if (key.return) onStart(coursePack ? "course" : "free")
32
+ if (key.return) onStart("course")
31
33
  else if (input === "c") onStart("course")
34
+ else if (input === "f") onStart("free")
32
35
  else if (input === "r") onStart("resume")
33
36
  else if (input === "w" || input === "W") onOpenWallet()
34
37
  else if (input === "h") onStart("help")
@@ -53,8 +56,9 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
53
56
  </Box>
54
57
  <Box marginTop={2}>
55
58
  <KeyHints hints={[
56
- { key: "Enter", label: coursePack ? t.startCourse : t.startFree },
57
- { key: "c", label: t.pickCourse },
59
+ { key: "Enter", label: coursePack ? t.startCourse : t.pickCourse },
60
+ ...(coursePack ? [{ key: "c", label: t.pickCourse }] : []),
61
+ { key: "f", label: t.startFree },
58
62
  { key: "r", label: t.resume },
59
63
  { key: "w", label: t.wallet },
60
64
  { key: "h", label: t.help },