@kidsinai/kids-client 0.0.20 → 0.0.22
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 +3 -0
- package/src/core/files.ts +41 -0
- package/src/core/session.ts +65 -1
- package/src/core/store.ts +6 -0
- package/src/index.tsx +30 -5
- package/src/render/ink/App.tsx +3 -1
- package/src/render/ink/components/ChatStream.tsx +11 -3
- package/src/render/ink/components/FileSuggestions.tsx +37 -0
- package/src/render/ink/components/MessageBody.tsx +99 -0
- package/src/render/ink/screens/MissionScreen.tsx +49 -6
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.22",
|
|
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.22"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@opencode-ai/sdk": "^1.14.51",
|
package/src/core/commands.ts
CHANGED
|
@@ -45,6 +45,9 @@ export const COMMANDS: KidCommand[] = [
|
|
|
45
45
|
{ id: "clear", slash: "/clear",
|
|
46
46
|
label: { en: "Clear screen", zh: "清屏" },
|
|
47
47
|
hint: { en: "Clear the chat (your files stay)", zh: "清空聊天记录(文件不动)" } },
|
|
48
|
+
{ id: "compact", slash: "/compact",
|
|
49
|
+
label: { en: "Shrink this chat", zh: "压缩对话" },
|
|
50
|
+
hint: { en: "Summarise a long chat to save space", zh: "把很长的对话压缩一下,省空间" } },
|
|
48
51
|
{ id: "menu", slash: "/menu", aliases: ["/home"],
|
|
49
52
|
label: { en: "Main menu", zh: "主菜单" },
|
|
50
53
|
hint: { en: "Back to the start screen", zh: "回到开始界面" } },
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper over the server's `find.files` for the `@file` mention
|
|
3
|
+
* autocomplete. Duck-typed + defensive (like models.ts): a missing endpoint or
|
|
4
|
+
* an error degrades to an empty list rather than crashing the input.
|
|
5
|
+
*
|
|
6
|
+
* Kid-safe: this only *lists* project file paths so the kid can name one. The
|
|
7
|
+
* AI reads it through the existing `read` tool (already on the kid whitelist) —
|
|
8
|
+
* we add no new capability here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { OpencodeClient } from "./connection.ts"
|
|
12
|
+
|
|
13
|
+
const MAX_FILES = 8
|
|
14
|
+
|
|
15
|
+
export async function findFiles(client: OpencodeClient, query: string, limit = MAX_FILES): Promise<string[]> {
|
|
16
|
+
const api = client as unknown as { find?: { files?: (params: unknown, options?: unknown) => Promise<unknown> } }
|
|
17
|
+
if (typeof api.find?.files !== "function") return []
|
|
18
|
+
try {
|
|
19
|
+
const raw = await api.find.files({ query: query || "", type: "file", limit })
|
|
20
|
+
return extractPaths(raw).slice(0, limit)
|
|
21
|
+
} catch {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractPaths(raw: unknown): string[] {
|
|
27
|
+
const arr = Array.isArray(raw)
|
|
28
|
+
? raw
|
|
29
|
+
: raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)
|
|
30
|
+
? ((raw as { data: unknown[] }).data)
|
|
31
|
+
: []
|
|
32
|
+
const out: string[] = []
|
|
33
|
+
for (const item of arr) {
|
|
34
|
+
if (typeof item === "string") out.push(item)
|
|
35
|
+
else if (item && typeof item === "object") {
|
|
36
|
+
const p = (item as { path?: string; absolute?: string }).path ?? (item as { absolute?: string }).absolute
|
|
37
|
+
if (typeof p === "string") out.push(p)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
package/src/core/session.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { OpencodeClient } from "./connection.ts"
|
|
13
|
-
import type { SessionSummary } from "./store.ts"
|
|
13
|
+
import type { SessionSummary, ChatMessage } from "./store.ts"
|
|
14
14
|
import { debug } from "./debug.ts"
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -59,6 +59,33 @@ export class SessionManager {
|
|
|
59
59
|
this.currentSessionId = sessionID
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Load a past session's transcript as ChatMessages (for `/sessions`
|
|
64
|
+
* rehydration) AND make it current. user → kid, assistant → agent (text parts
|
|
65
|
+
* joined); tool/reasoning/control messages are skipped. Defensive: an
|
|
66
|
+
* unavailable endpoint just yields an empty transcript.
|
|
67
|
+
*/
|
|
68
|
+
async loadMessages(sessionID: string): Promise<ChatMessage[]> {
|
|
69
|
+
this.currentSessionId = sessionID
|
|
70
|
+
const api = (this.client as unknown as { session?: { messages?: (p: unknown) => Promise<unknown> } }).session
|
|
71
|
+
if (typeof api?.messages !== "function") return []
|
|
72
|
+
let raw: unknown
|
|
73
|
+
try {
|
|
74
|
+
raw = await api.messages({ sessionID, order: "asc", limit: 200 })
|
|
75
|
+
} catch {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
return unwrapItems(raw).map(mapServerMessage).filter((m): m is ChatMessage => m !== null)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compress a long session server-side (the `/compact` command). */
|
|
82
|
+
async compact(): Promise<void> {
|
|
83
|
+
if (!this.currentSessionId) return
|
|
84
|
+
const api = (this.client as unknown as { session?: { compact?: (p: unknown, o?: unknown) => Promise<unknown> } }).session
|
|
85
|
+
if (typeof api?.compact !== "function") throw new Error("SDK: session.compact unavailable")
|
|
86
|
+
await api.compact({ sessionID: this.currentSessionId }, SDK_THROW)
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
async create(): Promise<string> {
|
|
63
90
|
const api = (this.client as unknown as { session?: { create: (input?: unknown, options?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
|
|
64
91
|
if (!api?.create) throw new Error("SDK v2: client.session.create unavailable")
|
|
@@ -134,6 +161,43 @@ function splitModelId(id: string | undefined): { providerID: string; modelID: st
|
|
|
134
161
|
return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) }
|
|
135
162
|
}
|
|
136
163
|
|
|
164
|
+
/** session.messages returns `{ items }` or `{ data: { items } }`. */
|
|
165
|
+
function unwrapItems(result: unknown): unknown[] {
|
|
166
|
+
if (result && typeof result === "object") {
|
|
167
|
+
const r = result as { items?: unknown; data?: { items?: unknown } }
|
|
168
|
+
if (Array.isArray(r.items)) return r.items
|
|
169
|
+
if (Array.isArray(r.data?.items)) return r.data!.items as unknown[]
|
|
170
|
+
}
|
|
171
|
+
return []
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Map a server SessionMessage to our ChatMessage; null = skip (tool/control). */
|
|
175
|
+
export function mapServerMessage(m: unknown): ChatMessage | null {
|
|
176
|
+
if (!m || typeof m !== "object") return null
|
|
177
|
+
const o = m as {
|
|
178
|
+
id?: string
|
|
179
|
+
type?: string
|
|
180
|
+
text?: string
|
|
181
|
+
content?: Array<{ type?: string; text?: string }>
|
|
182
|
+
time?: { created?: number }
|
|
183
|
+
}
|
|
184
|
+
const id = o.id ?? `srv-${o.time?.created ?? 0}`
|
|
185
|
+
const ts = typeof o.time?.created === "number" ? o.time.created : 0
|
|
186
|
+
if (o.type === "user" && typeof o.text === "string") {
|
|
187
|
+
return { id, actor: "kid", text: o.text, streaming: false, ts }
|
|
188
|
+
}
|
|
189
|
+
if (o.type === "assistant" && Array.isArray(o.content)) {
|
|
190
|
+
const text = o.content
|
|
191
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
192
|
+
.map((p) => p.text)
|
|
193
|
+
.join("")
|
|
194
|
+
.trim()
|
|
195
|
+
if (!text) return null
|
|
196
|
+
return { id, actor: "agent", text, streaming: false, ts }
|
|
197
|
+
}
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
137
201
|
/** SDK list responses come back as `T[]` or `{ data: T[] }` across versions. */
|
|
138
202
|
function unwrapArray(result: unknown): unknown[] {
|
|
139
203
|
if (Array.isArray(result)) return result
|
package/src/core/store.ts
CHANGED
|
@@ -159,6 +159,12 @@ export class Store {
|
|
|
159
159
|
this.notify()
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** Replace the whole transcript at once (used when rehydrating a past session). */
|
|
163
|
+
setMessages(messages: ChatMessage[]): void {
|
|
164
|
+
this.state = { ...this.state, messages }
|
|
165
|
+
this.notify()
|
|
166
|
+
}
|
|
167
|
+
|
|
162
168
|
appendDelta(messageId: string, delta: string): void {
|
|
163
169
|
const messages = this.state.messages.map((m) =>
|
|
164
170
|
m.id === messageId ? { ...m, text: m.text + delta } : m,
|
package/src/index.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import { readLastSession, writeLastSession } from "./core/last-session.ts"
|
|
|
32
32
|
import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
|
|
33
33
|
import { parseSlash, matchCommand } from "./core/commands.ts"
|
|
34
34
|
import { listModels } from "./core/models.ts"
|
|
35
|
+
import { findFiles } from "./core/files.ts"
|
|
35
36
|
import { App } from "./render/ink/App.tsx"
|
|
36
37
|
import { FREE_PLAY_PACK_ID } from "./render/ink/screens/CoursePackPicker.tsx"
|
|
37
38
|
import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
|
|
@@ -61,6 +62,7 @@ interface FullHandlers {
|
|
|
61
62
|
onPickPack: (packId: string) => void
|
|
62
63
|
onMissionNext: () => void
|
|
63
64
|
onSessionPick: (sessionId: string) => void
|
|
65
|
+
onFindFiles: (query: string) => Promise<string[]>
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
interface AppHandlers {
|
|
@@ -81,6 +83,7 @@ interface AppHandlers {
|
|
|
81
83
|
onModelPick: (modelId: string) => void
|
|
82
84
|
onSessionPick: (sessionId: string) => void
|
|
83
85
|
onPickerClose: () => void
|
|
86
|
+
onFindFiles: (query: string) => Promise<string[]>
|
|
84
87
|
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
85
88
|
onSetupContinue: () => Promise<void>
|
|
86
89
|
onSetupSkip: () => void
|
|
@@ -251,6 +254,10 @@ function makeHandlers(
|
|
|
251
254
|
})
|
|
252
255
|
},
|
|
253
256
|
onSessionPick: ifBooted((s, id: string) => s.handlers.onSessionPick(id)),
|
|
257
|
+
onFindFiles: async (query: string) => {
|
|
258
|
+
const s = servicesHolder.current
|
|
259
|
+
return s ? s.handlers.onFindFiles(query) : []
|
|
260
|
+
},
|
|
254
261
|
onPickerClose: () => {
|
|
255
262
|
const sc = store.getSnapshot().screen
|
|
256
263
|
if (sc.kind === "model_picker" || sc.kind === "session_list") {
|
|
@@ -522,6 +529,18 @@ function makeFullHandlers(
|
|
|
522
529
|
case "clear":
|
|
523
530
|
store.update({ messages: [] })
|
|
524
531
|
return
|
|
532
|
+
case "compact":
|
|
533
|
+
if (!session.getId()) {
|
|
534
|
+
sysMessage(zh ? "还没有对话可以压缩。" : "No chat to shrink yet.")
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
await session.compact()
|
|
539
|
+
flashToast(store, { kind: "success", text: zh ? "对话已压缩 ✓" : "Chat shrunk ✓" })
|
|
540
|
+
} catch {
|
|
541
|
+
flashToast(store, { kind: "warn", text: zh ? "压缩没成功,稍后再试" : "Couldn't shrink — try again later" })
|
|
542
|
+
}
|
|
543
|
+
return
|
|
525
544
|
case "new":
|
|
526
545
|
session.reset()
|
|
527
546
|
store.update({ messages: [] })
|
|
@@ -726,18 +745,24 @@ function makeFullHandlers(
|
|
|
726
745
|
text: env.locale === "zh-Hans" ? `开始:${next.title}` : `Starting: ${next.title}`,
|
|
727
746
|
})
|
|
728
747
|
},
|
|
729
|
-
onSessionPick: (sessionId) => {
|
|
730
|
-
session.switchTo(sessionId)
|
|
748
|
+
onSessionPick: async (sessionId) => {
|
|
731
749
|
const sc = store.getSnapshot().screen
|
|
732
750
|
const back = sc.kind === "session_list" ? sc.returnTo : { kind: "mission" as const }
|
|
733
|
-
//
|
|
734
|
-
//
|
|
751
|
+
// Show the chat immediately (loading), then rehydrate the transcript from
|
|
752
|
+
// the server so the kid sees what was said before.
|
|
735
753
|
store.update({ messages: [], screen: back })
|
|
754
|
+
try {
|
|
755
|
+
const past = await session.loadMessages(sessionId)
|
|
756
|
+
store.setMessages(past)
|
|
757
|
+
} catch {
|
|
758
|
+
session.switchTo(sessionId) // at least continue it even if rehydrate failed
|
|
759
|
+
}
|
|
736
760
|
flashToast(store, {
|
|
737
761
|
kind: "info",
|
|
738
|
-
text: env.locale === "zh-Hans" ? "
|
|
762
|
+
text: env.locale === "zh-Hans" ? "打开了这段对话" : "Opened that chat",
|
|
739
763
|
})
|
|
740
764
|
},
|
|
765
|
+
onFindFiles: (query) => findFiles(client, query),
|
|
741
766
|
}
|
|
742
767
|
}
|
|
743
768
|
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -67,6 +67,8 @@ export interface AppDeps {
|
|
|
67
67
|
onSessionPick: (sessionId: string) => void
|
|
68
68
|
/** Cancel a model/session picker and go back to where it was opened from. */
|
|
69
69
|
onPickerClose: () => void
|
|
70
|
+
/** Autocomplete project files for an `@mention`. */
|
|
71
|
+
onFindFiles: (query: string) => Promise<string[]>
|
|
70
72
|
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
71
73
|
onSetupContinue: () => Promise<void>
|
|
72
74
|
onSetupSkip: () => void
|
|
@@ -137,7 +139,7 @@ function renderScreen(state: ReturnType<Store["getSnapshot"]>, deps: AppDeps): R
|
|
|
137
139
|
case "startup":
|
|
138
140
|
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} onQuit={deps.onQuit} />
|
|
139
141
|
case "mission":
|
|
140
|
-
return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} onExit={deps.onMissionExit} />
|
|
142
|
+
return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} onExit={deps.onMissionExit} onFindFiles={deps.onFindFiles} />
|
|
141
143
|
case "help":
|
|
142
144
|
return <HelpScreen locale={deps.locale} onBack={deps.onHelpBack} />
|
|
143
145
|
case "course_picker":
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import React from "react"
|
|
2
2
|
import { Box, Static, Text } from "ink"
|
|
3
3
|
import { getTheme } from "../theme.ts"
|
|
4
|
+
import { MessageBody } from "./MessageBody.tsx"
|
|
4
5
|
import type { ChatMessage } from "../../../core/store.ts"
|
|
5
6
|
|
|
6
7
|
interface ChatStreamProps {
|
|
7
8
|
messages: ChatMessage[]
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
/** Agent text gets markdown/code rendering; kid + system stay plain. */
|
|
12
|
+
function MessageText({ m }: { m: ChatMessage }): React.ReactElement {
|
|
11
13
|
const theme = getTheme()
|
|
14
|
+
const color = colorFor(m.actor, theme)
|
|
15
|
+
if (m.actor === "agent") return <MessageBody text={m.text || " "} color={color} />
|
|
16
|
+
return <Text color={color}>{m.text || " "}</Text>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ChatStream({ messages }: ChatStreamProps): React.ReactElement {
|
|
12
20
|
if (messages.length === 0) return <Box />
|
|
13
21
|
// Settled messages go into <Static> so Ink doesn't re-render the entire
|
|
14
22
|
// history every delta. The active streaming message (if any) renders
|
|
@@ -22,7 +30,7 @@ export function ChatStream({ messages }: ChatStreamProps): React.ReactElement {
|
|
|
22
30
|
<Box key={m.id} flexDirection="row" marginBottom={1}>
|
|
23
31
|
<ActorBadge actor={m.actor} />
|
|
24
32
|
<Box flexDirection="column" flexGrow={1}>
|
|
25
|
-
<
|
|
33
|
+
<MessageText m={m} />
|
|
26
34
|
</Box>
|
|
27
35
|
</Box>
|
|
28
36
|
)}
|
|
@@ -31,7 +39,7 @@ export function ChatStream({ messages }: ChatStreamProps): React.ReactElement {
|
|
|
31
39
|
<Box flexDirection="row" marginBottom={1}>
|
|
32
40
|
<ActorBadge actor={active.actor} />
|
|
33
41
|
<Box flexDirection="column" flexGrow={1}>
|
|
34
|
-
<
|
|
42
|
+
<MessageText m={active} />
|
|
35
43
|
</Box>
|
|
36
44
|
</Box>
|
|
37
45
|
)}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline `@file` suggestions, shown above the input when the kid's current
|
|
3
|
+
* token starts with "@". Like CommandSuggestions, it's non-interactive: the kid
|
|
4
|
+
* keeps typing the path and presses Enter. The matches are fetched by
|
|
5
|
+
* MissionScreen (it owns the client) and passed in.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react"
|
|
9
|
+
import { Box, Text } from "ink"
|
|
10
|
+
import { getTheme } from "../theme.ts"
|
|
11
|
+
import type { Locale } from "../../../core/commands.ts"
|
|
12
|
+
|
|
13
|
+
export function FileSuggestions({ matches, locale }: { matches: string[]; locale: Locale }): React.ReactElement {
|
|
14
|
+
const theme = getTheme()
|
|
15
|
+
if (matches.length === 0) {
|
|
16
|
+
return (
|
|
17
|
+
<Box marginBottom={1}>
|
|
18
|
+
<Text color={theme.fgDim}>
|
|
19
|
+
{locale === "zh-Hans" ? "没找到这个文件 — 继续打它的名字" : "No file yet — keep typing its name"}
|
|
20
|
+
</Text>
|
|
21
|
+
</Box>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<Box marginBottom={1} flexDirection="column">
|
|
26
|
+
{matches.map((path) => (
|
|
27
|
+
<Box key={path}>
|
|
28
|
+
<Text color={theme.accent}>@</Text>
|
|
29
|
+
<Text color={theme.fg}>{path}</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
))}
|
|
32
|
+
<Text color={theme.fgDim}>
|
|
33
|
+
{locale === "zh-Hans" ? "打上文件名,AI 就能读它" : "Name a file and the AI can read it"}
|
|
34
|
+
</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight markdown-ish renderer for the AI's chat messages — no heavy dep.
|
|
3
|
+
* Handles the things a kid actually sees from a coding mentor:
|
|
4
|
+
* - fenced ```code``` blocks (incl. an unterminated one while streaming)
|
|
5
|
+
* - headings (#, ##), bullet lists (-, *), and inline **bold** / `code`
|
|
6
|
+
* Everything else renders as plain wrapped text. Kid/system messages stay plain
|
|
7
|
+
* (they don't author markdown); only agent text is enriched.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from "react"
|
|
11
|
+
import { Box, Text } from "ink"
|
|
12
|
+
import { getTheme } from "../theme.ts"
|
|
13
|
+
|
|
14
|
+
type Theme = ReturnType<typeof getTheme>
|
|
15
|
+
|
|
16
|
+
export interface Block {
|
|
17
|
+
type: "code" | "text"
|
|
18
|
+
/** code language label (may be ""); unused for text blocks. */
|
|
19
|
+
lang?: string
|
|
20
|
+
content: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Split message text into fenced-code vs prose blocks. */
|
|
24
|
+
export function splitBlocks(text: string): Block[] {
|
|
25
|
+
const lines = text.split("\n")
|
|
26
|
+
const blocks: Block[] = []
|
|
27
|
+
let mode: "text" | "code" = "text"
|
|
28
|
+
let buf: string[] = []
|
|
29
|
+
let lang = ""
|
|
30
|
+
const flush = (type: "code" | "text"): void => {
|
|
31
|
+
if (buf.length === 0 && type === "text") return
|
|
32
|
+
blocks.push({ type, lang: type === "code" ? lang : undefined, content: buf.join("\n") })
|
|
33
|
+
buf = []
|
|
34
|
+
}
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const fence = line.match(/^```(\w*)\s*$/)
|
|
37
|
+
if (fence) {
|
|
38
|
+
if (mode === "text") { flush("text"); mode = "code"; lang = fence[1] ?? "" }
|
|
39
|
+
else { flush("code"); mode = "text"; lang = "" }
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
buf.push(line)
|
|
43
|
+
}
|
|
44
|
+
// Unterminated fence while streaming → keep what we have as a code block.
|
|
45
|
+
flush(mode)
|
|
46
|
+
return blocks
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse inline **bold** and `code` into Ink <Text> spans. */
|
|
50
|
+
export function renderInline(line: string, theme: Theme, keyPrefix: string): React.ReactNode[] {
|
|
51
|
+
const out: React.ReactNode[] = []
|
|
52
|
+
const re = /(\*\*([^*]+)\*\*|`([^`]+)`)/g
|
|
53
|
+
let last = 0
|
|
54
|
+
let m: RegExpExecArray | null
|
|
55
|
+
let i = 0
|
|
56
|
+
while ((m = re.exec(line)) !== null) {
|
|
57
|
+
if (m.index > last) out.push(<Text key={`${keyPrefix}-t${i}`}>{line.slice(last, m.index)}</Text>)
|
|
58
|
+
if (m[2] !== undefined) out.push(<Text key={`${keyPrefix}-b${i}`} bold>{m[2]}</Text>)
|
|
59
|
+
else if (m[3] !== undefined) out.push(<Text key={`${keyPrefix}-c${i}`} color={theme.accent}>{m[3]}</Text>)
|
|
60
|
+
last = m.index + m[0].length
|
|
61
|
+
i++
|
|
62
|
+
}
|
|
63
|
+
if (last < line.length) out.push(<Text key={`${keyPrefix}-t${i}`}>{line.slice(last)}</Text>)
|
|
64
|
+
if (out.length === 0) out.push(<Text key={`${keyPrefix}-empty`}> </Text>)
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function MessageBody({ text, color }: { text: string; color: string }): React.ReactElement {
|
|
69
|
+
const theme = getTheme()
|
|
70
|
+
const blocks = splitBlocks(text)
|
|
71
|
+
return (
|
|
72
|
+
<Box flexDirection="column">
|
|
73
|
+
{blocks.map((b, bi) => {
|
|
74
|
+
if (b.type === "code") {
|
|
75
|
+
return (
|
|
76
|
+
<Box key={`code-${bi}`} flexDirection="column" borderStyle="round" borderColor={theme.fgDim} paddingX={1}>
|
|
77
|
+
{b.lang ? <Text color={theme.fgDim} dimColor>{b.lang}</Text> : null}
|
|
78
|
+
{(b.content || " ").split("\n").map((l, li) => (
|
|
79
|
+
<Text key={li} color={theme.success}>{l || " "}</Text>
|
|
80
|
+
))}
|
|
81
|
+
</Box>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
<Box key={`text-${bi}`} flexDirection="column">
|
|
86
|
+
{b.content.split("\n").map((line, li) => {
|
|
87
|
+
const key = `${bi}-${li}`
|
|
88
|
+
const heading = line.match(/^(#{1,3})\s+(.*)$/)
|
|
89
|
+
if (heading) return <Text key={key} color={theme.accent} bold>{heading[2]}</Text>
|
|
90
|
+
const bullet = line.match(/^\s*[-*]\s+(.*)$/)
|
|
91
|
+
if (bullet) return <Text key={key} color={color}>{"• "}{renderInline(bullet[1] ?? "", theme, key)}</Text>
|
|
92
|
+
return <Text key={key} color={color}>{renderInline(line, theme, key)}</Text>
|
|
93
|
+
})}
|
|
94
|
+
</Box>
|
|
95
|
+
)
|
|
96
|
+
})}
|
|
97
|
+
</Box>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* client.session.abort()).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import React, { useState } from "react"
|
|
12
|
+
import React, { useState, useEffect } from "react"
|
|
13
13
|
import { Box, Text, useInput } from "ink"
|
|
14
14
|
import { Header } from "../components/Header.tsx"
|
|
15
15
|
import { ChatStream } from "../components/ChatStream.tsx"
|
|
@@ -17,10 +17,15 @@ import { Input } from "../components/Input.tsx"
|
|
|
17
17
|
import { Thinking } from "../components/Thinking.tsx"
|
|
18
18
|
import { Toast } from "../components/Toast.tsx"
|
|
19
19
|
import { CommandSuggestions } from "../components/CommandSuggestions.tsx"
|
|
20
|
+
import { FileSuggestions } from "../components/FileSuggestions.tsx"
|
|
20
21
|
import { getTheme } from "../theme.ts"
|
|
21
22
|
import { useVoiceInput } from "../useVoiceInput.ts"
|
|
22
23
|
import type { KidsClientState } from "../../../core/store.ts"
|
|
23
24
|
|
|
25
|
+
// Module-level so the kid's prompt history survives MissionScreen remounts
|
|
26
|
+
// (e.g. after opening a picker and coming back) within one process.
|
|
27
|
+
const promptHistory: string[] = []
|
|
28
|
+
|
|
24
29
|
interface MissionScreenProps {
|
|
25
30
|
state: KidsClientState
|
|
26
31
|
locale: "zh-Hans" | "en"
|
|
@@ -28,13 +33,33 @@ interface MissionScreenProps {
|
|
|
28
33
|
onAbort: () => void
|
|
29
34
|
/** Leave the mission and return to the startup menu. */
|
|
30
35
|
onExit: () => void
|
|
36
|
+
/** Autocomplete project files for an `@mention`. Optional (demo/tests omit). */
|
|
37
|
+
onFindFiles?: (query: string) => Promise<string[]>
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: MissionScreenProps): React.ReactElement {
|
|
40
|
+
export function MissionScreen({ state, locale, onPrompt, onAbort, onExit, onFindFiles }: MissionScreenProps): React.ReactElement {
|
|
34
41
|
const theme = getTheme()
|
|
35
42
|
const [draft, setDraft] = useState("")
|
|
43
|
+
// -1 = not browsing history; otherwise an index into promptHistory.
|
|
44
|
+
const [historyIdx, setHistoryIdx] = useState(-1)
|
|
45
|
+
const [fileMatches, setFileMatches] = useState<string[]>([])
|
|
36
46
|
const placeholder = locale === "zh-Hans" ? "想做什么?告诉我吧(中文/英文都行)" : "What would you like to make? (English or Chinese)"
|
|
37
47
|
|
|
48
|
+
// `@file` autocomplete: when the current (last) token starts with "@", fetch
|
|
49
|
+
// matching project files. Plain display — the kid finishes the name and the
|
|
50
|
+
// AI reads it via the `read` tool.
|
|
51
|
+
const lastToken = draft.split(/\s/).pop() ?? ""
|
|
52
|
+
const atQuery = !draft.trim().startsWith("/") && lastToken.startsWith("@") ? lastToken.slice(1) : null
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (atQuery === null || !onFindFiles) {
|
|
55
|
+
setFileMatches([])
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
let cancelled = false
|
|
59
|
+
onFindFiles(atQuery).then((m) => { if (!cancelled) setFileMatches(m) }).catch(() => {})
|
|
60
|
+
return () => { cancelled = true }
|
|
61
|
+
}, [atQuery, onFindFiles])
|
|
62
|
+
|
|
38
63
|
const voice = useVoiceInput(onPrompt)
|
|
39
64
|
const voiceBusy = voice.voiceState !== "idle"
|
|
40
65
|
// Spacebar talks ONLY when the kid isn't mid-typing — a non-empty draft means
|
|
@@ -60,8 +85,19 @@ export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: Miss
|
|
|
60
85
|
onExit()
|
|
61
86
|
} else if (key.escape) {
|
|
62
87
|
if (state.thinking) onAbort()
|
|
63
|
-
else if (draft.length > 0) setDraft("")
|
|
88
|
+
else if (draft.length > 0) { setDraft(""); setHistoryIdx(-1) }
|
|
64
89
|
else onExit()
|
|
90
|
+
} else if (key.upArrow && (draft.length === 0 || historyIdx >= 0)) {
|
|
91
|
+
// ↑ recalls earlier prompts. Works from an empty box, or keeps walking
|
|
92
|
+
// back once we're already browsing history.
|
|
93
|
+
if (promptHistory.length === 0) return
|
|
94
|
+
const next = historyIdx < 0 ? promptHistory.length - 1 : Math.max(0, historyIdx - 1)
|
|
95
|
+
setHistoryIdx(next)
|
|
96
|
+
setDraft(promptHistory[next] ?? "")
|
|
97
|
+
} else if (key.downArrow && historyIdx >= 0) {
|
|
98
|
+
const next = historyIdx + 1
|
|
99
|
+
if (next >= promptHistory.length) { setHistoryIdx(-1); setDraft("") }
|
|
100
|
+
else { setHistoryIdx(next); setDraft(promptHistory[next] ?? "") }
|
|
65
101
|
} else if (input === " " && canTalk) {
|
|
66
102
|
setDraft("")
|
|
67
103
|
voice.startListening()
|
|
@@ -69,8 +105,8 @@ export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: Miss
|
|
|
69
105
|
})
|
|
70
106
|
|
|
71
107
|
const hint = locale === "zh-Hans"
|
|
72
|
-
? "提示:打 / 看命令 ·
|
|
73
|
-
: "Tip:
|
|
108
|
+
? "提示:打 / 看命令 · @ 选文件 · ↑ 上一句 · 「空格」说话 · 「我做完了」验收 · ← 返回 · Esc 打断"
|
|
109
|
+
: "Tip: / commands · @ files · ↑ last prompt · Space to talk · 'I'm done' to validate · ← back · Esc interrupts"
|
|
74
110
|
|
|
75
111
|
return (
|
|
76
112
|
<Box flexDirection="column" flexGrow={1}>
|
|
@@ -95,16 +131,23 @@ export function MissionScreen({ state, locale, onPrompt, onAbort, onExit }: Miss
|
|
|
95
131
|
<CommandSuggestions query={draft} locale={locale} />
|
|
96
132
|
</Box>
|
|
97
133
|
)}
|
|
134
|
+
{!voiceBusy && atQuery !== null && (
|
|
135
|
+
<Box marginTop={1}>
|
|
136
|
+
<FileSuggestions matches={fileMatches} locale={locale} />
|
|
137
|
+
</Box>
|
|
138
|
+
)}
|
|
98
139
|
<Box marginTop={1}>
|
|
99
140
|
{voiceBusy ? (
|
|
100
141
|
<VoiceBar voiceState={voice.voiceState} meter={voice.meter} mode={voice.mode} locale={locale} theme={theme} />
|
|
101
142
|
) : (
|
|
102
143
|
<Input
|
|
103
144
|
value={draft}
|
|
104
|
-
onChange={setDraft}
|
|
145
|
+
onChange={(v) => { setDraft(v); setHistoryIdx(-1) }}
|
|
105
146
|
onSubmit={(v) => {
|
|
106
147
|
const text = v.trim()
|
|
107
148
|
if (!text) return
|
|
149
|
+
if (promptHistory[promptHistory.length - 1] !== text) promptHistory.push(text)
|
|
150
|
+
setHistoryIdx(-1)
|
|
108
151
|
setDraft("")
|
|
109
152
|
onPrompt(text)
|
|
110
153
|
}}
|