@kidsinai/kids-client 0.0.16 → 0.0.17

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.16",
4
+ "version": "0.0.17",
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.16"
37
+ "@kidsinai/kids-opencode-plugin": "^0.0.17"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@opencode-ai/sdk": "^1.14.51",
@@ -67,15 +67,23 @@ export class EventSubscriber {
67
67
  }
68
68
 
69
69
  private async consume(): Promise<void> {
70
- // The SDK exposes the SSE stream via client.global.event(). Shape varies
71
- // between SDK minor versions (some return async iterable, some a stream
72
- // helper). We use the duck-typed iterable path.
73
- const eventApi = (this.client as unknown as { global?: { event: () => AsyncIterable<unknown> } }).global
70
+ // The SDK exposes the SSE stream via client.global.event(). The shape
71
+ // has changed across SDK versions:
72
+ // old: event() returns an AsyncIterable directly
73
+ // • new (>=1.14.51): event() returns Promise<{ stream: AsyncGenerator }>
74
+ // Handle both. The error "undefined is not a function (near '...raw of
75
+ // stream...')" came from `for await`-ing a Promise (the new shape) under
76
+ // the old code path.
77
+ const eventApi = (this.client as unknown as { global?: { event: (...a: unknown[]) => unknown } }).global
74
78
  if (!eventApi || typeof eventApi.event !== "function") {
75
79
  throw new Error("@opencode-ai/sdk/v2: client.global.event() not available — SDK version drift")
76
80
  }
77
- const stream = eventApi.event()
78
- for await (const raw of stream) {
81
+ const result = await Promise.resolve(eventApi.event())
82
+ const iterable = pickAsyncIterable(result)
83
+ if (!iterable) {
84
+ throw new Error(`@opencode-ai/sdk/v2: client.global.event() returned an unrecognised shape: ${describeShape(result)}`)
85
+ }
86
+ for await (const raw of iterable) {
79
87
  if (this.abort.signal.aborted) return
80
88
  if (this.retries > 0) {
81
89
  this.retries = 0
@@ -86,8 +94,19 @@ export class EventSubscriber {
86
94
  }
87
95
 
88
96
  private dispatch(raw: unknown): void {
89
- const env = raw as { payload?: { type?: string } & Record<string, unknown> }
90
- const payload = env?.payload
97
+ // Across SDK versions an event is either:
98
+ // • { payload: { type, …fields } } (older shape)
99
+ // • { type, …fields } (newer shape — yielded
100
+ // directly by the
101
+ // AsyncGenerator)
102
+ // • { data: { type, …fields } } (StreamEvent wrapper from some helpers)
103
+ // Unwrap to a single `payload` view so the switch below stays the same.
104
+ const e = raw as { payload?: Record<string, unknown>; data?: Record<string, unknown>; type?: string } & Record<string, unknown>
105
+ const payload: ({ type?: string } & Record<string, unknown>) | null =
106
+ (e?.payload && typeof e.payload === "object") ? (e.payload as { type?: string } & Record<string, unknown>)
107
+ : (e?.data && typeof e.data === "object" && typeof (e.data as { type?: unknown }).type === "string") ? (e.data as { type?: string } & Record<string, unknown>)
108
+ : (typeof e?.type === "string") ? (e as { type?: string } & Record<string, unknown>)
109
+ : null
91
110
  if (!payload || typeof payload.type !== "string") return
92
111
  const t = payload.type
93
112
  switch (t) {
@@ -166,3 +185,35 @@ function stringifyErr(err: unknown): string {
166
185
  return String(err)
167
186
  }
168
187
  }
188
+
189
+ /**
190
+ * The SDK has shipped at least three event() return shapes over its 1.14.x
191
+ * line. Find the AsyncIterable in whichever shape we got, or null if none.
192
+ */
193
+ function pickAsyncIterable(value: unknown): AsyncIterable<unknown> | null {
194
+ if (!value) return null
195
+ // Shape 1: the value IS the iterable.
196
+ if (typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
197
+ return value as AsyncIterable<unknown>
198
+ }
199
+ // Shape 2: { stream: AsyncGenerator } — the >=1.14.51 ServerSentEventsResult.
200
+ const s = (value as { stream?: unknown }).stream
201
+ if (s && typeof (s as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
202
+ return s as AsyncIterable<unknown>
203
+ }
204
+ // Shape 3: { data: { stream: ... } } — wrapped data envelope.
205
+ const d = (value as { data?: { stream?: unknown } }).data
206
+ if (d && typeof d === "object") {
207
+ const inner = (d as { stream?: unknown }).stream
208
+ if (inner && typeof (inner as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
209
+ return inner as AsyncIterable<unknown>
210
+ }
211
+ }
212
+ return null
213
+ }
214
+
215
+ function describeShape(value: unknown): string {
216
+ if (value == null) return String(value)
217
+ if (typeof value !== "object") return typeof value
218
+ return `object keys=[${Object.keys(value as object).join(",")}]`
219
+ }
package/src/core/setup.ts CHANGED
@@ -80,7 +80,7 @@ export const PROVIDERS: ProviderChoice[] = [
80
80
  },
81
81
  {
82
82
  id: "openai",
83
- label: "OpenAI GPT (ChatGPT Plus/Pro 可直接登录)",
83
+ label: "OpenAI GPT (sign in with ChatGPT Plus/Pro)",
84
84
  hint: "Already pay for ChatGPT Plus/Pro? Sign in with that — no API key. Otherwise pay-as-you-go ~$5-10/month.",
85
85
  envVar: "OPENAI_API_KEY",
86
86
  apiKeyUrl: "https://platform.openai.com/api-keys",
package/src/index.tsx CHANGED
@@ -74,6 +74,7 @@ interface AppHandlers {
74
74
  onPickerBack: () => void
75
75
  onMissionNext: () => void
76
76
  onMissionBack: () => void
77
+ onMissionExit: () => void
77
78
  onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
78
79
  onSetupContinue: () => Promise<void>
79
80
  onSetupSkip: () => void
@@ -83,6 +84,25 @@ interface AppHandlers {
83
84
  }
84
85
 
85
86
  async function main(): Promise<void> {
87
+ // Switch to the terminal's alternate screen buffer so Ink draws on a
88
+ // canvas isolated from whatever was in the terminal before us — most
89
+ // importantly the green "Complete authorization…" lines printed by
90
+ // `opencode auth login` between two execs of kids-client. On exit, the
91
+ // kid's original terminal contents (incl. scrollback) come back.
92
+ //
93
+ // NOTE: do NOT install SIGINT/SIGTERM handlers here — the existing
94
+ // `process.on("SIGINT", () => void services.quit())` registration below
95
+ // is the cleanup owner; double-handling closed the raw-mode stdin out
96
+ // from under Ink and surfaced as "EIO on fd 8" when the kid pressed Esc.
97
+ // The "exit" listener alone is enough to restore the terminal for normal
98
+ // exits + the OAuth handoff `process.exit(OAUTH_HANDOFF_EXIT_CODE)`.
99
+ if (process.stdout.isTTY) {
100
+ process.stdout.write("\x1b[?1049h\x1b[H")
101
+ process.on("exit", () => {
102
+ try { process.stdout.write("\x1b[?1049l") } catch { /* terminal already closed */ }
103
+ })
104
+ }
105
+
86
106
  const env: KidsClientEnv = readEnv()
87
107
  const store = new Store()
88
108
  const installedPacks = listInstalledPacks()
@@ -210,6 +230,10 @@ function makeHandlers(
210
230
  onPickerBack: () => store.update({ screen: { kind: "startup" } }),
211
231
  onMissionNext: ifBooted((s) => s.handlers.onMissionNext()),
212
232
  onMissionBack: () => store.update({ screen: { kind: "mission" } }),
233
+ // Leave an in-progress mission and return to the startup menu. The serve +
234
+ // session keep running in the background; the kid just re-enters from the
235
+ // picker. Mirrors onHelpBack / onPickerBack.
236
+ onMissionExit: () => store.update({ screen: { kind: "startup" } }),
213
237
  onSetupSave: async (provider, apiKey) => {
214
238
  try {
215
239
  saveSetup({ configDir: env.configDir, provider, apiKey })
@@ -379,7 +403,17 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
379
403
  })
380
404
  },
381
405
  onDisconnected: (reason) => {
382
- store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: reason } })
406
+ // This fires after the engine was already reachable, so the failure is a
407
+ // dropped event stream, not a failed startup. Make the detail say so —
408
+ // the variant's title still reads "AI teacher didn't start", but the
409
+ // detail keeps it from being misleading.
410
+ store.update({
411
+ screen: {
412
+ kind: "error",
413
+ variant: "serve_unreachable",
414
+ detail: `lost connection to the AI engine after it started — ${reason}`,
415
+ },
416
+ })
383
417
  },
384
418
  onReconnected: () => {
385
419
  flashToast(store, {
@@ -57,6 +57,8 @@ export interface AppDeps {
57
57
  onPickerBack: () => void
58
58
  onMissionNext: () => void
59
59
  onMissionBack: () => void
60
+ /** Leave an in-progress mission and return to the startup menu. */
61
+ onMissionExit: () => void
60
62
  onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
61
63
  onSetupContinue: () => Promise<void>
62
64
  onSetupSkip: () => void
@@ -105,9 +107,9 @@ export function App(deps: AppDeps): React.ReactElement {
105
107
  case "tour":
106
108
  return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
107
109
  case "startup":
108
- return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} />
110
+ return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} onQuit={deps.onQuit} />
109
111
  case "mission":
110
- return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
112
+ return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} onExit={deps.onMissionExit} />
111
113
  case "help":
112
114
  return <HelpScreen locale={deps.locale} onBack={deps.onHelpBack} />
113
115
  case "course_picker":
@@ -26,8 +26,17 @@ export function Header({ packTitle, missionTitle, missionIndex, missionTotal, st
26
26
  starsBudget > 0
27
27
  ? `⭐ ${starsBalance}/${starsBudget}`
28
28
  : `⭐ ${starsBalance}`
29
+ // borderStyle="round" + justifyContent="space-between" without an explicit
30
+ // width caused a cascade of stacked top-borders under Ink 5 + Bun — the
31
+ // Header re-rendered on every keystroke / spinner tick with a slightly
32
+ // different computed width, and Ink's diff failed to clear the old top
33
+ // border. Forcing width to the current terminal column count locks the
34
+ // measurement, and "single" border chars sidestep the rounded-corner
35
+ // width-counting glitch we hit in workshop dogfood (round corners stay
36
+ // available on Setup / Tour / Help screens which don't re-render rapidly).
37
+ const width = process.stdout.columns && process.stdout.columns > 4 ? process.stdout.columns : 80
29
38
  return (
30
- <Box borderStyle="round" borderColor={theme.border} paddingX={1} justifyContent="space-between">
39
+ <Box borderStyle="single" borderColor={theme.border} paddingX={1} justifyContent="space-between" width={width}>
31
40
  <Text color={theme.accent}>{left}</Text>
32
41
  <Text color={theme.stars}>{stars}</Text>
33
42
  </Box>
@@ -48,7 +48,7 @@ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPi
48
48
  // Course Pack install is broken — surface it loudly, but still let the kid
49
49
  // drop into free-play via the synthetic entry.
50
50
  return (
51
- <Box flexDirection="column" borderStyle="round" borderColor={theme.warn} paddingX={2} paddingY={1}>
51
+ <Box flexDirection="column" borderStyle="single" borderColor={theme.warn} paddingX={2} paddingY={1} width={process.stdout.columns && process.stdout.columns > 4 ? process.stdout.columns : 80}>
52
52
  <Text color={theme.warn} bold>{t.empty}</Text>
53
53
  <Box marginTop={1}>
54
54
  <Text color={theme.fgDim}>{t.emptyHint}</Text>
@@ -60,7 +60,7 @@ export function CoursePackPicker({ locale, packs, onPick, onBack }: CoursePackPi
60
60
  )
61
61
  }
62
62
  return (
63
- <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1}>
63
+ <Box flexDirection="column" borderStyle="single" borderColor={theme.accent} paddingX={2} paddingY={1} width={process.stdout.columns && process.stdout.columns > 4 ? process.stdout.columns : 80}>
64
64
  <Text color={theme.accent} bold>{t.title}</Text>
65
65
  <Box marginTop={1} flexDirection="column">
66
66
  {rows.map((row, i) => {
@@ -24,20 +24,29 @@ interface MissionScreenProps {
24
24
  locale: "zh-Hans" | "en"
25
25
  onPrompt: (text: string) => void
26
26
  onAbort: () => void
27
+ /** Leave the mission and return to the startup menu. */
28
+ onExit: () => void
27
29
  }
28
30
 
29
- export function MissionScreen({ state, locale, onPrompt, onAbort }: MissionScreenProps): React.ReactElement {
31
+ export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: MissionScreenProps): React.ReactElement {
30
32
  const theme = getTheme()
31
33
  const [draft, setDraft] = useState("")
32
34
  const placeholder = locale === "zh-Hans" ? "想做什么?告诉我吧(中文/英文都行)" : "What would you like to make? (English or Chinese)"
33
35
 
36
+ // Esc is overloaded so it never eats the kid's typing: while the AI is
37
+ // thinking it interrupts; with text typed it clears the draft; when idle and
38
+ // empty it leaves the mission back to the startup menu (so the kid isn't
39
+ // trapped here — dogfood feedback).
34
40
  useInput((_, key) => {
35
- if (key.escape && state.thinking) onAbort()
41
+ if (!key.escape) return
42
+ if (state.thinking) onAbort()
43
+ else if (draft.length > 0) setDraft("")
44
+ else onExit()
36
45
  })
37
46
 
38
47
  const hint = locale === "zh-Hans"
39
- ? "提示:做完一关时打 /check 或「我做完了」就能验收 · 按 Esc 打断 AI"
40
- : "Tip: type /check or 'I'm done' to validate · Esc interrupts the AI"
48
+ ? "提示:做完一关时打 /check 或「我做完了」就能验收 · 按 Esc 打断 AI / 返回菜单"
49
+ : "Tip: type /check or 'I'm done' to validate · Esc interrupts the AI / returns to menu"
41
50
 
42
51
  return (
43
52
  <Box flexDirection="column">
@@ -9,6 +9,7 @@
9
9
  * r → resume the last session
10
10
  * w → open Airbotix Portal wallet / login in the parent's browser
11
11
  * h → show kid-friendly help
12
+ * q → quit Kids OpenCode
12
13
  */
13
14
 
14
15
  import React from "react"
@@ -24,9 +25,10 @@ interface StartupScreenProps {
24
25
  toast: ToastState | null
25
26
  onStart: (mode: "free" | "course" | "resume" | "help") => void
26
27
  onOpenWallet: () => void
28
+ onQuit: () => void
27
29
  }
28
30
 
29
- export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet }: StartupScreenProps): React.ReactElement {
31
+ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet, onQuit }: StartupScreenProps): React.ReactElement {
30
32
  const theme = getTheme()
31
33
  useInput((input, key) => {
32
34
  if (key.return) onStart("course")
@@ -35,6 +37,7 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
35
37
  else if (input === "r") onStart("resume")
36
38
  else if (input === "w" || input === "W") onOpenWallet()
37
39
  else if (input === "h") onStart("help")
40
+ else if (input === "q" || input === "Q") onQuit()
38
41
  })
39
42
  const t = STRINGS[locale]
40
43
  return (
@@ -62,6 +65,7 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
62
65
  { key: "r", label: t.resume },
63
66
  { key: "w", label: t.wallet },
64
67
  { key: "h", label: t.help },
68
+ { key: "q", label: t.quit },
65
69
  ]} />
66
70
  </Box>
67
71
  {toast && (
@@ -87,6 +91,7 @@ const STRINGS = {
87
91
  resume: "继续上次",
88
92
  wallet: "钱包 / 充值(开浏览器)",
89
93
  help: "帮助",
94
+ quit: "退出",
90
95
  },
91
96
  en: {
92
97
  tagline: "🤖 Your AI coding buddy 🤖",
@@ -101,5 +106,6 @@ const STRINGS = {
101
106
  resume: "Resume last session",
102
107
  wallet: "Wallet / Top up (opens browser)",
103
108
  help: "Help",
109
+ quit: "Quit",
104
110
  },
105
111
  } as const
@@ -36,9 +36,13 @@ export interface Theme {
36
36
 
37
37
  /** Default — vibrant on a dark terminal. */
38
38
  const DARK: Theme = {
39
- fg: "white",
39
+ // Primary text is bright white and secondary text is plain white (not
40
+ // "gray"/blackBright, which renders near-invisible on many dark themes) so
41
+ // body copy actually reads. The fg/fgDim pair still differ enough to mark
42
+ // hierarchy. See dogfood feedback: "can't see the text, not prominent".
43
+ fg: "whiteBright",
40
44
  bg: "black",
41
- fgDim: "gray",
45
+ fgDim: "white",
42
46
  accent: "yellow",
43
47
  warn: "yellow",
44
48
  danger: "red",