@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 +2 -2
- package/src/core/commands.ts +105 -0
- package/src/core/debug.ts +51 -0
- package/src/core/events.ts +72 -28
- package/src/core/models.ts +66 -0
- package/src/core/session.ts +92 -10
- package/src/core/store.ts +25 -0
- package/src/index.tsx +99 -2
- package/src/render/ink/App.tsx +33 -0
- package/src/render/ink/components/CommandSuggestions.tsx +47 -0
- package/src/render/ink/components/Input.tsx +1 -1
- package/src/render/ink/components/PickList.tsx +62 -0
- package/src/render/ink/screens/MissionScreen.tsx +19 -6
- package/src/voice-demo.tsx +2 -0
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.
|
|
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.
|
|
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
|
+
}
|
package/src/core/events.ts
CHANGED
|
@@ -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
|
-
//
|
|
98
|
-
// • { payload: { type, …
|
|
99
|
-
// • { type, …
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// • { data: { type,
|
|
103
|
-
//
|
|
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 } } (older — flat 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
|
-
|
|
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(
|
|
150
|
+
this.handlers.onSessionCreated?.({ sessionID: String(props.sessionID ?? "") })
|
|
116
151
|
return
|
|
117
152
|
case "message.part.delta": {
|
|
118
|
-
const messageID = String(
|
|
119
|
-
const partID = String(
|
|
120
|
-
const sessionID = String(
|
|
121
|
-
const delta = String((
|
|
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(
|
|
127
|
-
const partID = String(
|
|
128
|
-
const sessionID = String(
|
|
129
|
-
const delta = String(
|
|
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(
|
|
135
|
-
const sessionID = String(
|
|
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(
|
|
142
|
-
const sessionID = String(
|
|
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:
|
|
147
|
-
metadata:
|
|
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((
|
|
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
|
+
}
|
package/src/core/session.ts
CHANGED
|
@@ -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
|
-
|
|
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: (
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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: (
|
|
104
|
+
const api = (this.client as unknown as { session?: { abort: (parameters: unknown, options?: unknown) => Promise<unknown> } }).session
|
|
51
105
|
if (!api?.abort) return
|
|
52
|
-
|
|
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
|
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
? "
|
|
65
|
-
: "Tip:
|
|
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} />
|