@kidsinai/kids-client 0.0.18 → 0.0.19

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.19",
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.19"
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
+ }
@@ -94,63 +94,85 @@ export class EventSubscriber {
94
94
  }
95
95
 
96
96
  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.
97
+ // The SDK shape evolved over the 1.14.x line. Yielded events arrive as:
98
+ // • { payload: { type, properties: { … } } } (current — 1.14.51 GlobalEvent)
99
+ // • { payload: { type, …flatfields } } (olderflat fields on payload)
100
+ // { type, properties: { … } } (some intermediate releases)
101
+ // • { type, …flatfields } (oldest)
102
+ // • { data: { type, …. } } (StreamEvent helper wrapper)
103
+ //
104
+ // CRITICAL: the current 1.14.51 GlobalEvent puts event fields
105
+ // (sessionID, messageID, delta, …) under .payload.properties, NOT
106
+ // directly on .payload. The previous code read .payload.sessionID
107
+ // (always undefined), so deltas were silently dropped and the
108
+ // "thinking" indicator never cleared. This split + the
109
+ // `props ?? payload` fallback handle both layouts uniformly.
104
110
  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>)
111
+ const payload: ({ type?: string; properties?: Record<string, unknown> } & Record<string, unknown>) | null =
112
+ (e?.payload && typeof e.payload === "object") ? (e.payload as { type?: string; properties?: Record<string, unknown> } & Record<string, unknown>)
113
+ : (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>)
114
+ : (typeof e?.type === "string") ? (e as { type?: string; properties?: Record<string, unknown> } & Record<string, unknown>)
109
115
  : null
110
116
  if (!payload || typeof payload.type !== "string") return
111
- const t = payload.type
117
+ let t = payload.type
118
+ // Where the actual event fields live: `.properties` (Event* legacy shape)
119
+ // OR `.data` (SyncEvent* shape) OR flat on payload (oldest).
120
+ let props: Record<string, unknown> = (payload.properties && typeof payload.properties === "object")
121
+ ? payload.properties as Record<string, unknown>
122
+ : payload
123
+ // 1.14.51 Sync events: type="sync", real event name in .name with a
124
+ // version suffix like ".1", fields under .data. Without this normalize,
125
+ // our switch never matches and streaming text events drop on the floor
126
+ // (kid sees "thinking..." forever).
127
+ if (t === "sync" && typeof (payload as { name?: unknown }).name === "string") {
128
+ t = String((payload as { name: string }).name).replace(/\.\d+$/, "")
129
+ const dataField = (payload as { data?: unknown }).data
130
+ if (dataField && typeof dataField === "object") {
131
+ props = dataField as Record<string, unknown>
132
+ }
133
+ }
112
134
  switch (t) {
113
135
  case "session.created":
114
136
  case "session.next.session.created":
115
- this.handlers.onSessionCreated?.({ sessionID: String(payload.sessionID ?? "") })
137
+ this.handlers.onSessionCreated?.({ sessionID: String(props.sessionID ?? "") })
116
138
  return
117
139
  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 ?? "")
140
+ const messageID = String(props.messageID ?? "")
141
+ const partID = String(props.partID ?? "")
142
+ const sessionID = String(props.sessionID ?? "")
143
+ const delta = String((props.delta as { text?: string } | undefined)?.text ?? props.delta ?? "")
122
144
  if (delta) this.handlers.onMessagePartDelta?.({ sessionID, messageID, partID, delta })
123
145
  return
124
146
  }
125
147
  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 ?? "")
148
+ const messageID = String(props.messageID ?? "")
149
+ const partID = String(props.partID ?? "stream")
150
+ const sessionID = String(props.sessionID ?? "")
151
+ const delta = String(props.delta ?? "")
130
152
  if (delta) this.handlers.onMessagePartDelta?.({ sessionID, messageID, partID, delta })
131
153
  return
132
154
  }
133
155
  case "session.next.text.ended": {
134
- const messageID = String(payload.messageID ?? "")
135
- const sessionID = String(payload.sessionID ?? "")
156
+ const messageID = String(props.messageID ?? "")
157
+ const sessionID = String(props.sessionID ?? "")
136
158
  this.handlers.onTextEnded?.({ sessionID, messageID })
137
159
  return
138
160
  }
139
161
  case "permission.asked":
140
162
  case "session.next.permission.asked": {
141
- const requestID = String(payload.requestID ?? payload.id ?? "")
142
- const sessionID = String(payload.sessionID ?? "")
163
+ const requestID = String(props.requestID ?? props.id ?? "")
164
+ const sessionID = String(props.sessionID ?? "")
143
165
  this.handlers.onPermissionAsked?.({
144
166
  requestID,
145
167
  sessionID,
146
- tool: payload.tool as string | undefined,
147
- metadata: payload.metadata as Record<string, unknown> | undefined,
168
+ tool: props.tool as string | undefined,
169
+ metadata: props.metadata as Record<string, unknown> | undefined,
148
170
  })
149
171
  return
150
172
  }
151
173
  case "session.error":
152
174
  case "llm.error": {
153
- const message = String((payload.error as { message?: string } | undefined)?.message ?? payload.message ?? "unknown LLM error")
175
+ const message = String((props.error as { message?: string } | undefined)?.message ?? props.message ?? "unknown LLM error")
154
176
  this.handlers.onLlmError?.({ message })
155
177
  return
156
178
  }
@@ -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,7 @@
10
10
  */
11
11
 
12
12
  import type { OpencodeClient } from "./connection.ts"
13
+ import type { SessionSummary } from "./store.ts"
13
14
 
14
15
  export class SessionManager {
15
16
  private client: OpencodeClient
@@ -23,6 +24,31 @@ export class SessionManager {
23
24
  return this.currentSessionId
24
25
  }
25
26
 
27
+ /** Forget the current session so the next prompt() opens a fresh one. */
28
+ reset(): void {
29
+ this.currentSessionId = null
30
+ }
31
+
32
+ /** List past sessions (for the `/sessions` picker). Newest-ish first. */
33
+ async list(): Promise<SessionSummary[]> {
34
+ const api = (this.client as unknown as { session?: { list?: () => Promise<unknown> } }).session
35
+ if (!api?.list) return []
36
+ const raw = await api.list()
37
+ const arr = unwrapArray(raw)
38
+ return arr
39
+ .map((s) => {
40
+ const o = s as { id?: string; title?: string }
41
+ if (!o?.id) return null
42
+ return { id: o.id, title: (o.title ?? "").trim() || o.id }
43
+ })
44
+ .filter((s): s is SessionSummary => s !== null)
45
+ }
46
+
47
+ /** Continue an existing session: subsequent prompt()s append to it. */
48
+ switchTo(sessionID: string): void {
49
+ this.currentSessionId = sessionID
50
+ }
51
+
26
52
  async create(): Promise<string> {
27
53
  const api = (this.client as unknown as { session?: { create: (input?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
28
54
  if (!api?.create) throw new Error("SDK v2: client.session.create unavailable")
@@ -61,3 +87,13 @@ function extractId(result: unknown): string | null {
61
87
  }
62
88
  return null
63
89
  }
90
+
91
+ /** SDK list responses come back as `T[]` or `{ data: T[] }` across versions. */
92
+ function unwrapArray(result: unknown): unknown[] {
93
+ if (Array.isArray(result)) return result
94
+ if (result && typeof result === "object") {
95
+ const d = (result as { data?: unknown }).data
96
+ if (Array.isArray(d)) return d
97
+ }
98
+ return []
99
+ }
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