@kidsinai/kids-client 0.0.18 → 0.0.20

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.18",
4
+ "version": "0.0.20",
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",
@@ -35,7 +35,7 @@
35
35
  "ink-spinner": "^5.0.0",
36
36
  "ink-text-input": "^6.0.0",
37
37
  "react": "^18.3.1",
38
- "@kidsinai/kids-opencode-plugin": "^0.0.18"
38
+ "@kidsinai/kids-opencode-plugin": "^0.0.20"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@opencode-ai/sdk": "^1.14.51",
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Kid-safe slash-command registry.
3
+ *
4
+ * kids-client is a custom minimal TUI (not opencode's native one), so it grows
5
+ * its OWN command set rather than exposing opencode's full palette. This module
6
+ * is pure metadata + parsing — the actual effects live in index.tsx's prompt
7
+ * dispatcher (it needs the store / session / client). Keeping it pure makes the
8
+ * list unit-testable and lets MissionScreen render live suggestions.
9
+ *
10
+ * Deliberately EXCLUDED for kid-safety: /share (public links), /editor &
11
+ * drop-to-shell, external @-mentions, arbitrary config edits. The server-side
12
+ * tool whitelist (kids-plugin system prompt) blocks the dangerous primitives
13
+ * anyway; we just don't surface the affordances.
14
+ */
15
+
16
+ export type Locale = "zh-Hans" | "en"
17
+
18
+ export interface KidCommand {
19
+ id: string
20
+ /** Primary slash form, e.g. "/model". */
21
+ slash: string
22
+ /** Extra accepted spellings, e.g. ["/models"]. */
23
+ aliases?: string[]
24
+ label: { en: string; zh: string }
25
+ hint: { en: string; zh: string }
26
+ }
27
+
28
+ /** Order here is the order shown in the suggestion list / /help. */
29
+ export const COMMANDS: KidCommand[] = [
30
+ { id: "help", slash: "/help", aliases: ["/?", "/commands"],
31
+ label: { en: "Help", zh: "帮助" },
32
+ hint: { en: "Show what you can type", zh: "看看能输入哪些命令" } },
33
+ { id: "model", slash: "/model", aliases: ["/models"],
34
+ label: { en: "Pick AI model", zh: "选 AI 模型" },
35
+ hint: { en: "Choose which AI helps you", zh: "换一个帮你的 AI 模型" } },
36
+ { id: "sessions", slash: "/sessions", aliases: ["/history", "/chats"],
37
+ label: { en: "Past chats", zh: "历史对话" },
38
+ hint: { en: "Open an earlier conversation", zh: "打开之前的对话" } },
39
+ { id: "new", slash: "/new",
40
+ label: { en: "New chat", zh: "开新对话" },
41
+ hint: { en: "Start a fresh conversation", zh: "清空,开始一段新的对话" } },
42
+ { id: "check", slash: "/check", aliases: ["/done"],
43
+ label: { en: "Check my work", zh: "验收作品" },
44
+ hint: { en: "See if you finished the mission", zh: "看看这一关完成没有" } },
45
+ { id: "clear", slash: "/clear",
46
+ label: { en: "Clear screen", zh: "清屏" },
47
+ hint: { en: "Clear the chat (your files stay)", zh: "清空聊天记录(文件不动)" } },
48
+ { id: "menu", slash: "/menu", aliases: ["/home"],
49
+ label: { en: "Main menu", zh: "主菜单" },
50
+ hint: { en: "Back to the start screen", zh: "回到开始界面" } },
51
+ { id: "quit", slash: "/quit", aliases: ["/exit"],
52
+ label: { en: "Quit", zh: "退出" },
53
+ hint: { en: "Close Kids OpenCode", zh: "关闭 Kids OpenCode" } },
54
+ ]
55
+
56
+ export interface ParsedCommand {
57
+ /** The slash token as typed, lowercased, incl. leading "/", e.g. "/model". */
58
+ name: string
59
+ /** Everything after the command word, trimmed (may be ""). */
60
+ args: string
61
+ }
62
+
63
+ /** Parse a slash command line. Returns null if `text` isn't a `/command`. */
64
+ export function parseSlash(text: string): ParsedCommand | null {
65
+ const t = text.trim()
66
+ if (!t.startsWith("/")) return null
67
+ const space = t.indexOf(" ")
68
+ if (space === -1) return { name: t.toLowerCase(), args: "" }
69
+ return { name: t.slice(0, space).toLowerCase(), args: t.slice(space + 1).trim() }
70
+ }
71
+
72
+ /** Resolve a parsed command name (e.g. "/models") to its KidCommand. */
73
+ export function matchCommand(name: string): KidCommand | null {
74
+ const n = name.toLowerCase()
75
+ for (const c of COMMANDS) {
76
+ if (c.slash === n) return c
77
+ if (c.aliases?.includes(n)) return c
78
+ }
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Commands matching a draft query (the text the kid has typed so far,
84
+ * including the leading "/"). "/" alone returns everything. Matches against
85
+ * slash, aliases, id, and both labels so "/mod" or "/模型"-style typing finds
86
+ * the model command.
87
+ */
88
+ export function filterCommands(query: string): KidCommand[] {
89
+ const q = query.trim().toLowerCase()
90
+ if (q === "" || q === "/") return COMMANDS
91
+ return COMMANDS.filter((c) => {
92
+ const hay = [c.slash, ...(c.aliases ?? []), c.id, c.label.en, c.label.zh]
93
+ .join(" ")
94
+ .toLowerCase()
95
+ return hay.includes(q.replace(/^\//, ""))
96
+ })
97
+ }
98
+
99
+ export function commandLabel(c: KidCommand, locale: Locale): string {
100
+ return locale === "zh-Hans" ? c.label.zh : c.label.en
101
+ }
102
+
103
+ export function commandHint(c: KidCommand, locale: Locale): string {
104
+ return locale === "zh-Hans" ? c.hint.zh : c.hint.en
105
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tiny file-based debug logger.
3
+ *
4
+ * Always-on writes to ~/.config/kids-opencode/debug.log so we can see what
5
+ * actually happened in a TUI session after the fact (Ink owns the terminal,
6
+ * so console.log/error doesn't show on screen and would also break the
7
+ * alt-screen-buffer canvas).
8
+ *
9
+ * Set KIDS_DEBUG=0 to silence it. Otherwise it's on by default while we're
10
+ * still chasing the "thinking…" hang in dogfood.
11
+ */
12
+
13
+ import { appendFileSync, mkdirSync } from "node:fs"
14
+ import { dirname, join } from "node:path"
15
+ import { homedir } from "node:os"
16
+
17
+ const PATH = process.env.KIDS_DEBUG_LOG
18
+ ?? join(process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"), "debug.log")
19
+
20
+ const ENABLED = process.env.KIDS_DEBUG !== "0"
21
+
22
+ let initialised = false
23
+
24
+ function init(): void {
25
+ if (initialised) return
26
+ try {
27
+ mkdirSync(dirname(PATH), { recursive: true })
28
+ appendFileSync(PATH, `\n===== kids-client debug log opened at ${new Date().toISOString()} (pid ${process.pid}) =====\n`, "utf8")
29
+ } catch {
30
+ // can't init — disable to avoid further failures
31
+ initialised = true
32
+ return
33
+ }
34
+ initialised = true
35
+ }
36
+
37
+ export function debug(message: string, fields?: Record<string, unknown>): void {
38
+ if (!ENABLED) return
39
+ init()
40
+ try {
41
+ const stamp = new Date().toISOString()
42
+ const tail = fields ? " " + JSON.stringify(fields) : ""
43
+ appendFileSync(PATH, `${stamp} ${message}${tail}\n`, "utf8")
44
+ } catch {
45
+ // best-effort, never throw from the logger
46
+ }
47
+ }
48
+
49
+ export function debugLogPath(): string {
50
+ return PATH
51
+ }
@@ -66,6 +66,14 @@ export class EventSubscriber {
66
66
  this.abort.abort()
67
67
  }
68
68
 
69
+ private dbg(message: string, fields?: Record<string, unknown>): void {
70
+ // Lazy require to avoid a hard dep cycle if debug.ts grows imports.
71
+ try {
72
+ const { debug } = require("./debug.ts") as { debug: (m: string, f?: Record<string, unknown>) => void }
73
+ debug(`[events] ${message}`, fields)
74
+ } catch { /* never let logging block events */ }
75
+ }
76
+
69
77
  private async consume(): Promise<void> {
70
78
  // The SDK exposes the SSE stream via client.global.event(). The shape
71
79
  // has changed across SDK versions:
@@ -78,79 +86,106 @@ export class EventSubscriber {
78
86
  if (!eventApi || typeof eventApi.event !== "function") {
79
87
  throw new Error("@opencode-ai/sdk/v2: client.global.event() not available — SDK version drift")
80
88
  }
89
+ this.dbg("consume: calling event()")
81
90
  const result = await Promise.resolve(eventApi.event())
91
+ this.dbg("consume: event() returned", { shape: describeShape(result) })
82
92
  const iterable = pickAsyncIterable(result)
83
93
  if (!iterable) {
84
94
  throw new Error(`@opencode-ai/sdk/v2: client.global.event() returned an unrecognised shape: ${describeShape(result)}`)
85
95
  }
96
+ this.dbg("consume: got iterable, awaiting SSE events…")
86
97
  for await (const raw of iterable) {
87
98
  if (this.abort.signal.aborted) return
88
99
  if (this.retries > 0) {
89
100
  this.retries = 0
90
101
  this.handlers.onReconnected?.()
91
102
  }
103
+ this.dbg("consume: raw event", { preview: previewRaw(raw) })
92
104
  this.dispatch(raw)
93
105
  }
106
+ this.dbg("consume: iterable ended")
94
107
  }
95
108
 
96
109
  private dispatch(raw: unknown): void {
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.
110
+ // The SDK shape evolved over the 1.14.x line. Yielded events arrive as:
111
+ // • { payload: { type, properties: { … } } } (current — 1.14.51 GlobalEvent)
112
+ // • { payload: { type, …flatfields } } (olderflat fields on payload)
113
+ // { type, properties: { … } } (some intermediate releases)
114
+ // • { type, …flatfields } (oldest)
115
+ // • { data: { type, …. } } (StreamEvent helper wrapper)
116
+ //
117
+ // CRITICAL: the current 1.14.51 GlobalEvent puts event fields
118
+ // (sessionID, messageID, delta, …) under .payload.properties, NOT
119
+ // directly on .payload. The previous code read .payload.sessionID
120
+ // (always undefined), so deltas were silently dropped and the
121
+ // "thinking" indicator never cleared. This split + the
122
+ // `props ?? payload` fallback handle both layouts uniformly.
104
123
  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>)
124
+ const payload: ({ type?: string; properties?: Record<string, unknown> } & Record<string, unknown>) | null =
125
+ (e?.payload && typeof e.payload === "object") ? (e.payload as { type?: string; properties?: Record<string, unknown> } & Record<string, unknown>)
126
+ : (e?.data && typeof e.data === "object" && typeof (e.data as { type?: unknown }).type === "string") ? (e.data as { type?: string; properties?: Record<string, unknown> } & Record<string, unknown>)
127
+ : (typeof e?.type === "string") ? (e as { type?: string; properties?: Record<string, unknown> } & Record<string, unknown>)
109
128
  : null
110
129
  if (!payload || typeof payload.type !== "string") return
111
- const t = payload.type
130
+ let t = payload.type
131
+ // Where the actual event fields live: `.properties` (Event* legacy shape)
132
+ // OR `.data` (SyncEvent* shape) OR flat on payload (oldest).
133
+ let props: Record<string, unknown> = (payload.properties && typeof payload.properties === "object")
134
+ ? payload.properties as Record<string, unknown>
135
+ : payload
136
+ // 1.14.51 Sync events: type="sync", real event name in .name with a
137
+ // version suffix like ".1", fields under .data. Without this normalize,
138
+ // our switch never matches and streaming text events drop on the floor
139
+ // (kid sees "thinking..." forever).
140
+ if (t === "sync" && typeof (payload as { name?: unknown }).name === "string") {
141
+ t = String((payload as { name: string }).name).replace(/\.\d+$/, "")
142
+ const dataField = (payload as { data?: unknown }).data
143
+ if (dataField && typeof dataField === "object") {
144
+ props = dataField as Record<string, unknown>
145
+ }
146
+ }
112
147
  switch (t) {
113
148
  case "session.created":
114
149
  case "session.next.session.created":
115
- this.handlers.onSessionCreated?.({ sessionID: String(payload.sessionID ?? "") })
150
+ this.handlers.onSessionCreated?.({ sessionID: String(props.sessionID ?? "") })
116
151
  return
117
152
  case "message.part.delta": {
118
- const messageID = String(payload.messageID ?? "")
119
- const partID = String(payload.partID ?? "")
120
- const sessionID = String(payload.sessionID ?? "")
121
- const delta = String((payload.delta as { text?: string } | undefined)?.text ?? payload.delta ?? "")
153
+ const messageID = String(props.messageID ?? "")
154
+ const partID = String(props.partID ?? "")
155
+ const sessionID = String(props.sessionID ?? "")
156
+ const delta = String((props.delta as { text?: string } | undefined)?.text ?? props.delta ?? "")
122
157
  if (delta) this.handlers.onMessagePartDelta?.({ sessionID, messageID, partID, delta })
123
158
  return
124
159
  }
125
160
  case "session.next.text.delta": {
126
- const messageID = String(payload.messageID ?? "")
127
- const partID = String(payload.partID ?? "stream")
128
- const sessionID = String(payload.sessionID ?? "")
129
- const delta = String(payload.delta ?? "")
161
+ const messageID = String(props.messageID ?? "")
162
+ const partID = String(props.partID ?? "stream")
163
+ const sessionID = String(props.sessionID ?? "")
164
+ const delta = String(props.delta ?? "")
130
165
  if (delta) this.handlers.onMessagePartDelta?.({ sessionID, messageID, partID, delta })
131
166
  return
132
167
  }
133
168
  case "session.next.text.ended": {
134
- const messageID = String(payload.messageID ?? "")
135
- const sessionID = String(payload.sessionID ?? "")
169
+ const messageID = String(props.messageID ?? "")
170
+ const sessionID = String(props.sessionID ?? "")
136
171
  this.handlers.onTextEnded?.({ sessionID, messageID })
137
172
  return
138
173
  }
139
174
  case "permission.asked":
140
175
  case "session.next.permission.asked": {
141
- const requestID = String(payload.requestID ?? payload.id ?? "")
142
- const sessionID = String(payload.sessionID ?? "")
176
+ const requestID = String(props.requestID ?? props.id ?? "")
177
+ const sessionID = String(props.sessionID ?? "")
143
178
  this.handlers.onPermissionAsked?.({
144
179
  requestID,
145
180
  sessionID,
146
- tool: payload.tool as string | undefined,
147
- metadata: payload.metadata as Record<string, unknown> | undefined,
181
+ tool: props.tool as string | undefined,
182
+ metadata: props.metadata as Record<string, unknown> | undefined,
148
183
  })
149
184
  return
150
185
  }
151
186
  case "session.error":
152
187
  case "llm.error": {
153
- const message = String((payload.error as { message?: string } | undefined)?.message ?? payload.message ?? "unknown LLM error")
188
+ const message = String((props.error as { message?: string } | undefined)?.message ?? props.message ?? "unknown LLM error")
154
189
  this.handlers.onLlmError?.({ message })
155
190
  return
156
191
  }
@@ -217,3 +252,12 @@ function describeShape(value: unknown): string {
217
252
  if (typeof value !== "object") return typeof value
218
253
  return `object keys=[${Object.keys(value as object).join(",")}]`
219
254
  }
255
+
256
+ function previewRaw(raw: unknown): string {
257
+ try {
258
+ const s = JSON.stringify(raw)
259
+ return s.length > 220 ? s.slice(0, 220) + "…" : s
260
+ } catch {
261
+ return describeShape(raw)
262
+ }
263
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Flatten the server's provider/model list into kid-friendly choices for the
3
+ * `/model` picker. Duck-typed and defensive: opencode's provider list shape has
4
+ * shifted across versions and a missing/!errored endpoint must degrade to an
5
+ * empty list (the picker shows a friendly "no models" note) rather than crash.
6
+ */
7
+
8
+ import type { OpencodeClient } from "./connection.ts"
9
+ import type { ModelChoice } from "./store.ts"
10
+
11
+ const MAX_MODELS = 40
12
+
13
+ export async function listModels(client: OpencodeClient): Promise<ModelChoice[]> {
14
+ const api = client as unknown as {
15
+ provider?: { list?: () => Promise<unknown> }
16
+ config?: { providers?: () => Promise<unknown> }
17
+ }
18
+ let raw: unknown
19
+ try {
20
+ if (typeof api.provider?.list === "function") raw = await api.provider.list()
21
+ else if (typeof api.config?.providers === "function") raw = await api.config.providers()
22
+ else return []
23
+ } catch {
24
+ return []
25
+ }
26
+ return flattenModels(raw).slice(0, MAX_MODELS)
27
+ }
28
+
29
+ export function flattenModels(raw: unknown): ModelChoice[] {
30
+ const providers = pickProviders(raw)
31
+ const out: ModelChoice[] = []
32
+ const seen = new Set<string>()
33
+ for (const p of providers) {
34
+ const prov = p as { id?: string; name?: string; models?: unknown }
35
+ const pid = prov.id ?? ""
36
+ const pname = prov.name ?? pid
37
+ for (const m of pickModels(prov.models)) {
38
+ const mod = m as { id?: string; name?: string }
39
+ const mid = mod.id ?? ""
40
+ if (!pid || !mid) continue
41
+ const fullId = `${pid}/${mid}`
42
+ if (seen.has(fullId)) continue
43
+ seen.add(fullId)
44
+ const mname = mod.name ?? mid
45
+ out.push({ id: fullId, label: pname ? `${mname} · ${pname}` : mname })
46
+ }
47
+ }
48
+ return out
49
+ }
50
+
51
+ function pickProviders(raw: unknown): unknown[] {
52
+ if (Array.isArray(raw)) return raw
53
+ if (raw && typeof raw === "object") {
54
+ const o = raw as { providers?: unknown; data?: { providers?: unknown } }
55
+ if (Array.isArray(o.providers)) return o.providers
56
+ if (Array.isArray(o.data?.providers)) return o.data!.providers as unknown[]
57
+ }
58
+ return []
59
+ }
60
+
61
+ function pickModels(models: unknown): unknown[] {
62
+ if (Array.isArray(models)) return models
63
+ // Most shapes use a Record<modelId, Model> map.
64
+ if (models && typeof models === "object") return Object.values(models)
65
+ return []
66
+ }
@@ -10,6 +10,17 @@
10
10
  */
11
11
 
12
12
  import type { OpencodeClient } from "./connection.ts"
13
+ import type { SessionSummary } from "./store.ts"
14
+ import { debug } from "./debug.ts"
15
+
16
+ /**
17
+ * SDK 1.14.x default is `ThrowOnError=false` which returns the discriminated
18
+ * union { data, error }. If we don't inspect `.error`, auth / 4xx / 5xx
19
+ * responses are silently swallowed and the kid sees "thinking…" forever.
20
+ * Pass this options object to every call so the SDK throws and the
21
+ * orchestrator's existing try/catch can surface the error on ErrorScreen.
22
+ */
23
+ const SDK_THROW: { throwOnError: true } = { throwOnError: true }
13
24
 
14
25
  export class SessionManager {
15
26
  private client: OpencodeClient
@@ -23,36 +34,85 @@ export class SessionManager {
23
34
  return this.currentSessionId
24
35
  }
25
36
 
37
+ /** Forget the current session so the next prompt() opens a fresh one. */
38
+ reset(): void {
39
+ this.currentSessionId = null
40
+ }
41
+
42
+ /** List past sessions (for the `/sessions` picker). Newest-ish first. */
43
+ async list(): Promise<SessionSummary[]> {
44
+ const api = (this.client as unknown as { session?: { list?: () => Promise<unknown> } }).session
45
+ if (!api?.list) return []
46
+ const raw = await api.list()
47
+ const arr = unwrapArray(raw)
48
+ return arr
49
+ .map((s) => {
50
+ const o = s as { id?: string; title?: string }
51
+ if (!o?.id) return null
52
+ return { id: o.id, title: (o.title ?? "").trim() || o.id }
53
+ })
54
+ .filter((s): s is SessionSummary => s !== null)
55
+ }
56
+
57
+ /** Continue an existing session: subsequent prompt()s append to it. */
58
+ switchTo(sessionID: string): void {
59
+ this.currentSessionId = sessionID
60
+ }
61
+
26
62
  async create(): Promise<string> {
27
- const api = (this.client as unknown as { session?: { create: (input?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
63
+ const api = (this.client as unknown as { session?: { create: (input?: unknown, options?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
28
64
  if (!api?.create) throw new Error("SDK v2: client.session.create unavailable")
29
- const result = await api.create({})
65
+ debug("session.create: calling SDK")
66
+ const result = await api.create({}, SDK_THROW)
67
+ debug("session.create: result shape", { keys: result && typeof result === "object" ? Object.keys(result) : typeof result })
30
68
  const id = extractId(result)
31
69
  if (!id) throw new Error("SDK v2 session.create returned no id")
32
70
  this.currentSessionId = id
71
+ debug("session.create: id", { id })
33
72
  return id
34
73
  }
35
74
 
36
75
  async prompt(text: string, opts?: { model?: string; agent?: string }): Promise<void> {
37
76
  if (!this.currentSessionId) await this.create()
38
77
  const sessionID = this.currentSessionId!
39
- const api = (this.client as unknown as { session?: { prompt: (sessionID: string, body: unknown) => Promise<unknown> } }).session
78
+ const api = (this.client as unknown as { session?: { prompt: (parameters: unknown, options?: unknown) => Promise<unknown> } }).session
40
79
  if (!api?.prompt) throw new Error("SDK v2: client.session.prompt unavailable")
41
- await api.prompt(sessionID, {
42
- parts: [{ type: "text", text }],
43
- model: opts?.model,
44
- agent: opts?.agent,
45
- })
80
+ // SDK 1.14.51 signature: single parameters object, kid's text under .prompt.text.
81
+ // Pass SDK_THROW so 4xx/5xx surface as exceptions instead of getting
82
+ // silently swallowed (the bug behind the "thinking…" hang).
83
+ // `model` (from the /model picker) is a "providerID/modelID" string; the SDK
84
+ // wants it split into { providerID, modelID }.
85
+ const model = splitModelId(opts?.model)
86
+ const payload = {
87
+ sessionID,
88
+ prompt: { text },
89
+ ...(model ? { model } : {}),
90
+ ...(opts?.agent ? { agent: opts.agent } : {}),
91
+ }
92
+ debug("session.prompt: sending", { sessionID, textLen: text.length })
93
+ try {
94
+ const result = await api.prompt(payload, SDK_THROW)
95
+ debug("session.prompt: SDK returned", { keys: result && typeof result === "object" ? Object.keys(result) : typeof result })
96
+ } catch (err) {
97
+ debug("session.prompt: SDK threw", { error: errMsg(err) })
98
+ throw err
99
+ }
46
100
  }
47
101
 
48
102
  async abort(): Promise<void> {
49
103
  if (!this.currentSessionId) return
50
- const api = (this.client as unknown as { session?: { abort: (sessionID: string) => Promise<unknown> } }).session
104
+ const api = (this.client as unknown as { session?: { abort: (parameters: unknown, options?: unknown) => Promise<unknown> } }).session
51
105
  if (!api?.abort) return
52
- await api.abort(this.currentSessionId)
106
+ debug("session.abort", { sessionID: this.currentSessionId })
107
+ await api.abort({ sessionID: this.currentSessionId }, SDK_THROW)
53
108
  }
54
109
  }
55
110
 
111
+ function errMsg(err: unknown): string {
112
+ if (err instanceof Error) return `${err.name}: ${err.message}`
113
+ try { return JSON.stringify(err) } catch { return String(err) }
114
+ }
115
+
56
116
  function extractId(result: unknown): string | null {
57
117
  if (typeof result === "string") return result
58
118
  if (result && typeof result === "object") {
@@ -61,3 +121,25 @@ function extractId(result: unknown): string | null {
61
121
  }
62
122
  return null
63
123
  }
124
+
125
+ /**
126
+ * Split a "providerID/modelID" id (as built by the /model picker) into the
127
+ * shape the SDK prompt body wants. Splits on the FIRST slash so model ids that
128
+ * themselves contain "/" survive. Returns undefined for empty/no input.
129
+ */
130
+ function splitModelId(id: string | undefined): { providerID: string; modelID: string } | undefined {
131
+ if (!id) return undefined
132
+ const slash = id.indexOf("/")
133
+ if (slash <= 0 || slash === id.length - 1) return undefined
134
+ return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) }
135
+ }
136
+
137
+ /** SDK list responses come back as `T[]` or `{ data: T[] }` across versions. */
138
+ function unwrapArray(result: unknown): unknown[] {
139
+ if (Array.isArray(result)) return result
140
+ if (result && typeof result === "object") {
141
+ const d = (result as { data?: unknown }).data
142
+ if (Array.isArray(d)) return d
143
+ }
144
+ return []
145
+ }
package/src/core/store.ts CHANGED
@@ -24,8 +24,27 @@ export type Screen =
24
24
  completionMessage: string
25
25
  hasNextMission: boolean
26
26
  }
27
+ // Server-backed pickers, reachable via the `/model` and `/sessions` slash
28
+ // commands. They carry their fetched list so the router stays a pure
29
+ // function of state. `returnTo` is the screen to restore on cancel/select.
30
+ | { kind: "model_picker"; models: ModelChoice[]; returnTo: Screen }
31
+ | { kind: "session_list"; sessions: SessionSummary[]; returnTo: Screen }
27
32
  | { kind: "error"; variant: ErrorVariant; detail?: string }
28
33
 
34
+ /** A selectable AI model, flattened from the server's provider list. */
35
+ export interface ModelChoice {
36
+ /** Full id passed to session.prompt, e.g. "anthropic/claude-3-5-sonnet". */
37
+ id: string
38
+ /** Kid-friendly display label. */
39
+ label: string
40
+ }
41
+
42
+ /** A past session, from session.list(). */
43
+ export interface SessionSummary {
44
+ id: string
45
+ title: string
46
+ }
47
+
29
48
  export type ErrorVariant =
30
49
  | "serve_unreachable"
31
50
  | "port_taken"
@@ -86,6 +105,10 @@ export interface KidsClientState {
86
105
  toast: ToastState | null
87
106
  /** Plugin emitted audit events kept for parent dashboard sync (capped). */
88
107
  auditBuffer: unknown[]
108
+ /** Model id chosen via `/model`; null → server default. Passed to prompt(). */
109
+ selectedModel: string | null
110
+ /** Kid-friendly label of selectedModel, for display in the header/toast. */
111
+ selectedModelLabel: string | null
89
112
  }
90
113
 
91
114
  type Listener = (state: KidsClientState) => void
@@ -107,6 +130,8 @@ const INITIAL: KidsClientState = {
107
130
  missionTotal: null,
108
131
  toast: null,
109
132
  auditBuffer: [],
133
+ selectedModel: null,
134
+ selectedModelLabel: null,
110
135
  }
111
136
 
112
137
  const AUDIT_BUFFER_CAP = 500
package/src/index.tsx CHANGED
@@ -30,6 +30,8 @@ import { Store } from "./core/store.ts"
30
30
  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
+ import { parseSlash, matchCommand } from "./core/commands.ts"
34
+ import { listModels } from "./core/models.ts"
33
35
  import { App } from "./render/ink/App.tsx"
34
36
  import { FREE_PLAY_PACK_ID } from "./render/ink/screens/CoursePackPicker.tsx"
35
37
  import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
@@ -58,6 +60,7 @@ interface FullHandlers {
58
60
  onErrorRetry: () => Promise<void>
59
61
  onPickPack: (packId: string) => void
60
62
  onMissionNext: () => void
63
+ onSessionPick: (sessionId: string) => void
61
64
  }
62
65
 
63
66
  interface AppHandlers {
@@ -75,6 +78,9 @@ interface AppHandlers {
75
78
  onMissionNext: () => void
76
79
  onMissionBack: () => void
77
80
  onMissionExit: () => void
81
+ onModelPick: (modelId: string) => void
82
+ onSessionPick: (sessionId: string) => void
83
+ onPickerClose: () => void
78
84
  onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
79
85
  onSetupContinue: () => Promise<void>
80
86
  onSetupSkip: () => void
@@ -230,6 +236,27 @@ function makeHandlers(
230
236
  onPickerBack: () => store.update({ screen: { kind: "startup" } }),
231
237
  onMissionNext: ifBooted((s) => s.handlers.onMissionNext()),
232
238
  onMissionBack: () => store.update({ screen: { kind: "mission" } }),
239
+ onModelPick: (modelId) => {
240
+ const sc = store.getSnapshot().screen
241
+ if (sc.kind !== "model_picker") return
242
+ const chosen = sc.models.find((m) => m.id === modelId)
243
+ store.update({
244
+ selectedModel: modelId,
245
+ selectedModelLabel: chosen?.label ?? modelId,
246
+ screen: sc.returnTo,
247
+ })
248
+ flashToast(store, {
249
+ kind: "success",
250
+ text: (env.locale === "zh-Hans" ? "已切换模型:" : "Model: ") + (chosen?.label ?? modelId),
251
+ })
252
+ },
253
+ onSessionPick: ifBooted((s, id: string) => s.handlers.onSessionPick(id)),
254
+ onPickerClose: () => {
255
+ const sc = store.getSnapshot().screen
256
+ if (sc.kind === "model_picker" || sc.kind === "session_list") {
257
+ store.update({ screen: sc.returnTo })
258
+ }
259
+ },
233
260
  // Leave an in-progress mission and return to the startup menu. The serve +
234
261
  // session keep running in the background; the kid just re-enters from the
235
262
  // picker. Mirrors onHelpBack / onPickerBack.
@@ -431,7 +458,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
431
458
  process.exit(0)
432
459
  }
433
460
 
434
- const handlers = makeFullHandlers(store, env, session, client, serve)
461
+ const handlers = makeFullHandlers(store, env, session, client, serve, quit)
435
462
 
436
463
  return { audit, serve, client, session, subscriber, quit, handlers }
437
464
  }
@@ -442,6 +469,7 @@ function makeFullHandlers(
442
469
  session: SessionManager,
443
470
  client: OpencodeClient,
444
471
  serve: ServeManager,
472
+ quit: () => Promise<void>,
445
473
  ): FullHandlers {
446
474
  const updateLastSession = (): void => {
447
475
  writeLastSession(env.configDir, {
@@ -466,6 +494,58 @@ function makeFullHandlers(
466
494
  }
467
495
  }
468
496
 
497
+ const zh = env.locale === "zh-Hans"
498
+ const sysMessage = (text: string): void => {
499
+ store.appendMessage({ id: `sys-${Date.now()}`, actor: "system", text, streaming: false, ts: Date.now() })
500
+ }
501
+
502
+ // Handle a kid-safe `/command`. Returns nothing — it mutates the store /
503
+ // opens a picker. Unknown commands get a friendly nudge toward /help. Note
504
+ // `/check` + `/done` are handled earlier in onPrompt (completion triggers)
505
+ // when inside a mission, so they only reach here outside one.
506
+ const dispatchSlash = async (text: string): Promise<void> => {
507
+ const parsed = parseSlash(text)
508
+ const cmd = parsed ? matchCommand(parsed.name) : null
509
+ if (!cmd) {
510
+ sysMessage(zh
511
+ ? `没有「${parsed?.name ?? text}」这个命令。输入 /help 看看能用哪些。`
512
+ : `No command "${parsed?.name ?? text}". Type /help to see what's available.`)
513
+ return
514
+ }
515
+ switch (cmd.id) {
516
+ case "help":
517
+ store.update({ screen: { kind: "help" } })
518
+ return
519
+ case "menu":
520
+ store.update({ screen: { kind: "startup" } })
521
+ return
522
+ case "clear":
523
+ store.update({ messages: [] })
524
+ return
525
+ case "new":
526
+ session.reset()
527
+ store.update({ messages: [] })
528
+ flashToast(store, { kind: "success", text: zh ? "开始一段新对话 ✓" : "New chat ✓" })
529
+ return
530
+ case "quit":
531
+ await quit()
532
+ return
533
+ case "check":
534
+ sysMessage(zh ? "先开始一个项目,再用 /check 验收哦。" : "Start a project first, then use /check.")
535
+ return
536
+ case "model": {
537
+ const models = await listModels(client)
538
+ store.update({ screen: { kind: "model_picker", models, returnTo: store.getSnapshot().screen } })
539
+ return
540
+ }
541
+ case "sessions": {
542
+ const sessions = await session.list()
543
+ store.update({ screen: { kind: "session_list", sessions, returnTo: store.getSnapshot().screen } })
544
+ return
545
+ }
546
+ }
547
+ }
548
+
469
549
  return {
470
550
  onStart: (mode) => {
471
551
  if (mode === "help") {
@@ -535,6 +615,11 @@ function makeFullHandlers(
535
615
  return
536
616
  }
537
617
 
618
+ if (text.trim().startsWith("/")) {
619
+ await dispatchSlash(text)
620
+ return
621
+ }
622
+
538
623
  const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(text) : detectDangerousTopicEn(text)
539
624
  if (hit) {
540
625
  store.update({ dangerousTopic: { category: hit, snippet: text } })
@@ -544,7 +629,7 @@ function makeFullHandlers(
544
629
  store.update({ thinking: true })
545
630
  updateLastSession()
546
631
  try {
547
- await session.prompt(text)
632
+ await session.prompt(text, { model: snap.selectedModel ?? undefined })
548
633
  } catch (err) {
549
634
  store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: errMessage(err) } })
550
635
  }
@@ -641,6 +726,18 @@ function makeFullHandlers(
641
726
  text: env.locale === "zh-Hans" ? `开始:${next.title}` : `Starting: ${next.title}`,
642
727
  })
643
728
  },
729
+ onSessionPick: (sessionId) => {
730
+ session.switchTo(sessionId)
731
+ const sc = store.getSnapshot().screen
732
+ const back = sc.kind === "session_list" ? sc.returnTo : { kind: "mission" as const }
733
+ // We continue the session server-side; the local transcript starts clean
734
+ // (full rehydration of past messages is a later enhancement).
735
+ store.update({ messages: [], screen: back })
736
+ flashToast(store, {
737
+ kind: "info",
738
+ text: env.locale === "zh-Hans" ? "已切到这段对话,继续聊吧" : "Switched to that chat — keep going",
739
+ })
740
+ },
644
741
  }
645
742
  }
646
743
 
@@ -24,6 +24,7 @@ import { MissionCompleteScreen } from "./screens/MissionCompleteScreen.tsx"
24
24
  import { LoadingScreen } from "./screens/LoadingScreen.tsx"
25
25
  import { SetupScreen } from "./screens/SetupScreen.tsx"
26
26
  import { TourScreen } from "./screens/TourScreen.tsx"
27
+ import { PickList } from "./components/PickList.tsx"
27
28
  import type { ProviderId } from "../../core/setup.ts"
28
29
 
29
30
  // Variants where the root cause may be a stale / wrong API key or a missing
@@ -60,6 +61,12 @@ export interface AppDeps {
60
61
  onMissionBack: () => void
61
62
  /** Leave an in-progress mission and return to the startup menu. */
62
63
  onMissionExit: () => void
64
+ /** Pick an AI model from the `/model` picker (id = "provider/model"). */
65
+ onModelPick: (modelId: string) => void
66
+ /** Open a past session from the `/sessions` picker. */
67
+ onSessionPick: (sessionId: string) => void
68
+ /** Cancel a model/session picker and go back to where it was opened from. */
69
+ onPickerClose: () => void
63
70
  onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
64
71
  onSetupContinue: () => Promise<void>
65
72
  onSetupSkip: () => void
@@ -156,6 +163,32 @@ function renderScreen(state: ReturnType<Store["getSnapshot"]>, deps: AppDeps): R
156
163
  onBack={deps.onMissionBack}
157
164
  />
158
165
  )
166
+ case "model_picker": {
167
+ const zh = deps.locale === "zh-Hans"
168
+ return (
169
+ <PickList
170
+ title={zh ? "选一个 AI 模型" : "Pick an AI model"}
171
+ items={state.screen.models.map((m) => ({ id: m.id, label: m.label, sublabel: m.id }))}
172
+ hints={zh ? "[↑↓] 选 · [Enter] 确认 · [Esc] 返回" : "[↑↓] move · [Enter] choose · [Esc] back"}
173
+ emptyText={zh ? "暂时拿不到模型列表,先用默认的吧。" : "No models available right now — using the default."}
174
+ onPick={deps.onModelPick}
175
+ onBack={deps.onPickerClose}
176
+ />
177
+ )
178
+ }
179
+ case "session_list": {
180
+ const zh = deps.locale === "zh-Hans"
181
+ return (
182
+ <PickList
183
+ title={zh ? "之前的对话" : "Your past chats"}
184
+ items={state.screen.sessions.map((s) => ({ id: s.id, label: s.title, sublabel: s.id }))}
185
+ hints={zh ? "[↑↓] 选 · [Enter] 打开 · [Esc] 返回" : "[↑↓] move · [Enter] open · [Esc] back"}
186
+ emptyText={zh ? "还没有以前的对话。" : "No earlier chats yet."}
187
+ onPick={deps.onSessionPick}
188
+ onBack={deps.onPickerClose}
189
+ />
190
+ )
191
+ }
159
192
  case "error":
160
193
  return (
161
194
  <ErrorScreen
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Inline slash-command suggestions, shown above the input the moment the kid's
3
+ * draft starts with "/". Non-interactive on purpose: the kid keeps typing and
4
+ * presses Enter, which submits the draft to onPrompt where it's dispatched.
5
+ * This gives opencode's "type / → see commands" feel without intercepting
6
+ * keystrokes out of the text input. The closest match is highlighted.
7
+ */
8
+
9
+ import React from "react"
10
+ import { Box, Text } from "ink"
11
+ import { getTheme } from "../theme.ts"
12
+ import { filterCommands, commandLabel, commandHint, type Locale } from "../../../core/commands.ts"
13
+
14
+ const MAX_SHOWN = 6
15
+
16
+ export function CommandSuggestions({ query, locale }: { query: string; locale: Locale }): React.ReactElement | null {
17
+ const theme = getTheme()
18
+ const matches = filterCommands(query).slice(0, MAX_SHOWN)
19
+ if (matches.length === 0) {
20
+ return (
21
+ <Box marginBottom={1} flexDirection="column">
22
+ <Text color={theme.fgDim}>
23
+ {locale === "zh-Hans" ? "没有这个命令 — 试试 /help" : "No such command — try /help"}
24
+ </Text>
25
+ </Box>
26
+ )
27
+ }
28
+ // Highlight an exact-ish lead match (full slash typed) so Enter feels obvious.
29
+ const q = query.trim().toLowerCase()
30
+ return (
31
+ <Box marginBottom={1} flexDirection="column">
32
+ {matches.map((c) => {
33
+ const lead = c.slash === q || c.aliases?.includes(q)
34
+ return (
35
+ <Box key={c.id}>
36
+ <Text color={lead ? theme.kid : theme.accent} bold={lead}>{c.slash}</Text>
37
+ <Text color={theme.fg}>{" "}{commandLabel(c, locale)}</Text>
38
+ <Text color={theme.fgDim} dimColor>{" — "}{commandHint(c, locale)}</Text>
39
+ </Box>
40
+ )
41
+ })}
42
+ <Text color={theme.fgDim}>
43
+ {locale === "zh-Hans" ? "输入命令后按 Enter 执行" : "Finish the command and press Enter"}
44
+ </Text>
45
+ </Box>
46
+ )
47
+ }
@@ -14,7 +14,7 @@ interface InputProps {
14
14
  export function Input({ value, onChange, onSubmit, placeholder, disabled }: InputProps): React.ReactElement {
15
15
  const theme = getTheme()
16
16
  return (
17
- <Box borderStyle="single" borderColor={theme.fgDim} paddingX={1}>
17
+ <Box borderStyle="single" borderColor={theme.fgDim} paddingX={1} width="100%">
18
18
  <Text color={theme.kid}>💬 </Text>
19
19
  {disabled ? (
20
20
  <Text color={theme.fgDim} dimColor>
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Generic full-width selectable list — ↑/↓ to move, Enter to pick, Esc to go
3
+ * back. Reused by the `/model` and `/sessions` pickers so they share the
4
+ * kid-friendly look of CoursePackPicker without duplicating the keyboard idiom.
5
+ */
6
+
7
+ import React, { useState } from "react"
8
+ import { Box, Text, useInput } from "ink"
9
+ import { getTheme } from "../theme.ts"
10
+
11
+ export interface PickItem {
12
+ id: string
13
+ label: string
14
+ /** Optional secondary line (e.g. provider, or session id). */
15
+ sublabel?: string
16
+ }
17
+
18
+ interface PickListProps {
19
+ title: string
20
+ items: PickItem[]
21
+ hints: string
22
+ emptyText: string
23
+ onPick: (id: string) => void
24
+ onBack: () => void
25
+ }
26
+
27
+ export function PickList({ title, items, hints, emptyText, onPick, onBack }: PickListProps): React.ReactElement {
28
+ const theme = getTheme()
29
+ const [idx, setIdx] = useState(0)
30
+ useInput((_, key) => {
31
+ if (key.escape || key.leftArrow) onBack()
32
+ else if (key.upArrow) setIdx((i) => Math.max(0, i - 1))
33
+ else if (key.downArrow) setIdx((i) => Math.min(items.length - 1, i + 1))
34
+ else if (key.return && items[idx]) onPick(items[idx]!.id)
35
+ })
36
+ return (
37
+ <Box flexDirection="column" borderStyle="single" borderColor={theme.accent} paddingX={2} paddingY={1} width="100%">
38
+ <Text color={theme.accent} bold>{title}</Text>
39
+ <Box marginTop={1} flexDirection="column">
40
+ {items.length === 0 ? (
41
+ <Text color={theme.fgDim}>{emptyText}</Text>
42
+ ) : (
43
+ items.map((item, i) => {
44
+ const active = i === idx
45
+ return (
46
+ <Box key={item.id}>
47
+ <Text color={active ? theme.kid : theme.fg}>{active ? "▶ " : " "}</Text>
48
+ <Box flexDirection="column" flexGrow={1}>
49
+ <Text color={active ? theme.accent : theme.fg} bold={active}>{item.label}</Text>
50
+ {item.sublabel && <Text color={theme.fgDim} dimColor={!active}> {item.sublabel}</Text>}
51
+ </Box>
52
+ </Box>
53
+ )
54
+ })
55
+ )}
56
+ </Box>
57
+ <Box marginTop={1}>
58
+ <Text color={theme.accent}>{hints}</Text>
59
+ </Box>
60
+ </Box>
61
+ )
62
+ }
@@ -16,6 +16,7 @@ import { ChatStream } from "../components/ChatStream.tsx"
16
16
  import { Input } from "../components/Input.tsx"
17
17
  import { Thinking } from "../components/Thinking.tsx"
18
18
  import { Toast } from "../components/Toast.tsx"
19
+ import { CommandSuggestions } from "../components/CommandSuggestions.tsx"
19
20
  import { getTheme } from "../theme.ts"
20
21
  import { useVoiceInput } from "../useVoiceInput.ts"
21
22
  import type { KidsClientState } from "../../../core/store.ts"
@@ -40,17 +41,24 @@ export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: Miss
40
41
  // they're writing, so spacebar must stay a literal space there.
41
42
  const canTalk = !state.thinking && state.pendingPermission === null && draft.trim() === "" && voice.ready
42
43
 
44
+ // ← (left arrow) is the kid-facing "go back" key, matching TourScreen's ←.
45
+ // It only leaves the mission when the input box is empty so it never fights
46
+ // TextInput's cursor movement mid-typing (same empty-draft gate as talk).
47
+ //
43
48
  // Esc is overloaded so it never eats the kid's typing: while recording it
44
49
  // cancels voice; while the AI is thinking it interrupts; with text typed it
45
- // clears the draft; when idle + empty it leaves back to the startup menu (so
46
- // the kid isn't trapped here — dogfood feedback).
50
+ // clears the draft; when idle + empty it also leaves back to the startup menu
51
+ // (so the kid isn't trapped here — dogfood feedback).
52
+ const canGoBack = !state.thinking && draft.length === 0
47
53
  useInput((input, key) => {
48
54
  if (voiceBusy) {
49
55
  if (key.escape) voice.cancel()
50
56
  else if (key.return || input === " ") voice.stopListening()
51
57
  return
52
58
  }
53
- if (key.escape) {
59
+ if (key.leftArrow && canGoBack) {
60
+ onExit()
61
+ } else if (key.escape) {
54
62
  if (state.thinking) onAbort()
55
63
  else if (draft.length > 0) setDraft("")
56
64
  else onExit()
@@ -61,11 +69,11 @@ export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: Miss
61
69
  })
62
70
 
63
71
  const hint = locale === "zh-Hans"
64
- ? "提示:按「空格」对小助手说话 · /check 或「我做完了」验收 · Esc 打断 AI / 返回菜单"
65
- : "Tip: press Space to talk · type /check or 'I'm done' to validate · Esc interrupts AI / returns to menu"
72
+ ? "提示:打 / 看命令 · 按「空格」说话 · 「我做完了」验收 · 返回菜单 · AI 说话时 Esc 打断"
73
+ : "Tip: type / for commands · Space to talk · 'I'm done' to validate · to go back · Esc interrupts the AI"
66
74
 
67
75
  return (
68
- <Box flexDirection="column">
76
+ <Box flexDirection="column" flexGrow={1}>
69
77
  <Header
70
78
  packTitle={state.packTitle}
71
79
  missionTitle={state.missionTitle}
@@ -82,6 +90,11 @@ export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: Miss
82
90
  </Box>
83
91
  )}
84
92
  </Box>
93
+ {!voiceBusy && draft.trim().startsWith("/") && (
94
+ <Box marginTop={1}>
95
+ <CommandSuggestions query={draft} locale={locale} />
96
+ </Box>
97
+ )}
85
98
  <Box marginTop={1}>
86
99
  {voiceBusy ? (
87
100
  <VoiceBar voiceState={voice.voiceState} meter={voice.meter} mode={voice.mode} locale={locale} theme={theme} />
@@ -37,6 +37,8 @@ function baseState(messages: ChatMessage[]): KidsClientState {
37
37
  missionTotal: 1,
38
38
  toast: null,
39
39
  auditBuffer: [],
40
+ selectedModel: null,
41
+ selectedModelLabel: null,
40
42
  }
41
43
  }
42
44