@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 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.20",
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.20"
38
+ "@kidsinai/kids-opencode-plugin": "^0.0.22"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@opencode-ai/sdk": "^1.14.51",
@@ -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
+ }
@@ -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
- // We continue the session server-side; the local transcript starts clean
734
- // (full rehydration of past messages is a later enhancement).
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" ? "已切到这段对话,继续聊吧" : "Switched to that chat — keep going",
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
 
@@ -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
- export function ChatStream({ messages }: ChatStreamProps): React.ReactElement {
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
- <Text color={colorFor(m.actor, theme)}>{m.text || " "}</Text>
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
- <Text color={colorFor(active.actor, theme)}>{active.text || " "}</Text>
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
- ? "提示:打 / 看命令 · 按「空格」说话 · 「我做完了」验收 · 返回菜单 · AI 说话时 Esc 打断"
73
- : "Tip: type / for commands · Space to talk · 'I'm done' to validate · ← to go back · Esc interrupts the AI"
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
  }}